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/checks/impl/CrossEntryCheckTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/CrossEntryCheckTest.java index 637e3fe413..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 @@ -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,33 @@ 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.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 +73,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 +98,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 +118,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 +142,408 @@ 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(), is(List.of())); + 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(); + + portfolio.getTransactions().clear(); + + List issues = new CrossEntryCheck().execute(client); + + assertThat(issues.size(), is(0)); + assertThat(account.getTransactions().size(), is(1)); + assertThat(portfolio.getTransactions().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)); + } + + /** + * 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 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()); + + targetAccount.getTransactions().clear(); + + List issues = new CrossEntryCheck().execute(client); + + 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, + 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()); + + targetPortfolio.getTransactions().clear(); + + issues = new CrossEntryCheck().execute(client); + + 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)); + } + + /** + * 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; + return ledgerBacked.getLedgerProjectionDescriptor().getPrimaryPosting(); + } + + private LedgerPosting primaryPosting(PortfolioTransaction transaction) + { + var ledgerBacked = (LedgerBackedTransaction) transaction; + return ledgerBacked.getLedgerProjectionDescriptor().getPrimaryPosting(); + } + + private LedgerEntry ledgerEntry(Transaction transaction) + { + return ((LedgerBackedTransaction) transaction).getLedgerEntry(); + } + + 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 testThatMatchingBuySellEntriesAreFixed() + public void testThatMatchingBuySellEntriesAreReportedDeleteOnly() { LocalDateTime date = LocalDateTime.now(); portfolio.addTransaction(new PortfolioTransaction(date, CurrencyUnit.EUR, 1, security, 1, @@ -107,11 +552,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 +582,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 +602,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 +622,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 +650,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 +673,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 +693,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 +721,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 +762,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/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java new file mode 100644 index 0000000000..63e072d582 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/checks/impl/OnlyAccountTransactionsWithSecurityCanHaveExDateCheckTest.java @@ -0,0 +1,195 @@ +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.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; +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"); + + client.addAccount(account); + + entry.setType(LedgerEntryType.DIVIDENDS); + entry.setDateTime(DATE_TIME); + + posting.setType(LedgerPostingType.FEE); + posting.setAccount(account); + posting.setAmount(Values.Amount.factorize(10)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.FEE); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, EX_DATE)); + entry.addPosting(posting); + + 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 = 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(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)); + + var refreshed = account.getTransactions().get(0); + + assertTrue(refreshed instanceof LedgerBackedTransaction); + assertThat(((LedgerBackedTransaction) refreshed).getLedgerProjectionDescriptor().getRuntimeProjectionId(), + 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"); + + client.addAccount(account); + + entry.setType(LedgerEntryType.FEES); + entry.setDateTime(DATE_TIME); + + posting.setType(LedgerPostingType.FEE); + posting.setAccount(account); + posting.setAmount(Values.Amount.factorize(10)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.FEE); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.addParameter(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, EX_DATE)); + entry.addPosting(posting); + + 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).getType(), is(LedgerEntryType.FEES)); + 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..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 @@ -1,31 +1,63 @@ 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.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.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 +65,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 +96,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 +120,565 @@ 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.getLedgerProjectionDescriptor().getRuntimeProjectionId(); + 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.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); + + LedgerImportInsertionSupport insert = new LedgerImportInsertionSupport(client); + + var exception = assertThrows(UnsupportedOperationException.class, + () -> insert.updateLedgerBackedInvestmentPlanTransaction(generated, imported)); + + assertThat(exception.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(client.getLedger().getEntries().stream() + .filter(entry -> plan.getPlanKey().equals(entry.getGeneratedByPlanKey())).count(), is(1L)); + 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(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); + } + + @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 +694,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..a499a3511b --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/actions/LedgerImportWriteGuardrailTest.java @@ -0,0 +1,424 @@ +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 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.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 generatedPlanKey = generatedEntry.getGeneratedByPlanKey(); + 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)); + 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()); + } + + /** + * 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 generatedPlanKey = generatedEntry.getGeneratedByPlanKey(); + 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)); + 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()); + } + + /** + * 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 generatedPlanKey = generatedEntry.getGeneratedByPlanKey(); + 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(generatedEntry.getGeneratedByPlanKey(), is(generatedPlanKey)); + 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 generatedPlanKey = generatedEntry.getGeneratedByPlanKey(); + 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(generatedEntry.getGeneratedByPlanKey(), is(generatedPlanKey)); + 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); + 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); + } + + 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 generatedPlanKey) throws Exception + { + assertLoadedRoundtrip(loadXml(saveXml(client)), generatedPlanKey); + assertLoadedRoundtrip(loadProtobuf(saveProtobuf(client)), generatedPlanKey); + } + + private void assertLoadedRoundtrip(Client client, String generatedPlanKey) + { + 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); + } + + 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(), + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).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(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).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 runtimeId, LedgerProjectionRole role, Account account, Portfolio portfolio, + String primaryPostingId, String groupKey) + { + static ProjectionSnapshot of( + 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()); + } + } + + 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/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/InvestmentPlanTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/InvestmentPlanTest.java index cc2546171e..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 @@ -1,11 +1,19 @@ 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.hamcrest.Matchers.nullValue; +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 +28,18 @@ 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.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,444 @@ 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 metadata. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ + @Test + public void testLedgerGenerationOfBuyStoresPortfolioExecutionMetadata() 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(0)); + 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; + 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 metadata. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ + @Test + public void testLedgerGenerationOfDeliveryStoresPortfolioExecutionMetadata() 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(0)); + 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; + 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 metadata. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ + @Test + public void testLedgerGenerationOfAccountOnlyStoresAccountExecutionMetadata() 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(0)); + 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; + 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 metadata. + * The plan reference must resolve to the intended transaction after the operation. + * This prevents stale or ambiguous generated booking references. + */ + @Test + public void testLedgerGenerationOfDepositWithoutUnitsStoresAccountExecutionMetadata() 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(0)); + 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; + assertPlanExecutionMetadata(ledgerBacked, InvestmentPlan.LedgerExecutionViewKind.ACCOUNT); + assertThat(investmentPlan.getTransactions(client).get(0).getTransaction(), is(transaction)); + 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 metadata. + * 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(0)); + 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(((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 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 testLedgerDeletionClearsExecutionMetadataBeforeProtobufRoundtrip() 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(0)); + 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(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() + .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(0)); + assertThat(loaded.getPlans().get(1).getTransactions(loaded), 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 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 testGeneratedLedgerExecutionMetadataSurvivesXmlAndProtobufRoundtrip() 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); + assertFalse(xml.contains("")); + assertThat(xml, containsString("" + investmentPlan.getPlanKey() + "")); + assertThat(xml, containsString("" + investmentPlan.getPlanKey() + + "")); + assertFalse(xml.contains(" 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/LedgerOwnerChangeFailurePathTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerOwnerChangeFailurePathTest.java new file mode 100644 index 0000000000..b520daab20 --- /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.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(), + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).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 runtimeId, Object 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/LedgerProtobufPersistenceTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java new file mode 100644 index 0000000000..e0a6fe58dd --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceTest.java @@ -0,0 +1,1379 @@ +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.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; +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.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.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.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; +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; +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 testSaveWritesSemanticLedgerTruthOnField13AndAccountShadow() 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 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)); + assertNoLedgerUuidTruth(proto); + assertTrue(proto.hasLedger()); + assertThat(proto.getLedger().getEntriesCount(), is(1)); + assertNoDeprecatedLedgerIdentity(ledgerEntry); + 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(shadowUUID)); + assertThat(proto.getTransactions(0).getType(), is(PTransaction.Type.DEPOSIT)); + + var loaded = load(saveBytes(fixture.client())); + + assertThat(loaded.getLedger().getEntries().size(), is(1)); + 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)); + assertThat(loaded.getAccounts().get(0).getTransactions().get(0), instanceOf(LedgerBackedTransaction.class)); + 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 semantic roles. + * The restored ledger-backed projection must represent the same dividend booking. + */ + @Test + public void testDividendRoundtripPreservesExDateUnitsForexAndSemantics() 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(); + entry.setUpdatedAt(UPDATED_AT); + + var proto = saveProto(fixture.client()); + assertNoLedgerUuidTruth(proto); + 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() + .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.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(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() + .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"))); + assertThat(reloadedProjection.getExDate(), is(EX_DATE)); + 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 order, parameters, and semantic selectors. + * Runtime projections must be rebuilt from the same persisted ledger facts. + */ + @Test + 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"); + + 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.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))); + + feePosting.setType(LedgerPostingType.FEE); + 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())); + + entry.addPosting(cashPosting); + entry.addPosting(feePosting); + 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.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(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(), + 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(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); + } + + /** + * 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. + */ + @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, 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(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, 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(shadowUUID(sell, LedgerProjectionRole.ACCOUNT))); + assertThat(sellShadow.getSecurity(), is(fixture.security().getUUID())); + assertThat(sellShadow.getShares(), is(Values.Share.factorize(2))); + + 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(shadowUUID(cashTransfer, LedgerProjectionRole.TARGET_ACCOUNT))); + + assertCommonShadowFields(securityTransferShadow, + 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(shadowUUID(securityTransfer, LedgerProjectionRole.TARGET_PORTFOLIO))); + assertThat(securityTransferShadow.getSecurity(), is(fixture.security().getUUID())); + assertThat(securityTransferShadow.getShares(), is(Values.Share.factorize(3))); + + 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, + shadowUUID(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)); + assertFalse(transaction.getUUID().equals(loaded.getAccounts().get(0).getTransactions().get(0).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, shadowUUID(buy, LedgerProjectionRole.ACCOUNT), + shadowUUID(buy, LedgerProjectionRole.PORTFOLIO)); + assertProjectionUUIDs(loaded, LedgerEntryType.CASH_TRANSFER, + shadowUUID(cashTransfer, LedgerProjectionRole.SOURCE_ACCOUNT), + shadowUUID(cashTransfer, LedgerProjectionRole.TARGET_ACCOUNT)); + assertProjectionUUIDs(loaded, LedgerEntryType.SECURITY_TRANSFER, + shadowUUID(securityTransfer, LedgerProjectionRole.SOURCE_PORTFOLIO), + shadowUUID(securityTransfer, LedgerProjectionRole.TARGET_PORTFOLIO)); + assertProjectionUUIDs(loaded, LedgerEntryType.DELIVERY_INBOUND, + shadowUUID(delivery, LedgerProjectionRole.DELIVERY_INBOUND)); + + var loadedAccountBuy = ledgerBacked(loaded.getAccounts().get(0).getTransactions(), + shadowUUID(buy, LedgerProjectionRole.ACCOUNT)); + var loadedPortfolioBuy = ledgerBacked(loaded.getPortfolios().get(0).getTransactions(), + 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(), + shadowUUID(cashTransfer, LedgerProjectionRole.SOURCE_ACCOUNT)); + var loadedCashTransferIn = ledgerBacked(loaded.getAccounts().get(1).getTransactions(), + 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(), + shadowUUID(securityTransfer, LedgerProjectionRole.SOURCE_PORTFOLIO)); + var loadedSecurityTransferIn = ledgerBacked(loaded.getPortfolios().get(1).getTransactions(), + 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, + 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(), + shadowUUID(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.hasTypeCode()); + assertThat(entry.getTypeCode(), is(LedgerEntryType.DIVIDENDS.getCode())); + 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 code fails with a clear protobuf load error. + * Unsupported persisted ledger vocabulary must not be interpreted silently. + */ + @Test + public void testUnknownLedgerEntryTypeCodeFailsClearly() throws IOException + { + var fixture = fixtureWithDividendAndExDate(); + var proto = saveProto(fixture.client()).toBuilder(); + + proto.getLedgerBuilder().getEntriesBuilder(0).setTypeCode("UNKNOWN_ENTRY_TYPE"); + + 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. + */ + @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 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")); + } + + /** + * 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 + { + for (var entryType : List.of(LedgerEntryType.CORPORATE_ACTION)) + { + var fixture = fixture(); + addNativeEntry(fixture, entryType); + + var proto = saveProto(fixture.client()); + + assertNoLedgerUuidTruth(proto); + 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())); + + var loaded = load(wrap(proto)); + var reloadedEntry = loaded.getLedger().getEntries().get(0); + + 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); + } + } + + /** + * 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 metadata roundtrips through protobuf. + * The saved plan key must still identify the generated ledger-backed booking after load. + */ + @Test + public void testInvestmentPlanExecutionMetadataRoundtrip() throws IOException + { + var fixture = fixture(); + 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(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(0)); + assertThat(loadedPlan.getTransactions(loaded).size(), is(1)); + assertThat(loadedPlan.getTransactions(loaded).get(0).getOwner(), is(loaded.getPortfolios().get(0))); + } + + /** + * 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 testLegacyInvestmentPlanLedgerExecutionRefDoesNotBecomePlanMetadata() 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 = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(buy).stream() + .filter(projection -> projection.getRole() == LedgerProjectionRole.PORTFOLIO).findFirst() + .orElseThrow(); + var plan = plan(fixture); + + fixture.client().addPlan(plan); + + var proto = saveProto(fixture.client()).toBuilder(); + proto.getPlansBuilder(0).clearPlanKey() + .addLedgerExecutionRefs(PInvestmentPlanLedgerExecutionRef.newBuilder() + .setLedgerEntryUUID(buy.getUUID()) + .setProjectionUUID(portfolioProjection.getRuntimeProjectionId()) + .setProjectionRole(PLedgerProjectionRole.LEDGER_PROJECTION_ROLE_PORTFOLIO)); + + 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(0)); + assertThat(loadedEntry.getGeneratedByPlanKey(), nullValue()); + assertFalse(loadedPlan.getPlanKey().equals(buy.getUUID())); + assertFalse(loadedPlan.getPlanKey().equals(portfolioProjection.getRuntimeProjectionId())); + assertThat(loadedEntry.getPreferredViewKind(), nullValue()); + } + + /** + * 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 exception = assertThrows(IllegalArgumentException.class, () -> load(wrap(proto.build()))); + + assertTrue(exception.getMessage(), exception.getMessage().contains("AMBIGUOUS_SEMANTIC_PRIMARY")); + } + + /** + * 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)); + } + + /** + * 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(); + 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); + 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, otherSecurity); + } + + 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 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"); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF)); + + switch (entryType) + { + case CORPORATE_ACTION: + 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; + default: + throw new IllegalArgumentException(entryType.name()); + } + + fixture.client().getLedger().addEntry(entry); + } + + private List nativeCorporateActionShadowTypes(LedgerEntryType entryType) + { + return switch (entryType) + { + case CORPORATE_ACTION -> 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) + { + 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() + .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 assertNoLedgerUuidTruth(PClient client) + { + for (var entry : client.getLedger().getEntriesList()) + assertNoDeprecatedLedgerIdentity(entry); + } + + @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) + { + var actual = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(onlyEntry(client, type)) + .stream().map(descriptor -> descriptor.getRuntimeProjectionId()).toList(); + + assertThat(actual.size(), is(projectionUUIDs.length)); + } + + 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) + { + 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) + .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:" //$NON-NLS-1$ + + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.runtimeProjectionId(entry, + role); + } + + 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 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 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, Security otherSecurity) + { + } +} 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/LedgerCodeTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCodeTest.java new file mode 100644 index 0000000000..633379529d --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerCodeTest.java @@ -0,0 +1,142 @@ +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.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; +import name.abuchen.portfolio.model.ledger.configuration.CostAllocationMethod; +import name.abuchen.portfolio.model.ledger.configuration.EventStage; +import name.abuchen.portfolio.model.ledger.configuration.FeeReason; +import name.abuchen.portfolio.model.ledger.configuration.FractionTreatment; +import name.abuchen.portfolio.model.ledger.configuration.LedgerCode; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterCodeDomain; +import name.abuchen.portfolio.model.ledger.configuration.QuotationStyle; +import name.abuchen.portfolio.model.ledger.configuration.RoundingModeCode; +import name.abuchen.portfolio.model.ledger.configuration.TaxReason; + +/** + * Tests ledger configuration and validation metadata. + * These tests make sure entry definitions, posting rules, and parameter domains stay stable for Ledger-V6 transactions. + */ +@SuppressWarnings("nls") +public class LedgerCodeTest +{ + /** + * Checks the ledger rule scenario: every code belongs to one domain and is uppercase. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testEveryCodeBelongsToOneDomainAndIsUppercase() + { + for (var code : allCodes()) + { + assertTrue(EnumSet.allOf(LedgerParameterCodeDomain.class).contains(code.getDomain())); + assertFalse(code.getCode().isBlank()); + assertThat(code.getCode(), is(code.getCode().toUpperCase(Locale.ROOT))); + } + } + + /** + * Checks the ledger rule scenario: no duplicate codes within domain. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testNoDuplicateCodesWithinDomain() + { + var seen = new EnumMap>(LedgerParameterCodeDomain.class); + + for (var domain : LedgerParameterCodeDomain.values()) + seen.put(domain, new HashSet<>()); + + for (var code : allCodes()) + assertTrue(code.getDomain() + ":" + code.getCode(), seen.get(code.getDomain()).add(code.getCode())); + } + + /** + * Checks the ledger rule scenario: domain allowed codes match domain enums. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDomainAllowedCodesMatchDomainEnums() + { + for (var domain : LedgerParameterCodeDomain.values()) + { + var codes = allCodes().stream().filter(code -> code.getDomain() == domain).toList(); + + assertFalse(codes.isEmpty()); + assertThat(codes.stream().map(LedgerCode::getCode).toList(), is(domain.getAllowedCodes())); + } + } + + /** + * Checks the ledger rule scenario: corporate action kinds classify the generic + * native corporate action entry family. + */ + @Test + public void testCorporateActionKindsClassifyGenericNativeEntryType() + { + assertTrue(LedgerEntryType.CORPORATE_ACTION.isLedgerNativeTargeted()); + assertThat(List.of(CorporateActionKind.values()), is(List.of( + CorporateActionKind.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))); + 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_DIVIDEND").isEmpty()); + assertTrue(CorporateActionKind.fromCode("OTHER").isEmpty()); + } + + private List allCodes() + { + return Stream.of( + CorporateActionLeg.values(), + CorporateActionKind.values(), + CorporateActionBasisStatus.values(), + CorporateActionBasisMethod.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/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/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/LedgerDiagnosticCodeTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticCodeTest.java new file mode 100644 index 0000000000..05027937e9 --- /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_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")); + 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.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..de01074fa5 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEditorTest.java @@ -0,0 +1,1154 @@ +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.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 = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); + 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(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)); + } + + /** + * 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 = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).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(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).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 = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); + 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(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))); + 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).getRuntimeProjectionId(); + var portfolioProjectionUUID = projection(entry, LedgerProjectionRole.PORTFOLIO).getRuntimeProjectionId(); + 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).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)); + 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: 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 projection(entry, role).getPrimaryPosting(); + } + + 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 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(); + } + +} 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..28774757e6 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerEntryDefinitionTest.java @@ -0,0 +1,862 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; +import java.util.EnumSet; +import java.util.HashSet; + +import org.junit.Test; + +import name.abuchen.portfolio.model.ledger.configuration.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; +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.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; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerProjectionRule; +import name.abuchen.portfolio.model.ledger.configuration.rule.LedgerRequirement; +import name.abuchen.portfolio.money.CurrencyUnit; + +/** + * Tests ledger configuration and validation metadata. + * These tests make sure entry definitions, posting rules, and parameter domains stay stable for Ledger-V6 transactions. + */ +@SuppressWarnings("nls") +public class LedgerEntryDefinitionTest +{ + /** + * Checks the ledger rule scenario: ledger native entry types have definitions. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testLedgerNativeEntryTypesHaveDefinitions() + { + for (var type : LedgerEntryType.values()) + { + if (type.isLedgerNativeTargeted()) + { + var definition = LedgerEntryDefinitionRegistry.lookup(type).orElseThrow(); + + assertThat(definition.getEntryType(), is(type)); + } + else + { + assertFalse(type.name(), LedgerEntryDefinitionRegistry.hasDefinition(type)); + } + } + } + + /** + * Checks the ledger rule scenario: definitions are consistent static configuration. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDefinitionsAreConsistentStaticConfiguration() + { + for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) + { + assertTrue(definition.getEntryType().isLedgerNativeTargeted()); + assertTrue(definition.getNativeShape() != LedgerNativeEntryShape.UNDEFINED); + assertFalse(definition.getPostingTypes().isEmpty()); + assertFalse(definition.getPostingRules().isEmpty()); + assertFalse(definition.getEntryParameterTypes().isEmpty()); + assertFalse(definition.getRequiredEntryParameterRules().isEmpty()); + assertFalse(definition.getEntryParameterRules().isEmpty()); + assertFalse(definition.getPostingParameterTypes().isEmpty()); + assertFalse(definition.getPostingParameterRules().isEmpty()); + assertFalse(definition.getLegDefinitions().isEmpty()); + assertTrue(definition.getReportingClass() != LedgerReportingClass.UNDEFINED); + assertTrue(definition.getPerformanceTreatment() != LedgerPerformanceTreatment.UNDEFINED); + assertThat(definition.getDownstreamResultsNotPersisted(), is(EnumSet.allOf(LedgerDownstreamResult.class))); + } + } + + /** + * Checks the ledger rule scenario: definition registries expose read only schemas. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDefinitionRegistriesExposeReadOnlySchemas() + { + for (var entryType : LedgerEntryType.values()) + { + if (!entryType.isLedgerNativeTargeted()) + continue; + + var definition = LedgerEntryDefinitionRegistry.lookup(entryType).orElseThrow(); + + assertThat(definition.getEntryType(), is(entryType)); + assertFalse(definition.getPostingRules().isEmpty()); + assertFalse(definition.getEntryParameterRules().isEmpty()); + assertFalse(definition.getPostingParameterRules().isEmpty()); + assertTrue(definition.getReportingClass() != LedgerReportingClass.UNDEFINED); + assertTrue(definition.getPerformanceTreatment() != LedgerPerformanceTreatment.UNDEFINED); + assertThat(definition.getDownstreamResultsNotPersisted(), is(EnumSet.allOf(LedgerDownstreamResult.class))); + } + + for (var postingType : LedgerPostingType.values()) + { + var definition = LedgerPostingTypeDefinitionRegistry.lookup(postingType).orElseThrow(); + + assertThat(definition.getPostingType(), is(postingType)); + assertFalse(postingType.name(), definition.getComponentParameterTypes().isEmpty()); + } + } + + /** + * Checks the ledger rule scenario: rule keys are unique within definition categories. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testRuleKeysAreUniqueWithinDefinitionCategories() + { + for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) + { + assertUniquePostingRules(definition); + assertUniqueParameterRules(definition, definition.getEntryParameterRules(), "entry parameter rule"); + assertUniqueParameterRules(definition, definition.getPostingParameterRules(), "posting parameter rule"); + assertUniqueProjectionRules(definition); + assertUniquePostingGroupRules(definition); + assertUniqueAlternativeGroupRules(definition); + assertUniqueComponentRequirements(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() + + group.getPrimaryMovements().size(); + + var minimumAlternatives = group.getPrimaryMovements().isEmpty() ? 2 : 1; + + assertTrue(definition.getEntryType() + ":" + group.getName(), + numberOfAlternatives >= minimumAlternatives); + } + } + } + + /** + * Checks the ledger rule scenario: alternative groups do not duplicate hard required members. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testAlternativeGroupsDoNotDuplicateHardRequiredMembers() + { + for (var definition : LedgerEntryDefinitionRegistry.getDefinitions()) + { + var requiredPostingTypes = EnumSet.noneOf(LedgerPostingType.class); + var requiredParameterTypes = EnumSet.noneOf(LedgerParameterType.class); + + for (var rule : definition.getRequiredPostingRules()) + requiredPostingTypes.add(rule.getPostingType()); + + for (var rule : definition.getRequiredEntryParameterRules()) + requiredParameterTypes.add(rule.getParameterType()); + + for (var rule : definition.getRequiredPostingParameterRules()) + requiredParameterTypes.add(rule.getParameterType()); + + for (var rule : definition.getPostingRules()) + requiredParameterTypes.addAll(rule.getRequiredParameterTypes()); + + for (var group : definition.getAlternativeRequirementGroups()) + { + for (var postingType : group.getPostingTypes()) + assertFalse(definition.getEntryType() + ":" + group.getName() + ":" + postingType, + requiredPostingTypes.contains(postingType)); + + for (var parameterType : group.getParameterTypes()) + assertFalse(definition.getEntryType() + ":" + group.getName() + ":" + parameterType, + requiredParameterTypes.contains(parameterType)); + } + } + } + + /** + * Checks the ledger rule scenario: spin off definition describes native data model. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testSpinOffDefinitionDescribesNativeDataModel() + { + var definition = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.SPIN_OFF).orElseThrow(); + + assertThat(definition.getNativeShape(), is(LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT)); + assertTrue(definition.getPostingTypes().contains(LedgerPostingType.SECURITY)); + assertTrue(definition.getPostingTypes().contains(LedgerPostingType.CASH_COMPENSATION)); + assertTrue(definition.getPostingTypes().contains(LedgerPostingType.FEE)); + assertTrue(definition.getPostingTypes().contains(LedgerPostingType.TAX)); + assertTrue(definition.getPostingTypes().contains(LedgerPostingType.FOREX)); + assertTrue(definition.getEntryParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_KIND)); + assertTrue(definition.getEntryParameterTypes().contains(LedgerParameterType.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)); + assertTrue(definition.getPostingParameterTypes().contains(LedgerParameterType.TARGET_SECURITY)); + assertTrue(definition.getProjectionRoles().contains(LedgerProjectionRole.OLD_SECURITY_LEG)); + assertTrue(definition.getProjectionRoles().contains(LedgerProjectionRole.NEW_SECURITY_LEG)); + assertTrue(definition.getProjectionRoles().contains(LedgerProjectionRole.CASH_COMPENSATION)); + assertOptionalPosting(definition, LedgerPostingType.SECURITY); + assertOptionalPosting(definition, LedgerPostingType.CASH_COMPENSATION); + assertOptionalPosting(definition, LedgerPostingType.FEE); + assertOptionalPosting(definition, LedgerPostingType.TAX); + assertOptionalPosting(definition, LedgerPostingType.FOREX); + assertRequiredEntryParameter(definition, LedgerParameterType.CORPORATE_ACTION_KIND); + assertOptionalEntryParameter(definition, LedgerParameterType.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); + assertRequiredPostingParameter(definition, LedgerParameterType.SOURCE_SECURITY); + assertRequiredPostingParameter(definition, LedgerParameterType.TARGET_SECURITY); + assertOptionalPostingParameter(definition, LedgerParameterType.CASH_IN_LIEU_AMOUNT); + assertRepeatableParameter(definition, LedgerParameterType.CORPORATE_ACTION_LEG); + assertOptionalProjection(definition, LedgerProjectionRole.OLD_SECURITY_LEG, true, false); + assertOptionalProjection(definition, LedgerProjectionRole.NEW_SECURITY_LEG, true, false); + assertOptionalProjection(definition, LedgerProjectionRole.CASH_COMPENSATION, true, true); + assertAlternativeGroup(definition, "SPIN_OFF_DATE", LedgerRequirement.REQUIRED, + LedgerParameterType.EX_DATE, LedgerParameterType.EFFECTIVE_DATE); + assertThat(definition.getReportingClass(), is(LedgerReportingClass.SECURITIES_DISTRIBUTION)); + assertThat(definition.getPerformanceTreatment(), is(LedgerPerformanceTreatment.COST_BASIS_REALLOCATION)); + } + + /** + * Checks that the spin-off definition names its business legs explicitly. + * The old and new security sides both use SECURITY postings, so the leg + * definitions must carry the business meaning that the posting type cannot. + */ + @Test + public void testSpinOffDefinitionDescribesFunctionalLegs() + { + var definition = LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, CorporateActionKind.SPIN_OFF).orElseThrow(); + + var sourceLeg = assertLeg(definition, LedgerLegRole.SOURCE_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.REPEATABLE); + assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.CORPORATE_ACTION_LEG)); + assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.SOURCE_SECURITY)); + assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_NUMERATOR)); + assertTrue(sourceLeg.getRequiredParameterTypes().contains(LedgerParameterType.RATIO_DENOMINATOR)); + assertTrue(sourceLeg.getOptionalParameterTypes().contains(LedgerParameterType.TARGET_SECURITY)); + assertThat(sourceLeg.getProjectionRole().orElseThrow(), is(LedgerProjectionRole.OLD_SECURITY_LEG)); + assertTrue(sourceLeg.isPrimaryPostingExpected()); + assertFalse(sourceLeg.isPostingGroupExpected()); + + var targetLeg = assertLeg(definition, LedgerLegRole.TARGET_SECURITY_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.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)); + 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 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)); + assertTrue(cashLeg.isPrimaryPostingExpected()); + assertTrue(cashLeg.isPostingGroupExpected()); + assertTrue(cashLeg.getGroupNames().contains("CASH_COMPENSATION_GROUP")); + + var feeLeg = assertLeg(definition, LedgerLegRole.FEE_LEG, LedgerPostingType.FEE, + LedgerLegCardinality.REPEATABLE); + assertTrue(feeLeg.getProjectionRole().isEmpty()); + assertTrue(feeLeg.getGroupNames().contains("CASH_COMPENSATION_GROUP")); + + var taxLeg = assertLeg(definition, LedgerLegRole.TAX_LEG, LedgerPostingType.TAX, + LedgerLegCardinality.REPEATABLE); + assertTrue(taxLeg.getProjectionRole().isEmpty()); + assertTrue(taxLeg.getGroupNames().contains("CASH_COMPENSATION_GROUP")); + + var forexLeg = assertLeg(definition, LedgerLegRole.FOREX_CONTEXT_LEG, LedgerPostingType.FOREX, + LedgerLegCardinality.OPTIONAL); + assertTrue(forexLeg.getProjectionRole().isEmpty()); + assertTrue(forexLeg.getGroupNames().isEmpty()); + } + + /** + * Checks the ledger rule scenario: corporate action kinds can be registered as stable + * identities before all Registry.md profile dimensions are modeled. Definitions only + * include dimensions that current Ledger primitives can express honestly. + */ + @Test + public void testCorporateActionKindDefinitionsRegisterRepresentableProfileSubsets() + { + for (var kind : new CorporateActionKind[] { CorporateActionKind.CASH_DISTRIBUTION, + 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 }) + assertTrue(kind.name(), LedgerEntryDefinitionRegistry + .lookup(LedgerEntryType.CORPORATE_ACTION, kind).isPresent()); + + 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(); + 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); + 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) + .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); + 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) + .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); + 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) + .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); + assertTrue(conversion.getComponentRequirements().isEmpty()); + + 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); + } + + 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); + } + + /** + * 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.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)); + assertTrue(allEntryParameters.contains(LedgerParameterType.EFFECTIVE_DATE)); + assertTrue(allPostingParameters.contains(LedgerParameterType.SOURCE_SECURITY)); + assertTrue(allPostingParameters.contains(LedgerParameterType.TARGET_SECURITY)); + assertTrue(allPostingParameters.contains(LedgerParameterType.RATIO_NUMERATOR)); + assertTrue(allPostingParameters.contains(LedgerParameterType.RATIO_DENOMINATOR)); + assertTrue(allPostingParameters.contains(LedgerParameterType.FRACTION_TREATMENT)); + assertTrue(allPostingParameters.contains(LedgerParameterType.CASH_IN_LIEU_AMOUNT)); + assertTrue(allPostingParameters.contains(LedgerParameterType.COST_ALLOCATION_METHOD)); + assertTrue(allPostingParameters.contains(LedgerParameterType.FAIR_MARKET_VALUE)); + assertTrue(allPostingParameters.contains(LedgerParameterType.FEE_REASON)); + assertTrue(allPostingParameters.contains(LedgerParameterType.TAX_REASON)); + } + + /** + * Checks the ledger rule scenario: corporate action leg has controlled code domain. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testCorporateActionLegHasControlledCodeDomain() + { + assertThat(LedgerParameterType.CORPORATE_ACTION_LEG.getCodeDomain(), + is(LedgerParameterCodeDomain.CORPORATE_ACTION_LEG)); + assertTrue(LedgerParameterType.CORPORATE_ACTION_LEG + .supportsCode(CorporateActionLeg.SOURCE_SECURITY.getCode())); + assertTrue(LedgerParameterType.CORPORATE_ACTION_LEG + .supportsCode(CorporateActionLeg.TARGET_SECURITY.getCode())); + assertFalse(LedgerParameterType.CORPORATE_ACTION_LEG.supportsCode("SOURCE")); + } + + @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. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDefinitionLayerAllowsPartialNativeCompleteness() + { + var ledger = new Ledger(); + var entry = new LedgerEntry("entry-1"); + var posting = new LedgerPosting("posting-1"); + + entry.setType(LedgerEntryType.CORPORATE_ACTION); + entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); + posting.setType(LedgerPostingType.CASH); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + entry.addPosting(posting); + ledger.addEntry(entry); + + assertTrue(LedgerStructuralValidator.validate(ledger).isOK()); + } + + private void assertOptionalPosting(name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerPostingType postingType) + { + assertTrue(postingType.name(), hasPostingRule(definition.getOptionalPostingRules(), postingType)); + } + + private void assertRequiredEntryParameter( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerParameterType parameterType) + { + assertTrue(parameterType.name(), hasParameterRule(definition.getRequiredEntryParameterRules(), parameterType)); + } + + private void assertOptionalEntryParameter( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerParameterType parameterType) + { + assertTrue(parameterType.name(), hasParameterRule(definition.getOptionalEntryParameterRules(), parameterType)); + } + + private void assertRequiredPostingParameter( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerParameterType parameterType) + { + assertTrue(parameterType.name(), hasParameterRule(definition.getRequiredPostingParameterRules(), parameterType)); + } + + private void assertOptionalPostingParameter( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerParameterType parameterType) + { + assertTrue(parameterType.name(), hasParameterRule(definition.getOptionalPostingParameterRules(), parameterType)); + } + + private void assertRepeatableParameter( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerParameterType parameterType) + { + assertTrue(parameterType.name(), definition.getRepeatableParameterTypes().contains(parameterType)); + } + + private void assertOptionalProjection( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerProjectionRole role, boolean primaryPostingExpected, boolean postingGroupExpected) + { + assertProjection(definition.getOptionalProjectionRules(), role, primaryPostingExpected, postingGroupExpected); + } + + private void assertProjection(Iterable rules, LedgerProjectionRole role, + boolean primaryPostingExpected, boolean postingGroupExpected) + { + for (var rule : rules) + { + if (rule.getRole() == role) + { + assertThat(rule.isPrimaryPostingExpected(), is(primaryPostingExpected)); + assertThat(rule.isPostingGroupExpected(), is(postingGroupExpected)); + return; + } + } + + assertTrue(role.name(), false); + } + + private void assertAlternativeGroup( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, String name, + LedgerRequirement requirement, LedgerParameterType first, LedgerParameterType... rest) + { + var expected = EnumSet.of(first, rest); + + for (var group : definition.getAlternativeRequirementGroups()) + { + if (group.getName().equals(name)) + { + assertThat(group.getRequirement(), is(requirement)); + assertThat(group.getParameterTypes(), is(expected)); + return; + } + } + + assertTrue(name, false); + } + + private 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 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) + 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 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) + { + var seen = new HashSet(); + + for (var leg : definition.getLegDefinitions()) + assertTrue(definition.getEntryType() + ": leg " + leg.getRole(), seen.add(leg.getRole())); + } + + private LedgerLegDefinition assertLeg( + name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinition definition, + LedgerLegRole role, LedgerPostingType postingType, LedgerLegCardinality cardinality) + { + var leg = definition.getLegDefinition(role).orElseThrow(); + + assertThat(leg.getPostingType(), is(postingType)); + assertThat(leg.getCardinality(), is(cardinality)); + + return leg; + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriterTest.java new file mode 100644 index 0000000000..a18bb7efc0 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriterTest.java @@ -0,0 +1,148 @@ +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(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(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.getPostings().get(0).setGroupKey("changed"); + + assertThat(source.getPostings().get(0).getAmount(), is(Values.Amount.factorize(100))); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(source).get(0) + .getPrimaryPosting().getUUID(), 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"); + + 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); + 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); + securityPosting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + securityPosting.setDirection(LedgerPostingDirection.NEUTRAL); + securityPosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + securityPosting.setGroupKey(uuid + "-trade"); + + entry.addPosting(cash); + entry.addPosting(securityPosting); + 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..36982f05e6 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerGuardrailTestSupport.java @@ -0,0 +1,232 @@ +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.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; + +@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 DerivedProjectionDescriptor projection(LedgerEntry entry, LedgerProjectionRole role) + { + return name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).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(), + 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()); + } + + 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(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)); + } + + 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(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 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 name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).stream().map(DerivedProjectionDescriptor::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 runtimeId, LedgerProjectionRole role, Account account, Portfolio portfolio, + String primaryPostingId, String groupKey) + { + static ProjectionSnapshot of(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/LedgerInlineEditingPolicyTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerInlineEditingPolicyTest.java new file mode 100644 index 0000000000..605e01a1da --- /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.CORPORATE_ACTION, + LedgerProjectionRole.DELIVERY_INBOUND, LedgerInlineEditingField.DATE), is(false)); + 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/LedgerModelCopyTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelCopyTest.java new file mode 100644 index 0000000000..fd6f1bb2f5 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelCopyTest.java @@ -0,0 +1,212 @@ +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.model.ledger.projection.DerivedProjectionDescriptor; +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(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)); + } + + /** + * 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.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.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() + { + 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"); + + 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.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)))); + + 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); + securityPosting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + securityPosting.setDirection(LedgerPostingDirection.NEUTRAL); + securityPosting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + securityPosting.setGroupKey("trade"); + + entry.addPosting(cash); + entry.addPosting(securityPosting); + 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.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 assertDescriptorCopied(DerivedProjectionDescriptor original, DerivedProjectionDescriptor copy) + { + assertNotSame(original, copy); + assertThat(copy.getRuntimeProjectionId(), is(original.getRuntimeProjectionId())); + assertThat(copy.getRole(), is(original.getRole())); + assertSame(original.getAccount(), copy.getAccount()); + assertSame(original.getPortfolio(), copy.getPortfolio()); + assertThat(copy.getPrimaryPosting().getUUID(), is(original.getPrimaryPosting().getUUID())); + } + + 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/LedgerModelTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelTest.java new file mode 100644 index 0000000000..26c791395c --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerModelTest.java @@ -0,0 +1,849 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; + +import org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerParameter.ValueKind; +import name.abuchen.portfolio.model.ledger.configuration.CashCompensationKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionSubtype; +import name.abuchen.portfolio.model.ledger.configuration.CostAllocationMethod; +import name.abuchen.portfolio.model.ledger.configuration.EventStage; +import name.abuchen.portfolio.model.ledger.configuration.FeeReason; +import name.abuchen.portfolio.model.ledger.configuration.FractionTreatment; +import name.abuchen.portfolio.model.ledger.configuration.LedgerCode; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterCodeDomain; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.configuration.QuotationStyle; +import name.abuchen.portfolio.model.ledger.configuration.RoundingModeCode; +import name.abuchen.portfolio.model.ledger.configuration.TaxReason; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; + +/** + * Tests low-level ledger model behavior used by higher-level transaction flows. + * These tests make sure copying, graph links, and core model fields keep ledger data consistent. + */ +@SuppressWarnings("nls") +public class LedgerModelTest +{ + /** + * Checks the Ledger-V6 scenario: ledger entry carries identity and minimum fields. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerEntryCarriesIdentityAndMinimumFields() + { + var dateTime = LocalDateTime.of(2020, 9, 28, 0, 0); + var updatedAt = Instant.parse("2026-01-02T03:04:05Z"); + var parameter = LedgerParameter.ofString(LedgerParameterType.EVENT_REFERENCE, "reference"); + var posting = new LedgerPosting("posting-1"); + var entry = new LedgerEntry("entry-1"); + + entry.setType(LedgerEntryType.BUY); + entry.setDateTime(dateTime); + entry.setNote("note"); + entry.setSource("source"); + entry.addParameter(parameter); + entry.addPosting(posting); + entry.setUpdatedAt(updatedAt); + + assertThat(entry.getUUID(), is("entry-1")); + assertThat(entry.getType(), is(LedgerEntryType.BUY)); + assertThat(entry.getDateTime(), is(dateTime)); + assertThat(entry.getNote(), is("note")); + assertThat(entry.getSource(), is("source")); + assertThat(entry.getUpdatedAt(), is(updatedAt)); + assertThat(entry.getParameters(), is(List.of(parameter))); + assertThat(entry.getPostings(), is(List.of(posting))); + assertThrows(UnsupportedOperationException.class, () -> entry.getParameters().add(parameter)); + assertThrows(UnsupportedOperationException.class, () -> entry.getPostings().add(new LedgerPosting())); + assertTrue(entry.removeParameter(parameter)); + assertTrue(entry.getParameters().isEmpty()); + } + + /** + * Checks the Ledger-V6 scenario: generated uuids are independent. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testGeneratedUUIDsAreIndependent() + { + var entry = new LedgerEntry(); + var otherEntry = new LedgerEntry(); + var posting = new LedgerPosting(); + + assertNotEquals(entry.getUUID(), otherEntry.getUUID()); + assertNotEquals(entry.getUUID(), posting.getUUID()); + } + + /** + * Checks the Ledger-V6 scenario: ledger collects entries without owning client integration. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerCollectsEntriesWithoutOwningClientIntegration() + { + var ledger = new Ledger(); + var entry = new LedgerEntry("entry-1"); + + ledger.addEntry(entry); + + assertThat(ledger.getEntries(), is(List.of(entry))); + assertThrows(UnsupportedOperationException.class, () -> ledger.getEntries().add(new LedgerEntry())); + assertTrue(ledger.removeEntry(entry)); + assertTrue(ledger.getEntries().isEmpty()); + } + + /** + * Checks the Ledger-V6 scenario: ledger posting carries fields and forex as posting data. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerPostingCarriesFieldsAndForexAsPostingData() + { + var account = new Account(); + var portfolio = new Portfolio(); + var security = new Security("Siemens", CurrencyUnit.EUR); + var exchangeRate = BigDecimal.valueOf(1.0875); + var posting = new LedgerPosting("posting-1"); + + posting.setType(LedgerPostingType.SECURITY); + posting.setAmount(123456L); + posting.setCurrency(CurrencyUnit.EUR); + posting.setForexAmount(134259L); + posting.setForexCurrency(CurrencyUnit.USD); + posting.setExchangeRate(exchangeRate); + posting.setSecurity(security); + posting.setShares(500000L); + posting.setAccount(account); + posting.setPortfolio(portfolio); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(LedgerPostingDirection.INBOUND); + posting.setCorporateActionLeg(CorporateActionLeg.TARGET_SECURITY); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setGroupKey("security-leg"); + posting.setLocalKey("target"); + + assertThat(posting.getUUID(), is("posting-1")); + assertThat(posting.getType(), is(LedgerPostingType.SECURITY)); + assertThat(posting.getAmount(), is(123456L)); + assertThat(posting.getCurrency(), is(CurrencyUnit.EUR)); + assertThat(posting.getForexAmount(), is(134259L)); + assertThat(posting.getForexCurrency(), is(CurrencyUnit.USD)); + assertThat(posting.getExchangeRate(), is(exchangeRate)); + assertSame(security, posting.getSecurity()); + assertThat(posting.getShares(), is(500000L)); + assertSame(account, posting.getAccount()); + assertSame(portfolio, posting.getPortfolio()); + assertThat(posting.getSemanticRole(), is(LedgerPostingSemanticRole.SECURITY)); + assertThat(posting.getDirection(), is(LedgerPostingDirection.INBOUND)); + assertThat(posting.getCorporateActionLeg(), is(CorporateActionLeg.TARGET_SECURITY)); + assertThat(posting.getUnitRole(), is(LedgerPostingUnitRole.PRIMARY)); + assertThat(posting.getGroupKey(), is("security-leg")); + assertThat(posting.getLocalKey(), is("target")); + } + + /** + * Checks the Phase-1 scenario: semantic posting vocabulary is typed and additive. + * Projection refs remain the active materialization source in this phase. + */ + @Test + public void testLedgerPostingSemanticVocabularyIsTypedAndAdditive() + { + var posting = new LedgerPosting("posting-1"); + + assertThat(posting.getSemanticRole(), is((LedgerPostingSemanticRole) null)); + assertThat(posting.getDirection(), is((LedgerPostingDirection) null)); + assertThat(posting.getCorporateActionLeg(), is((CorporateActionLeg) null)); + assertThat(posting.getUnitRole(), is((LedgerPostingUnitRole) null)); + assertThat(posting.getGroupKey(), is((String) null)); + assertThat(posting.getLocalKey(), is((String) null)); + + assertThat(EnumSet.allOf(LedgerPostingSemanticRole.class), + is(EnumSet.of(LedgerPostingSemanticRole.CASH, LedgerPostingSemanticRole.SECURITY, + LedgerPostingSemanticRole.RIGHT, LedgerPostingSemanticRole.BOND, + LedgerPostingSemanticRole.CASH_COMPENSATION, + LedgerPostingSemanticRole.ACCRUED_INTEREST, + LedgerPostingSemanticRole.PRINCIPAL_REDEMPTION, + LedgerPostingSemanticRole.FEE, LedgerPostingSemanticRole.TAX, + LedgerPostingSemanticRole.GROSS_VALUE, + LedgerPostingSemanticRole.FOREX_CONTEXT))); + assertThat(EnumSet.allOf(LedgerPostingDirection.class), + is(EnumSet.of(LedgerPostingDirection.INBOUND, LedgerPostingDirection.OUTBOUND, + LedgerPostingDirection.NEUTRAL))); + assertThat(EnumSet.allOf(LedgerPostingUnitRole.class), + is(EnumSet.of(LedgerPostingUnitRole.PRIMARY, LedgerPostingUnitRole.FEE, + LedgerPostingUnitRole.TAX, LedgerPostingUnitRole.GROSS_VALUE, + LedgerPostingUnitRole.FOREX_CONTEXT))); + } + + /** + * Checks the Ledger-V6 scenario: ex-date is local date time ledger parameter. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testExDateIsLocalDateTimeLedgerParameter() + { + var exDate = LocalDateTime.of(2020, 9, 28, 0, 0); + var parameter = LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, exDate); + var posting = new LedgerPosting(); + + posting.addParameter(parameter); + + assertFalse(Arrays.stream(LedgerEntry.class.getDeclaredFields()).anyMatch(f -> "exDate".equals(f.getName()))); + assertThat(posting.getParameters(), is(List.of(parameter))); + assertThat(parameter.getType(), is(LedgerParameterType.EX_DATE)); + assertThat(parameter.getValueKind(), is(ValueKind.LOCAL_DATE_TIME)); + assertThat(parameter.getValue(), is(exDate)); + assertThrows(UnsupportedOperationException.class, () -> posting.getParameters().clear()); + } + + /** + * Checks the Ledger-V6 scenario: ledger entry and posting parameters are independent. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerEntryAndPostingParametersAreIndependent() + { + var entryParameter = LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF.getCode()); + var postingParameter = LedgerParameter.ofString(LedgerParameterType.FEE_REASON, + FeeReason.BROKER_FEE.getCode()); + var entry = new LedgerEntry(); + var posting = new LedgerPosting(); + + entry.addParameter(entryParameter); + posting.addParameter(postingParameter); + entry.addPosting(posting); + + assertThat(entry.getParameters(), is(List.of(entryParameter))); + assertThat(posting.getParameters(), is(List.of(postingParameter))); + assertFalse(entry.getParameters().contains(postingParameter)); + assertFalse(posting.getParameters().contains(entryParameter)); + } + + /** + * Checks the Ledger-V6 scenario: ledger parameter value kinds are explicit. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerParameterValueKindsAreExplicit() + { + var security = new Security("Siemens Energy", CurrencyUnit.EUR); + var expectedKinds = EnumSet.of(ValueKind.STRING, ValueKind.DECIMAL, ValueKind.LONG, ValueKind.MONEY, + ValueKind.SECURITY, ValueKind.ACCOUNT, ValueKind.PORTFOLIO, ValueKind.BOOLEAN, + ValueKind.LOCAL_DATE, ValueKind.LOCAL_DATE_TIME); + + assertThat(EnumSet.allOf(ValueKind.class), is(expectedKinds)); + assertValueKindPolicy(ValueKind.STRING, String.class); + assertValueKindPolicy(ValueKind.DECIMAL, BigDecimal.class); + assertValueKindPolicy(ValueKind.LONG, Long.class); + assertValueKindPolicy(ValueKind.MONEY, Money.class); + assertValueKindPolicy(ValueKind.SECURITY, Security.class); + assertValueKindPolicy(ValueKind.ACCOUNT, Account.class); + assertValueKindPolicy(ValueKind.PORTFOLIO, Portfolio.class); + assertValueKindPolicy(ValueKind.BOOLEAN, Boolean.class); + assertValueKindPolicy(ValueKind.LOCAL_DATE, LocalDate.class); + assertValueKindPolicy(ValueKind.LOCAL_DATE_TIME, LocalDateTime.class); + + assertThat(LedgerParameter.ofString(LedgerParameterType.FEE_REASON, + FeeReason.BROKER_FEE.getCode()).getValueKind(), + is(ValueKind.STRING)); + assertThat(LedgerParameter.ofDecimal(LedgerParameterType.RATIO_NUMERATOR, BigDecimal.ONE) + .getValueKind(), is(ValueKind.DECIMAL)); + assertSame(security, + LedgerParameter.ofSecurity(LedgerParameterType.SOURCE_SECURITY, security) + .getValue()); + assertThat(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.SOURCE_SECURITY.getCode()) + .getValueKind(), is(ValueKind.STRING)); + assertThat(LedgerParameter.ofString(LedgerParameterType.CASH_COMPENSATION_KIND, + CashCompensationKind.CASH_IN_LIEU.getCode()) + .getValueKind(), is(ValueKind.STRING)); + } + + /** + * Checks the Ledger-V6 scenario: ledger parameter type policies are explicit. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerParameterTypePoliciesAreExplicit() + { + assertParameterTypePolicy(LedgerParameterType.EX_DATE, LedgerParameterType.Scope.GENERAL, + ValueKind.LOCAL_DATE_TIME); + assertParameterTypePolicies(LedgerParameterType.Scope.CORPORATE_ACTION, ValueKind.STRING, + LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.CASH_COMPENSATION_KIND, LedgerParameterType.FEE_REASON, + LedgerParameterType.TAX_REASON, LedgerParameterType.CORPORATE_ACTION_KIND, + LedgerParameterType.CORPORATE_ACTION_SUBTYPE, + LedgerParameterType.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); + assertParameterTypePolicies(LedgerParameterType.Scope.CORPORATE_ACTION, ValueKind.LOCAL_DATE, + 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, + 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.CORPORATE_ACTION_BASIS_MANUAL_OVERRIDE, + 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.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); + assertCodeDomain(LedgerParameterType.CORPORATE_ACTION_KIND, + LedgerParameterCodeDomain.CORPORATE_ACTION_KIND, + 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, + CorporateActionSubtype.CASH_AND_STOCK, CorporateActionSubtype.OTHER); + assertCodeDomain(LedgerParameterType.EVENT_STAGE, LedgerParameterCodeDomain.EVENT_STAGE, + EventStage.ANNOUNCED, EventStage.RECORD, + EventStage.EX_DATE, EventStage.PAYMENT, + EventStage.ISSUED, EventStage.EXERCISED, + EventStage.SOLD, EventStage.EXPIRED, + EventStage.SETTLED, EventStage.OTHER); + assertCodeDomain(LedgerParameterType.CASH_COMPENSATION_KIND, + LedgerParameterCodeDomain.CASH_COMPENSATION_KIND, + CashCompensationKind.CASH_IN_LIEU, + CashCompensationKind.FRACTIONAL_SHARE_COMPENSATION, + CashCompensationKind.ROUNDING_COMPENSATION, + CashCompensationKind.OTHER); + assertCodeDomain(LedgerParameterType.FRACTION_TREATMENT, + LedgerParameterCodeDomain.FRACTION_TREATMENT, FractionTreatment.NONE, + FractionTreatment.CASH_IN_LIEU, FractionTreatment.ROUND_DOWN, + FractionTreatment.ROUND_UP, FractionTreatment.DROP, + FractionTreatment.OTHER); + assertCodeDomain(LedgerParameterType.ROUNDING_MODE, LedgerParameterCodeDomain.ROUNDING_MODE, + RoundingModeCode.NONE, RoundingModeCode.FLOOR, + RoundingModeCode.CEILING, RoundingModeCode.HALF_UP, + RoundingModeCode.HALF_EVEN, RoundingModeCode.OTHER); + assertCodeDomain(LedgerParameterType.COST_ALLOCATION_METHOD, + LedgerParameterCodeDomain.COST_ALLOCATION_METHOD, CostAllocationMethod.NONE, + CostAllocationMethod.FMV_RATIO, + CostAllocationMethod.MANUAL_PERCENTAGE, + CostAllocationMethod.ZERO_COST_TARGET, + CostAllocationMethod.CARRY_OVER, CostAllocationMethod.OTHER); + assertCodeDomain(LedgerParameterType.QUOTATION_STYLE, + LedgerParameterCodeDomain.QUOTATION_STYLE, QuotationStyle.UNIT, + QuotationStyle.PERCENT, QuotationStyle.NOMINAL, + QuotationStyle.OTHER); + assertCodeDomain(LedgerParameterType.FEE_REASON, LedgerParameterCodeDomain.FEE_REASON, + FeeReason.BROKER_FEE, FeeReason.EXCHANGE_FEE, + FeeReason.CORPORATE_ACTION_FEE, FeeReason.STAMP_DUTY, + FeeReason.OTHER); + assertCodeDomain(LedgerParameterType.TAX_REASON, LedgerParameterCodeDomain.TAX_REASON, + TaxReason.WITHHOLDING_TAX, TaxReason.CAPITAL_GAINS_TAX, + TaxReason.TRANSACTION_TAX, TaxReason.STAMP_DUTY, + TaxReason.RECLAIMABLE_TAX, TaxReason.OTHER); + + assertFalse(LedgerParameterType.EVENT_REFERENCE.hasCodeDomain()); + + for (var type : LedgerParameterType.values()) + if (type.hasCodeDomain()) + assertThat(type.getExpectedValueKind(), is(ValueKind.STRING)); + + for (var domain : LedgerParameterCodeDomain.values()) + { + assertFalse(domain.getAllowedCodes().isEmpty()); + assertThat(domain.getAllowedCodes().stream().distinct().count(), is((long) domain.getAllowedCodes().size())); + assertTrue(domain.getAllowedCodes().stream().allMatch(code -> code.equals(code.toUpperCase(Locale.ROOT)))); + } + } + + /** + * Checks the Ledger-V6 scenario: ledger parameter factories validate parameter type. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerParameterFactoriesValidateParameterType() + { + var exDate = LocalDateTime.of(2020, 9, 28, 0, 0); + var money = Money.of(CurrencyUnit.EUR, 500L); + + assertThat(LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, exDate).getValue(), + is(exDate)); + assertThat(LedgerParameter.ofString(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.SOURCE_SECURITY.getCode()).getValue(), + is(CorporateActionLeg.SOURCE_SECURITY.getCode())); + assertThat(LedgerParameter.ofString(LedgerParameterType.CASH_COMPENSATION_KIND, + CashCompensationKind.CASH_IN_LIEU.getCode()).getValue(), + is(CashCompensationKind.CASH_IN_LIEU.getCode())); + assertThat(LedgerParameter.ofString(LedgerParameterType.FEE_REASON, + FeeReason.BROKER_FEE.getCode()).getValue(), + is(FeeReason.BROKER_FEE.getCode())); + assertThat(LedgerParameter.ofLocalDate(LedgerParameterType.RECORD_DATE, + LocalDate.of(2026, 1, 2)).getValue(), is(LocalDate.of(2026, 1, 2))); + assertThat(LedgerParameter.ofBoolean(LedgerParameterType.CASH_IN_LIEU_APPLIED, Boolean.TRUE) + .getValue(), is(Boolean.TRUE)); + assertThat(LedgerParameter.ofMoney(LedgerParameterType.NOMINAL_VALUE, money).getValue(), + is(money)); + + assertFactoryRejects(LedgerParameterType.EX_DATE, ValueKind.MONEY, + () -> LedgerParameter.ofMoney(LedgerParameterType.EX_DATE, money)); + assertFactoryRejects(LedgerParameterType.RATIO_NUMERATOR, ValueKind.STRING, + () -> LedgerParameter.ofString(LedgerParameterType.RATIO_NUMERATOR, "1")); + assertFactoryRejects(LedgerParameterType.FEE_REASON, ValueKind.BOOLEAN, + () -> LedgerParameter.ofBoolean(LedgerParameterType.FEE_REASON, Boolean.TRUE)); + assertFactoryRejects(LedgerParameterType.EX_DATE, ValueKind.LOCAL_DATE, + () -> LedgerParameter.ofLocalDate(LedgerParameterType.EX_DATE, + LocalDate.of(2020, 9, 28))); + assertFactoryRejects(LedgerParameterType.RECORD_DATE, ValueKind.LOCAL_DATE_TIME, + () -> LedgerParameter.ofLocalDateTime(LedgerParameterType.RECORD_DATE, exDate)); + assertFactoryRejects(LedgerParameterType.CASH_IN_LIEU_APPLIED, ValueKind.STRING, + () -> LedgerParameter.ofString(LedgerParameterType.CASH_IN_LIEU_APPLIED, + "true")); + } + + /** + * Checks the Ledger-V6 scenario: ledger entry type policies separate standard and ledger native shapes. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerEntryTypePoliciesSeparateStandardAndLedgerNativeShapes() + { + var corporateActionFamilies = EnumSet.of(LedgerEntryType.CORPORATE_ACTION); + var standardFamilies = EnumSet.complementOf(corporateActionFamilies); + + standardFamilies.forEach(this::assertStandardLegacyShape); + corporateActionFamilies.forEach(this::assertLedgerNativeTargetedShape); + + assertTrue(LedgerEntryType.CORPORATE_ACTION.requiresTargetedDerivedDescriptors()); + assertTrue(LedgerEntryType.CORPORATE_ACTION.usesSignedTargetedProjectionFacts()); + } + + /** + * Checks the Ledger-V6 scenario: ledger entry type does not use positional boolean policy arguments. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerEntryTypeDoesNotUsePositionalBooleanPolicyArguments() + { + assertFalse(Arrays.stream(LedgerEntryType.class.getDeclaredFields()).anyMatch(f -> f.getType() == boolean.class)); + assertFalse(Arrays.stream(LedgerEntryType.class.getDeclaredConstructors()) + .flatMap(c -> Arrays.stream(c.getParameterTypes())).anyMatch(t -> t == boolean.class)); + assertTrue(Arrays.stream(LedgerEntryType.class.getDeclaredConstructors()) + .flatMap(c -> Arrays.stream(c.getParameterTypes())).anyMatch(t -> t == String.class)); + assertFalse(Arrays.stream(LedgerEntryType.class.getDeclaredMethods()) + .anyMatch(method -> method.getName().contains("Protobuf"))); + } + + /** + * Checks the Ledger-V6 scenario: ledger posting type does not use positional boolean policy arguments. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testLedgerPostingTypeDoesNotUsePositionalBooleanPolicyArguments() + { + assertFalse(Arrays.stream(LedgerPostingType.class.getDeclaredFields()) + .anyMatch(f -> f.getType() == boolean.class)); + assertFalse(Arrays.stream(LedgerPostingType.class.getDeclaredConstructors()) + .flatMap(c -> Arrays.stream(c.getParameterTypes())).anyMatch(t -> t == boolean.class)); + } + + /** + * Checks the Ledger-V6 scenario: enum skeleton contains required posting and projection shapes. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testEnumSkeletonContainsRequiredPostingAndProjectionShapes() + { + assertThat(EnumSet.allOf(LedgerPostingType.class), + is(EnumSet.of(LedgerPostingType.CASH, LedgerPostingType.SECURITY, LedgerPostingType.FEE, + LedgerPostingType.TAX, LedgerPostingType.GROSS_VALUE, + LedgerPostingType.FOREX, LedgerPostingType.CASH_COMPENSATION, + LedgerPostingType.RIGHT, LedgerPostingType.BOND, + LedgerPostingType.ACCRUED_INTEREST, + LedgerPostingType.PRINCIPAL_REDEMPTION))); + assertThat(EnumSet.allOf(LedgerProjectionRole.class), + is(EnumSet.of(LedgerProjectionRole.ACCOUNT, LedgerProjectionRole.PORTFOLIO, + LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT, + LedgerProjectionRole.SOURCE_PORTFOLIO, LedgerProjectionRole.TARGET_PORTFOLIO, + LedgerProjectionRole.DELIVERY, LedgerProjectionRole.DELIVERY_INBOUND, + LedgerProjectionRole.DELIVERY_OUTBOUND, LedgerProjectionRole.CASH_COMPENSATION, + LedgerProjectionRole.OLD_SECURITY_LEG, LedgerProjectionRole.NEW_SECURITY_LEG))); + assertThat(EnumSet.allOf(LedgerParameterType.class), + is(EnumSet.of(LedgerParameterType.EX_DATE, + LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.SOURCE_SECURITY, + LedgerParameterType.TARGET_SECURITY, + LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.RATIO_DENOMINATOR, + LedgerParameterType.CASH_COMPENSATION_KIND, + LedgerParameterType.FEE_REASON, + LedgerParameterType.TAX_REASON, + LedgerParameterType.CORPORATE_ACTION_KIND, + LedgerParameterType.CORPORATE_ACTION_SUBTYPE, + LedgerParameterType.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, + LedgerParameterType.PAYMENT_DATE, + LedgerParameterType.EFFECTIVE_DATE, + LedgerParameterType.SETTLEMENT_DATE, + LedgerParameterType.ELECTION_DEADLINE, + LedgerParameterType.INTEREST_PERIOD_START, + LedgerParameterType.INTEREST_PERIOD_END, + LedgerParameterType.RIGHT_SECURITY, + LedgerParameterType.SOURCE_ACCOUNT, + LedgerParameterType.TARGET_ACCOUNT, + LedgerParameterType.CASH_ACCOUNT, + LedgerParameterType.SOURCE_PORTFOLIO, + LedgerParameterType.TARGET_PORTFOLIO, + LedgerParameterType.FRACTION_QUANTITY, + LedgerParameterType.CONVERSION_RATIO, + LedgerParameterType.PARTIAL_REDEMPTION_FACTOR, + LedgerParameterType.COUPON_RATE, + LedgerParameterType.REDEMPTION_PRICE_PERCENT, + LedgerParameterType.SOURCE_COST_PERCENT, + LedgerParameterType.TARGET_COST_PERCENT, + LedgerParameterType.AFFECTED_SOURCE_QUANTITY, + LedgerParameterType.NOMINAL_VALUE, + LedgerParameterType.SUBSCRIPTION_PRICE, + LedgerParameterType.REFERENCE_PRICE, + LedgerParameterType.CASH_IN_LIEU_AMOUNT, + LedgerParameterType.VALUATION_PRICE, + LedgerParameterType.FAIR_MARKET_VALUE, + LedgerParameterType.ACCRUED_INTEREST_AMOUNT, + LedgerParameterType.FRACTION_TREATMENT, + LedgerParameterType.ROUNDING_MODE, + LedgerParameterType.COST_ALLOCATION_METHOD, + LedgerParameterType.QUOTATION_STYLE, + LedgerParameterType.CASH_IN_LIEU_APPLIED, + LedgerParameterType.TAXABLE_DISTRIBUTION, + LedgerParameterType.MANUAL_VALUATION_OVERRIDE, + LedgerParameterType.SAME_SECURITY_AS_SOURCE, + LedgerParameterType.FRACTION_ROUNDED, + LedgerParameterType.RECLAIMABLE_TAX, + LedgerParameterType.WITHHOLDING_TAX, + LedgerParameterType.TRANSACTION_TAX, + LedgerParameterType.STAMP_DUTY))); + } + + /** + * Checks the Ledger-V6 scenario: configurable ledger entry type codes are stable. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testConfigurableLedgerEntryTypeCodesAreStable() + { + assertStableCodes(Arrays.stream(LedgerEntryType.values()).map(LedgerEntryType::getCode) + .toArray(String[]::new)); + + for (LedgerEntryType type : LedgerEntryType.values()) + assertThat(LedgerEntryType.fromCode(type.getCode()), is(type)); + + var exception = assertThrows(IllegalArgumentException.class, () -> LedgerEntryType.fromCode("UNKNOWN_CODE")); + + assertTrue(exception.getMessage(), exception.getMessage().contains(LedgerDiagnosticCode.LEDGER_CORE_017.prefix())); + assertTrue(exception.getMessage(), exception.getMessage().contains("LedgerEntryType")); + assertTrue(exception.getMessage(), exception.getMessage().contains("UNKNOWN_CODE")); + } + + /** + * Checks the Ledger-V6 scenario: configurable ledger posting type codes are stable. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testConfigurableLedgerPostingTypeCodesAreStable() + { + assertStableCodes(Arrays.stream(LedgerPostingType.values()).map(LedgerPostingType::getCode) + .toArray(String[]::new)); + + for (LedgerPostingType type : LedgerPostingType.values()) + assertThat(LedgerPostingType.fromCode(type.getCode()), is(type)); + + var exception = assertThrows(IllegalArgumentException.class, () -> LedgerPostingType.fromCode("UNKNOWN_CODE")); + + assertTrue(exception.getMessage(), exception.getMessage().contains(LedgerDiagnosticCode.LEDGER_CORE_022.prefix())); + assertTrue(exception.getMessage(), exception.getMessage().contains("LedgerPostingType")); + assertTrue(exception.getMessage(), exception.getMessage().contains("UNKNOWN_CODE")); + } + + /** + * Checks the Ledger-V6 scenario: configurable ledger parameter type codes are stable. + * The result must keep ledger truth and visible runtime rows consistent. + * This protects against duplicate truth or partial mutation. + */ + @Test + public void testConfigurableLedgerParameterTypeCodesAreStable() + { + assertStableCodes(Arrays.stream(LedgerParameterType.values()).map(LedgerParameterType::getCode) + .toArray(String[]::new)); + + for (LedgerParameterType type : LedgerParameterType.values()) + assertThat(LedgerParameterType.fromCode(type.getCode()), is(type)); + + var exception = assertThrows(IllegalArgumentException.class, + () -> LedgerParameterType.fromCode("UNKNOWN_CODE")); + + assertTrue(exception.getMessage(), exception.getMessage().contains(LedgerDiagnosticCode.LEDGER_CORE_020.prefix())); + assertTrue(exception.getMessage(), exception.getMessage().contains("LedgerParameterType")); + assertTrue(exception.getMessage(), exception.getMessage().contains("UNKNOWN_CODE")); + } + + private void assertStandardLegacyShape(LedgerEntryType type) + { + assertTrue(type.isLegacyFixedShape()); + assertFalse(type.isLedgerNativeTargeted()); + assertFalse(type.requiresTargetedDerivedDescriptors()); + assertTrue(type.supportsDerivedDescriptors()); + assertFalse(type.usesSignedTargetedProjectionFacts()); + } + + private void assertLedgerNativeTargetedShape(LedgerEntryType type) + { + assertFalse(type.isLegacyFixedShape()); + assertTrue(type.isLedgerNativeTargeted()); + assertTrue(type.requiresTargetedDerivedDescriptors()); + assertTrue(type.supportsDerivedDescriptors()); + assertTrue(type.usesSignedTargetedProjectionFacts()); + } + + private void assertValueKindPolicy(ValueKind valueKind, Class valueType) + { + assertThat(valueKind.getValueType(), is(valueType)); + assertTrue(valueKind.supportsValue(valueFor(valueKind))); + assertFalse(valueKind.supportsValue(null)); + assertFalse(valueKind.supportsValue(new Object())); + } + + private void assertStableCodes(String[] codes) + { + assertThat(Arrays.stream(codes).filter(String::isBlank).count(), is(0L)); + assertThat(Arrays.stream(codes).distinct().count(), is((long) codes.length)); + } + + private void assertParameterTypePolicy(LedgerParameterType type, LedgerParameterType.Scope scope, + ValueKind valueKind) + { + assertThat(type.getScope(), is(scope)); + assertThat(type.getExpectedValueKind(), is(valueKind)); + assertThat(type.getExpectedValueType(), is(valueKind.getValueType())); + assertThat(type.getExpectedJavaType(), is(valueKind.getValueType())); + assertTrue(type.supportsValueKind(valueKind)); + type.requireValueKind(valueKind); + assertTrue(type.supportsValue(valueFor(valueKind))); + assertFalse(type.supportsValue(null)); + assertFalse(type.supportsValue(new Object())); + assertThat(type.isReferenceParameter(), + is(valueKind == ValueKind.ACCOUNT || valueKind == ValueKind.PORTFOLIO + || valueKind == ValueKind.SECURITY)); + assertThat(type.isDateParameter(), + is(valueKind == ValueKind.LOCAL_DATE || valueKind == ValueKind.LOCAL_DATE_TIME)); + assertThat(type.isBooleanParameter(), is(valueKind == ValueKind.BOOLEAN)); + + EnumSet.complementOf(EnumSet.of(valueKind)).forEach(kind -> { + assertFalse(type.supportsValueKind(kind)); + assertFactoryRejects(type, kind, () -> type.requireValueKind(kind)); + }); + } + + private void assertParameterTypePolicies(LedgerParameterType.Scope scope, ValueKind valueKind, + LedgerParameterType... types) + { + for (var type : types) + assertParameterTypePolicy(type, scope, valueKind); + } + + private void assertCodeDomain(LedgerParameterType type, LedgerParameterCodeDomain domain, + LedgerCode... allowedCodes) + { + assertTrue(type.hasCodeDomain()); + assertThat(type.getCodeDomain(), is(domain)); + assertThat(domain.getAllowedCodes(), is(Arrays.stream(allowedCodes).map(LedgerCode::getCode).toList())); + + for (var allowedCode : allowedCodes) + { + assertTrue(domain.allows(allowedCode.getCode())); + assertTrue(type.supportsCode(allowedCode.getCode())); + } + + assertFalse(domain.allows("UNKNOWN_CODE")); + assertFalse(type.supportsCode("UNKNOWN_CODE")); + assertTrue(type.isControlledCode()); + } + + private void assertPostingTypePolicy(LedgerPostingType type, LedgerPostingType.ComponentClass componentClass, + boolean moneyBearing, boolean currencyRequired, boolean securityBearing, boolean securityRequired, + boolean sharesMeaningful, boolean accountReferenceMeaningful, + boolean portfolioReferenceMeaningful, boolean forexMeaningful) + { + assertThat(type.getComponentClass(), is(componentClass)); + assertThat(type.isMoneyBearing(), is(moneyBearing)); + assertThat(type.requiresCurrency(), is(currencyRequired)); + assertThat(type.isSecurityBearing(), is(securityBearing)); + assertThat(type.requiresSecurity(), is(securityRequired)); + assertThat(type.isSharesMeaningful(), is(sharesMeaningful)); + assertThat(type.isAccountReferenceMeaningful(), is(accountReferenceMeaningful)); + assertThat(type.isPortfolioReferenceMeaningful(), is(portfolioReferenceMeaningful)); + assertThat(type.isForexMeaningful(), is(forexMeaningful)); + } + + private void assertFactoryRejects(LedgerParameterType type, ValueKind valueKind, Runnable runnable) + { + var exception = assertThrows(IllegalArgumentException.class, runnable::run); + + assertTrue(exception.getMessage(), exception.getMessage().contains(type.name())); + assertTrue(exception.getMessage(), exception.getMessage().contains(valueKind.name())); + assertTrue(exception.getMessage(), exception.getMessage().contains(type.getExpectedValueKind().name())); + } + + private Object valueFor(ValueKind valueKind) + { + return switch (valueKind) + { + case STRING -> "value"; + case DECIMAL -> BigDecimal.ONE; + case LONG -> Long.valueOf(1); + case MONEY -> Money.of(CurrencyUnit.EUR, 1); + case SECURITY -> new Security("Security", CurrencyUnit.EUR); + case ACCOUNT -> new Account(); + case PORTFOLIO -> new Portfolio(); + case BOOLEAN -> Boolean.TRUE; + case LOCAL_DATE -> LocalDate.of(2020, 9, 28); + case LOCAL_DATE_TIME -> LocalDateTime.of(2020, 9, 28, 0, 0); + }; + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java new file mode 100644 index 0000000000..30d95f5785 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerMutationContextTest.java @@ -0,0 +1,914 @@ +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.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 = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); + 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, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).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 = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); + 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(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(); + + 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 = 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); + + 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 = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); + + LedgerProjectionService.materialize(client); + + 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 -> name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(editedEntry).get(0).getPrimaryPosting().setAccount(null))); + + 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()); + assertThat(account.getTransactions().size(), is(1)); + assertThat(account.getTransactions().get(0).getUUID(), is(projectionUUID)); + assertThat(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).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())); + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(editedEntry).get(0).getPrimaryPosting().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 = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); + 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"); + + name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(editedEntry).get(0).getPrimaryPosting().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, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getAccount()); + assertSame(target, entry.getPostings().get(0).getAccount()); + } + + /** + * 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. + */ + @Test + public void testMutationContextPreservesEntryLocalProjectionTargetingDuringCopySync() + { + var client = new Client(); + var account = register(client, account()); + var entry = targetedAccountEntry(account); + var projection = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); + var posting = entry.getPostings().get(0); + var projectionUUID = projection.getRuntimeProjectionId(); + 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(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), + 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)); + } + + private LedgerEntry targetedAccountEntry(Account account) + { + var entry = new LedgerEntry(); + var posting = new LedgerPosting(); + + entry.setType(LedgerEntryType.DEPOSIT); + entry.setDateTime(DATE_TIME); + 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); + entry.addPosting(posting); + + 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 = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(unrelatedEntry).get(0).getRuntimeProjectionId(); + + 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 = 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(name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(deletedEntry).get(0).getRuntimeProjectionId())) + .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 = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); + 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, name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).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.getRuntimeProjectionId(); + var portfolioProjectionUUID = portfolioProjection.getRuntimeProjectionId(); + var cashPostingUUID = accountProjection.getPrimaryPosting().getUUID(); + var securityPostingUUID = portfolioProjection.getPrimaryPosting().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 = sourceProjection.getPrimaryPosting().getUUID(); + var targetPostingUUID = targetProjection.getPrimaryPosting().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 = sourceProjection.getPrimaryPosting().getUUID(); + var targetPostingUUID = targetProjection.getPrimaryPosting().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. + */ + + + /** + * 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 = 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(); + + 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(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))); + } + + /** + * 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. + */ + + + /** + * 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 = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0).getRuntimeProjectionId(); + + 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 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, 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/LedgerNativeEntryDefinitionValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java new file mode 100644 index 0000000000..4d37425021 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerNativeEntryDefinitionValidatorTest.java @@ -0,0 +1,1456 @@ +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.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.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; +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.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; +import name.abuchen.portfolio.model.ledger.configuration.TaxReason; +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.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 corporate-action entry without a kind is rejected + * before it can select a kind-specific native definition. + */ + @Test + public void testCorporateActionWithoutKindIsRejected() + { + var entry = copyValidSpinOff(); + removeEntryParameters(entry, LedgerParameterType.CORPORATE_ACTION_KIND); + + assertIssue(entry, IssueCode.ENTRY_DEFINITION_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 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 testMissingSourceSecurityLegIsAccepted() + { + var entry = copyValidSpinOff(); + var sourcePosting = postingFor(entry, LedgerProjectionRole.OLD_SECURITY_LEG); + + projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG).getPrimaryPosting().setUnitRole(null); + entry.removePosting(sourcePosting); + + assertOK(entry); + } + + /** + * 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 testMissingTargetSecurityLegIsRejected() + { + var entry = copyValidSpinOff(); + var targetPosting = postingFor(entry, LedgerProjectionRole.NEW_SECURITY_LEG); + + entry.removePosting(targetPosting); + + assertIssue(entry, IssueCode.LEG_CARDINALITY_VIOLATED); + } + + @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-2")); //$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); + } + + @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 testCouponPaymentDefinitionRejectsCashWithoutInterestDetail() + { + 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$ + + 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() + { + 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() + { + 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); + } + + @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); + } + + @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. + */ + @Test + 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); + } + + @Test + public void testSpinOffDefinitionAcceptsRepeatedTargetLegsWithDistinctLocalKeys() + { + 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); + + 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); + } + + /** + * 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(); + + projection(entry, LedgerProjectionRole.OLD_SECURITY_LEG).getPrimaryPosting().setCorporateActionLeg(null); + + 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(); + + projection(entry, LedgerProjectionRole.NEW_SECURITY_LEG).getPrimaryPosting().setCorporateActionLeg(null); + + 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).getPrimaryPosting().setCorporateActionLeg(targetPosting.getCorporateActionLeg()); + + assertIssue(entry, IssueCode.REQUIRED_PROJECTION_MISSING); + } + + /** + * 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).getPrimaryPosting().setCorporateActionLeg(sourcePosting.getCorporateActionLeg()); + + assertIssue(entry, IssueCode.REQUIRED_PROJECTION_MISSING); + } + + /** + * 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).getPrimaryPosting().setGroupKey(null); + + assertIssue(entry, IssueCode.PROJECTION_POSTING_GROUP_REQUIRED); + } + + /** + * Checks that buildDetached accepts partial spin-off movement shapes after + * the definition cardinality cleanup. + */ + @Test + public void testAssemblerBuildDetachedAcceptsPartialSpinOffEntry() + { + var fixture = fixture(); + var result = baseSpinOff(fixture) // + .securityLeg(contextLeg(fixture).build()) // + .securityLeg(targetLeg(fixture).build()).buildDetached(); + + assertTrue(result.getValidationResult().isOK()); + assertThat(result.getEntry().getPostings().size(), is(2)); + } + + /** + * Checks that buildAndAdd accepts partial spin-off movement shapes and + * materializes the descriptors that are present. + */ + @Test + public void testAssemblerBuildAndAddAcceptsPartialSpinOffEntry() + { + var fixture = fixture(); + 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)); + assertThat(fixture.account.getTransactions().size(), is(0)); + assertThat(fixture.portfolio.getTransactions().size(), is(1)); + } + + /** + * 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 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()); + } + + 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 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 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 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$ + + 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 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 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, + 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 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 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, + 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() + .ifPresent(entry::removePosting); + } + + private static Money money(long amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + + private static 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 static LedgerPosting postingFor(LedgerEntry entry, LedgerProjectionRole role) + { + var postingUUID = projection(entry, role).getPrimaryPosting().getUUID(); + + 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..8b7a5113c8 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerParameterCodeDomainTest.java @@ -0,0 +1,100 @@ +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", "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("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")), + 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")), + Map.entry(LedgerParameterCodeDomain.CASH_COMPENSATION_KIND, + List.of("CASH_IN_LIEU", "FRACTIONAL_SHARE_COMPENSATION", + "ROUNDING_COMPENSATION", "OTHER")), + Map.entry(LedgerParameterCodeDomain.FRACTION_TREATMENT, + List.of("NONE", "CASH_IN_LIEU", "ROUND_DOWN", "ROUND_UP", "DROP", "OTHER")), + Map.entry(LedgerParameterCodeDomain.ROUNDING_MODE, + List.of("NONE", "FLOOR", "CEILING", "HALF_UP", "HALF_EVEN", "OTHER")), + Map.entry(LedgerParameterCodeDomain.COST_ALLOCATION_METHOD, + List.of("NONE", "FMV_RATIO", "MANUAL_PERCENTAGE", "ZERO_COST_TARGET", + "CARRY_OVER", "OTHER")), + Map.entry(LedgerParameterCodeDomain.QUOTATION_STYLE, + List.of("UNIT", "PERCENT", "NOMINAL", "OTHER")), + Map.entry(LedgerParameterCodeDomain.FEE_REASON, + List.of("BROKER_FEE", "EXCHANGE_FEE", "CORPORATE_ACTION_FEE", "STAMP_DUTY", + "OTHER")), + Map.entry(LedgerParameterCodeDomain.TAX_REASON, + List.of("WITHHOLDING_TAX", "CAPITAL_GAINS_TAX", "TRANSACTION_TAX", + "STAMP_DUTY", "RECLAIMABLE_TAX", "OTHER"))); + + /** + * Checks the ledger rule scenario: allowed codes remain persisted string contract. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testAllowedCodesRemainPersistedStringContract() + { + assertThat(EXPECTED_CODES.keySet(), is(EnumSet.allOf(LedgerParameterCodeDomain.class))); + + for (var domain : LedgerParameterCodeDomain.values()) + assertThat(domain.getAllowedCodes(), is(EXPECTED_CODES.get(domain))); + } + + /** + * Checks the ledger rule scenario: allows remains compatible. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testAllowsRemainsCompatible() + { + for (var domain : LedgerParameterCodeDomain.values()) + { + assertFalse(domain.getAllowedCodes().isEmpty()); + assertThat(domain.getAllowedCodes().stream().distinct().count(), is((long) domain.getAllowedCodes().size())); + assertTrue(domain.getAllowedCodes().stream().allMatch(code -> code.equals(code.toUpperCase(Locale.ROOT)))); + + for (var code : domain.getAllowedCodes()) + assertTrue(domain.allows(code)); + + assertFalse(domain.allows("UNKNOWN_CODE")); + } + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerParameterTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerParameterTest.java new file mode 100644 index 0000000000..a332923b63 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerParameterTest.java @@ -0,0 +1,86 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerParameter.ValueKind; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.TaxReason; + +/** + * Tests ledger configuration and validation metadata. + * These tests make sure entry definitions, posting rules, and parameter domains stay stable for Ledger-V6 transactions. + */ +@SuppressWarnings("nls") +public class LedgerParameterTest +{ + /** + * Checks the ledger rule scenario: of code accepts matching domain. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testOfCodeAcceptsMatchingDomain() + { + var parameter = LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_LEG, + CorporateActionLeg.CASH_COMPENSATION); + + assertThat(parameter.getType(), is(LedgerParameterType.CORPORATE_ACTION_LEG)); + assertThat(parameter.getValueKind(), is(ValueKind.STRING)); + assertThat(parameter.getValue(), is(CorporateActionLeg.CASH_COMPENSATION.getCode())); + } + + /** + * Checks the ledger rule scenario: of code rejects wrong domain. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testOfCodeRejectsWrongDomain() + { + var exception = assertThrows(IllegalArgumentException.class, + () -> LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_LEG, + TaxReason.WITHHOLDING_TAX)); + + assertThat(exception.getMessage(), containsString(LedgerDiagnosticCode.LEDGER_CORE_013.prefix())); + assertThat(exception.getMessage(), containsString("expects code domain")); //$NON-NLS-1$ + } + + /** + * Checks the ledger rule scenario: of code rejects non code domain parameter. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testOfCodeRejectsNonCodeDomainParameter() + { + var exception = assertThrows(IllegalArgumentException.class, + () -> LedgerParameter.ofCode(LedgerParameterType.EVENT_REFERENCE, + CorporateActionLeg.CASH_COMPENSATION)); + + assertThat(exception.getMessage(), containsString(LedgerDiagnosticCode.LEDGER_CORE_012.prefix())); + assertThat(exception.getMessage(), containsString("does not define a controlled code domain")); //$NON-NLS-1$ + } + + /** + * Checks the ledger rule scenario: of code rejects non string parameter. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testOfCodeRejectsNonStringParameter() + { + var exception = assertThrows(IllegalArgumentException.class, + () -> LedgerParameter.ofCode(LedgerParameterType.SOURCE_SECURITY, + CorporateActionLeg.SOURCE_SECURITY)); + + assertThat(exception.getMessage(), containsString(LedgerDiagnosticCode.LEDGER_CORE_021.prefix())); + assertThat(exception.getMessage(), containsString("does not support STRING")); //$NON-NLS-1$ + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerPostingTypeDefinitionTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerPostingTypeDefinitionTest.java new file mode 100644 index 0000000000..857393f070 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerPostingTypeDefinitionTest.java @@ -0,0 +1,198 @@ +package name.abuchen.portfolio.model.ledger; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.time.LocalDateTime; +import java.util.EnumSet; + +import org.junit.Test; + +import name.abuchen.portfolio.model.ledger.configuration.FeeReason; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryDefinitionRegistry; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEventParameterDefinition; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingTypeDefinitionRegistry; +import name.abuchen.portfolio.money.CurrencyUnit; + +/** + * Tests ledger configuration and validation metadata. + * These tests make sure entry definitions, posting rules, and parameter domains stay stable for Ledger-V6 transactions. + */ +@SuppressWarnings("nls") +public class LedgerPostingTypeDefinitionTest +{ + /** + * Checks the ledger rule scenario: every posting type has definition. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testEveryPostingTypeHasDefinition() + { + for (var postingType : LedgerPostingType.values()) + { + var definition = LedgerPostingTypeDefinitionRegistry.lookup(postingType).orElseThrow(); + + assertThat(definition.getPostingType(), is(postingType)); + assertFalse(definition.getComponentParameterTypes().isEmpty()); + } + + assertThat(LedgerPostingTypeDefinitionRegistry.getDefinitions().size(), is(LedgerPostingType.values().length)); + } + + /** + * Checks the ledger rule scenario: posting type definitions are consistent static configuration. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testPostingTypeDefinitionsAreConsistentStaticConfiguration() + { + for (var definition : LedgerPostingTypeDefinitionRegistry.getDefinitions()) + { + assertTrue(LedgerPostingTypeDefinitionRegistry.hasDefinition(definition.getPostingType())); + + for (var parameterType : definition.getComponentParameterTypes()) + { + assertTrue(definition.supportsParameterType(parameterType)); + assertTrue(EnumSet.allOf(LedgerParameterType.class).contains(parameterType)); + } + } + } + + /** + * Checks the ledger rule scenario: component fact mapping sanity. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testComponentFactMappingSanity() + { + assertSupports(LedgerPostingType.CASH, LedgerParameterType.SOURCE_ACCOUNT, LedgerParameterType.TARGET_ACCOUNT, + LedgerParameterType.CASH_ACCOUNT, LedgerParameterType.PAYMENT_DATE); + + assertSupports(LedgerPostingType.SECURITY, LedgerParameterType.SOURCE_SECURITY, + LedgerParameterType.TARGET_SECURITY, LedgerParameterType.RATIO_NUMERATOR, + LedgerParameterType.FRACTION_TREATMENT, LedgerParameterType.COST_ALLOCATION_METHOD, + LedgerParameterType.REFERENCE_PRICE, LedgerParameterType.CORPORATE_ACTION_LEG); + + assertSupports(LedgerPostingType.CASH_COMPENSATION, LedgerParameterType.CASH_COMPENSATION_KIND, + LedgerParameterType.CASH_IN_LIEU_AMOUNT, LedgerParameterType.CASH_IN_LIEU_APPLIED, + LedgerParameterType.FRACTION_QUANTITY, LedgerParameterType.CORPORATE_ACTION_LEG); + + assertSupports(LedgerPostingType.FEE, LedgerParameterType.FEE_REASON); + assertSupports(LedgerPostingType.TAX, LedgerParameterType.TAX_REASON, LedgerParameterType.WITHHOLDING_TAX, + LedgerParameterType.RECLAIMABLE_TAX); + assertSupports(LedgerPostingType.GROSS_VALUE, LedgerParameterType.REFERENCE_PRICE, + LedgerParameterType.VALUATION_PRICE, LedgerParameterType.FAIR_MARKET_VALUE); + assertSupports(LedgerPostingType.FOREX, LedgerParameterType.REFERENCE_PRICE, + LedgerParameterType.VALUATION_PRICE); + assertSupports(LedgerPostingType.RIGHT, LedgerParameterType.RIGHT_SECURITY, + LedgerParameterType.SUBSCRIPTION_PRICE, LedgerParameterType.ELECTION_DEADLINE); + assertSupports(LedgerPostingType.BOND, LedgerParameterType.NOMINAL_VALUE, + LedgerParameterType.QUOTATION_STYLE, LedgerParameterType.CONVERSION_RATIO, + LedgerParameterType.REDEMPTION_PRICE_PERCENT); + assertSupports(LedgerPostingType.ACCRUED_INTEREST, LedgerParameterType.ACCRUED_INTEREST_AMOUNT, + LedgerParameterType.COUPON_RATE, LedgerParameterType.INTEREST_PERIOD_START, + LedgerParameterType.INTEREST_PERIOD_END); + assertSupports(LedgerPostingType.PRINCIPAL_REDEMPTION, LedgerParameterType.NOMINAL_VALUE, + LedgerParameterType.REDEMPTION_PRICE_PERCENT, + LedgerParameterType.PARTIAL_REDEMPTION_FACTOR); + } + + /** + * Checks the ledger rule scenario: event level parameters are classified separately. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testEventLevelParametersAreClassifiedSeparately() + { + assertTrue(LedgerEventParameterDefinition.supportsParameterType(LedgerParameterType.CORPORATE_ACTION_KIND)); + assertTrue(LedgerEventParameterDefinition.supportsParameterType(LedgerParameterType.CORPORATE_ACTION_SUBTYPE)); + assertTrue(LedgerEventParameterDefinition.supportsParameterType(LedgerParameterType.EVENT_REFERENCE)); + assertTrue(LedgerEventParameterDefinition.supportsParameterType(LedgerParameterType.EVENT_STAGE)); + assertTrue(LedgerEventParameterDefinition.supportsParameterType(LedgerParameterType.EX_DATE)); + assertTrue(LedgerEventParameterDefinition.supportsParameterType(LedgerParameterType.ELECTION_DEADLINE)); + assertFalse(LedgerEventParameterDefinition.supportsParameterType(LedgerParameterType.FEE_REASON)); + } + + /** + * Checks the ledger rule scenario: native entry definitions reference defined posting fact vocabulary. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testNativeEntryDefinitionsReferenceDefinedPostingFactVocabulary() + { + for (var entryDefinition : LedgerEntryDefinitionRegistry.getDefinitions()) + { + for (var postingType : entryDefinition.getPostingTypes()) + assertTrue(LedgerPostingTypeDefinitionRegistry.hasDefinition(postingType)); + + for (var parameterType : entryDefinition.getEntryParameterTypes()) + assertTrue(parameterType.name(), isEventOrPostingFact(entryDefinition.getPostingTypes(), parameterType)); + + for (var parameterType : entryDefinition.getPostingParameterTypes()) + assertTrue(parameterType.name(), isEventOrPostingFact(entryDefinition.getPostingTypes(), parameterType)); + } + } + + /** + * Checks the ledger rule scenario: definition layer does not enforce posting fact completeness. + * Invalid entry shapes must be rejected before they can be stored. + * This keeps higher-level Ledger-V6 transaction flows predictable. + */ + @Test + public void testDefinitionLayerDoesNotEnforcePostingFactCompleteness() + { + var ledger = new Ledger(); + var entry = new LedgerEntry("entry-1"); + var posting = new LedgerPosting("posting-1"); + + entry.setType(LedgerEntryType.CORPORATE_ACTION); + entry.setDateTime(LocalDateTime.of(2026, 1, 2, 0, 0)); + posting.setType(LedgerPostingType.CASH); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.addParameter(LedgerParameter.ofString(LedgerParameterType.FEE_REASON, + FeeReason.BROKER_FEE.getCode())); + entry.addPosting(posting); + ledger.addEntry(entry); + + assertTrue(LedgerStructuralValidator.validate(ledger).isOK()); + assertFalse(LedgerPostingTypeDefinitionRegistry.lookup(LedgerPostingType.CASH).orElseThrow() + .supportsParameterType(LedgerParameterType.FEE_REASON)); + } + + private void assertSupports(LedgerPostingType postingType, LedgerParameterType... parameterTypes) + { + var definition = LedgerPostingTypeDefinitionRegistry.lookup(postingType).orElseThrow(); + + for (var parameterType : parameterTypes) + assertTrue(parameterType.name(), definition.supportsParameterType(parameterType)); + } + + private boolean isEventOrPostingFact(Iterable postingTypes, LedgerParameterType parameterType) + { + if (LedgerEventParameterDefinition.supportsParameterType(parameterType)) + return true; + + for (var postingType : postingTypes) + { + var definition = LedgerPostingTypeDefinitionRegistry.lookup(postingType).orElseThrow(); + + if (definition.supportsParameterType(parameterType)) + return true; + } + + return false; + } +} diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java new file mode 100644 index 0000000000..c2c1b960c2 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerProjectionMaterializerTest.java @@ -0,0 +1,921 @@ +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.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.Transaction; +import name.abuchen.portfolio.model.Transaction.Unit; +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.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; +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; + +/** + * 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 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(descriptor.getRuntimeProjectionId())); + 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)); + } + + @Test + public void testMaterializationUsesDerivedDescriptorWhenPersistedProjectionLayoutIsAbsent() + { + var client = new Client(); + var account = account(); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + + LedgerProjectionService.materialize(client); + + 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))); + } + + @Test + public void testFixedShapeMaterializationUsesDescriptorsWithoutPersistedProjectionLayout() + { + assertDescriptorMaterializesBuySell(PortfolioTransaction.Type.BUY, AccountTransaction.Type.BUY); + assertDescriptorMaterializesBuySell(PortfolioTransaction.Type.SELL, AccountTransaction.Type.SELL); + assertDescriptorMaterializesDelivery(true); + assertDescriptorMaterializesDelivery(false); + assertDescriptorMaterializesCashTransfer(); + assertDescriptorMaterializesSecurityTransfer(); + } + + @Test + public void testSiemensSpinOffMaterializesFromDerivedDescriptorsWithoutPersistedProjectionLayout() + { + 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.context() // + .portfolio(portfolio) // + .security(siemens) // + .shares(0L) // + .amount(money(0)) // + .groupKey("main") // + .localKey("context-1") // + .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(); + + client.getLedger().addEntry(entry); + + LedgerProjectionService.materialize(client); + + 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() + .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)); + } + + @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. + * 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 exception = assertThrows(IllegalArgumentException.class, + () -> 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=DELIVERY_INBOUND Missing semantic primary posting")); //$NON-NLS-1$ + } + + /** + * 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 exception = assertThrows(IllegalArgumentException.class, + () -> 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 Semantic account owner is missing")); //$NON-NLS-1$ + } + + 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"); + + 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); + + return entry; + } + + private LedgerEntry portfolioProjectionEntry(LedgerEntryType type) + { + var portfolio = portfolio(); + var entry = new LedgerEntry(); + var posting = new LedgerPosting("posting-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); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + entry.addPosting(posting); + + 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(); + + LedgerProjectionService.materialize(client); + + 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()); + } + + 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(); + + LedgerProjectionService.materialize(client); + + 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)); + } + + 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(); + + moveFirstPostingToEnd(entry); + LedgerProjectionService.materialize(client); + + 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), + 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(); + + moveFirstPostingToEnd(entry); + LedgerProjectionService.materialize(client); + + 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), + source.getTransactions().get(0).getCrossEntry() + .getCrossTransaction(source.getTransactions().get(0))); + } + + private void moveFirstPostingToEnd(LedgerEntry entry) + { + var posting = entry.getPostings().get(0); + + 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(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, + "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) + { + } +} 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/LedgerStructuralValidatorTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidatorTest.java new file mode 100644 index 0000000000..83236a17c0 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidatorTest.java @@ -0,0 +1,574 @@ +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 org.junit.Test; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator.IssueCode; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Values; + +/** + * Tests structural validation for ledger entries. + */ +@SuppressWarnings("nls") +public class LedgerStructuralValidatorTest +{ + @Test + public void testEmptyLedgerIsValid() + { + assertOK(LedgerStructuralValidator.validate(new Ledger())); + } + + @Test + public void testSimpleValidLedgerEntryPassesValidation() + { + assertOK(LedgerStructuralValidator.validate(createStandardLedger())); + } + + @Test + public void testValidFixedShapeSemanticEntriesPassValidation() + { + for (var type : new LedgerEntryType[] { LedgerEntryType.DEPOSIT, LedgerEntryType.REMOVAL, + LedgerEntryType.INTEREST, LedgerEntryType.INTEREST_CHARGE, LedgerEntryType.DIVIDENDS, + LedgerEntryType.FEES, LedgerEntryType.FEES_REFUND, LedgerEntryType.TAXES, + LedgerEntryType.TAX_REFUND, LedgerEntryType.BUY, LedgerEntryType.SELL, + LedgerEntryType.DELIVERY_INBOUND, LedgerEntryType.DELIVERY_OUTBOUND, + LedgerEntryType.CASH_TRANSFER, LedgerEntryType.SECURITY_TRANSFER }) + assertOK(LedgerStructuralValidator.validate(ledger(entry(type)))); + } + + @Test + public void testMissingTransferSourceIsRejected() + { + var entry = entry(LedgerEntryType.CASH_TRANSFER); + entry.removePosting(posting(entry, LedgerPostingDirection.OUTBOUND)); + + assertIssue(entry, IssueCode.SEMANTIC_SOURCE_REQUIRED); + } + + @Test + public void testMissingTransferTargetIsRejected() + { + var entry = entry(LedgerEntryType.CASH_TRANSFER); + entry.removePosting(posting(entry, LedgerPostingDirection.INBOUND)); + + assertIssue(entry, IssueCode.SEMANTIC_TARGET_REQUIRED); + } + + @Test + public void testDuplicateTransferSourceIsRejected() + { + var entry = entry(LedgerEntryType.CASH_TRANSFER); + entry.addPosting(cashPosting("duplicate-source", LedgerPostingDirection.OUTBOUND)); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.SEMANTIC_SOURCE_AMBIGUOUS); + } + + @Test + public void testDuplicateTransferTargetIsRejected() + { + var entry = entry(LedgerEntryType.CASH_TRANSFER); + entry.addPosting(cashPosting("duplicate-target", LedgerPostingDirection.INBOUND)); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.SEMANTIC_TARGET_AMBIGUOUS); + } + + @Test + public void testMissingPrimarySemanticRoleIsRejected() + { + var entry = entry(LedgerEntryType.BUY); + posting(entry, LedgerPostingSemanticRole.CASH).setSemanticRole(null); + + assertIssue(entry, IssueCode.SEMANTIC_PRIMARY_REQUIRED); + } + + @Test + public void testDuplicatePrimaryPostingsAreRejected() + { + var entry = entry(LedgerEntryType.DEPOSIT); + entry.addPosting(cashPosting("duplicate-cash", LedgerPostingDirection.NEUTRAL)); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.SEMANTIC_PRIMARY_AMBIGUOUS); + } + + @Test + public void testGroupedUnitWithoutGroupKeyIsRejectedWhenEntryHasMultiplePrimaries() + { + var entry = entry(LedgerEntryType.BUY); + posting(entry, LedgerPostingSemanticRole.CASH).setGroupKey(LedgerProjectionRole.ACCOUNT.name()); + posting(entry, LedgerPostingSemanticRole.SECURITY).setGroupKey(LedgerProjectionRole.PORTFOLIO.name()); + var fee = unitPosting(LedgerPostingType.FEE, LedgerPostingUnitRole.FEE, null); + entry.addPosting(fee); + + assertIssue(entry, IssueCode.SEMANTIC_UNIT_GROUP_REQUIRED); + } + + @Test + public void testUnitWithUnknownGroupKeyIsRejected() + { + var entry = entry(LedgerEntryType.BUY); + var fee = unitPosting(LedgerPostingType.FEE, LedgerPostingUnitRole.FEE, "missing-group"); //$NON-NLS-1$ + entry.addPosting(fee); + + assertIssue(entry, IssueCode.SEMANTIC_UNIT_GROUP_AMBIGUOUS); + } + + @Test + public void testRepeatedUnitWithoutLocalKeyIsRejected() + { + var entry = entry(LedgerEntryType.BUY); + entry.addPosting(unitPosting(LedgerPostingType.FEE, LedgerPostingUnitRole.FEE, "account")); //$NON-NLS-1$ + entry.addPosting(unitPosting(LedgerPostingType.FEE, LedgerPostingUnitRole.FEE, "account")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED); + } + + @Test + public void testOptionalLocalKeyIsNotRequiredForUniqueUnit() + { + var entry = entry(LedgerEntryType.BUY); + entry.addPosting(unitPosting(LedgerPostingType.FEE, LedgerPostingUnitRole.FEE, "account")); //$NON-NLS-1$ + + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + + @Test + public void testCorporateActionLegCompletenessIsValidated() + { + assertOK(LedgerStructuralValidator.validate(ledger(nativeEntry(LedgerEntryType.CORPORATE_ACTION)))); + } + + @Test + public void 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 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() + { + 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() + { + var entry = new LedgerEntry(); + entry.setType(LedgerEntryType.CORPORATE_ACTION); + entry.setDateTime(LocalDateTime.of(2026, 1, 1, 10, 0)); + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF)); + + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + + @Test + public void testDuplicateCorporateActionLegWithoutLocalKeyIsRejected() + { + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); + var duplicate = securityPosting("duplicate-target", LedgerPostingDirection.INBOUND, //$NON-NLS-1$ + CorporateActionLeg.TARGET_SECURITY); + duplicate.setLocalKey(null); + entry.addPosting(duplicate); + + assertIssue(entry, IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED); + } + + @Test + public void testSpinOffAcceptsRepeatedTargetLegsWithDistinctLocalKeys() + { + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); + var target = posting(entry, CorporateActionLeg.TARGET_SECURITY); + var duplicate = securityPosting("target-2", LedgerPostingDirection.INBOUND, //$NON-NLS-1$ + CorporateActionLeg.TARGET_SECURITY); + + target.setLocalKey("target-1"); //$NON-NLS-1$ + target.setGroupKey("main"); //$NON-NLS-1$ + duplicate.setGroupKey("main"); //$NON-NLS-1$ + entry.addPosting(duplicate); + + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + + @Test + public void testSpinOffRejectsRepeatedTargetLegsWithDuplicateLocalKey() + { + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); + var target = posting(entry, CorporateActionLeg.TARGET_SECURITY); + var duplicate = securityPosting("target-1", LedgerPostingDirection.INBOUND, //$NON-NLS-1$ + CorporateActionLeg.TARGET_SECURITY); + + target.setLocalKey("target-1"); //$NON-NLS-1$ + entry.addPosting(duplicate); + + assertIssue(entry, IssueCode.SEMANTIC_TARGET_AMBIGUOUS); + } + + @Test + public void testSpinOffWithoutTargetLegIsAccepted() + { + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); + entry.removePosting(posting(entry, CorporateActionLeg.TARGET_SECURITY)); + + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + + @Test + public void testSpinOffAcceptsRepeatedCashCompensationWithDistinctLocalKeys() + { + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); + + entry.addPosting(cashCompensationPosting("cash-1")); //$NON-NLS-1$ + entry.addPosting(cashCompensationPosting("cash-2")); //$NON-NLS-1$ + + assertOK(LedgerStructuralValidator.validate(ledger(entry))); + } + + @Test + public void testSpinOffRejectsRepeatedCashCompensationWithDuplicateLocalKey() + { + var entry = nativeEntry(LedgerEntryType.CORPORATE_ACTION); + + entry.addPosting(cashCompensationPosting("cash-1")); //$NON-NLS-1$ + entry.addPosting(cashCompensationPosting("cash-1")); //$NON-NLS-1$ + + assertIssue(entry, IssueCode.SEMANTIC_PRIMARY_AMBIGUOUS); + } + + private Ledger createStandardLedger() + { + return ledger(entry(LedgerEntryType.DEPOSIT)); + } + + private Ledger ledger(LedgerEntry entry) + { + var ledger = new Ledger(); + ledger.addEntry(entry); + + return ledger; + } + + private LedgerEntry entry(LedgerEntryType type) + { + var entry = new LedgerEntry(); + entry.setType(type); + entry.setDateTime(LocalDateTime.of(2026, 1, 1, 10, 0)); + + switch (type) + { + case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, DIVIDENDS -> + { + var posting = cashPosting("account", LedgerPostingDirection.NEUTRAL); //$NON-NLS-1$ + if (type == LedgerEntryType.DIVIDENDS) + posting.setSecurity(security()); + entry.addPosting(posting); + } + case FEES, FEES_REFUND -> entry.addPosting(accountPosting("fee", LedgerPostingType.FEE, //$NON-NLS-1$ + LedgerPostingSemanticRole.FEE, LedgerPostingDirection.NEUTRAL)); + case TAXES, TAX_REFUND -> entry.addPosting(accountPosting("tax", LedgerPostingType.TAX, //$NON-NLS-1$ + LedgerPostingSemanticRole.TAX, LedgerPostingDirection.NEUTRAL)); + case BUY -> { + entry.addPosting(cashPosting("account", LedgerPostingDirection.OUTBOUND)); //$NON-NLS-1$ + entry.addPosting(securityPosting("portfolio", LedgerPostingDirection.INBOUND)); //$NON-NLS-1$ + } + case SELL -> { + entry.addPosting(cashPosting("account", LedgerPostingDirection.INBOUND)); //$NON-NLS-1$ + entry.addPosting(securityPosting("portfolio", LedgerPostingDirection.OUTBOUND)); //$NON-NLS-1$ + } + case DELIVERY_INBOUND -> entry.addPosting(securityPosting("delivery-in", LedgerPostingDirection.INBOUND)); //$NON-NLS-1$ + case DELIVERY_OUTBOUND -> entry.addPosting(securityPosting("delivery-out", LedgerPostingDirection.OUTBOUND)); //$NON-NLS-1$ + case CASH_TRANSFER -> { + entry.addPosting(cashPosting("source", LedgerPostingDirection.OUTBOUND)); //$NON-NLS-1$ + entry.addPosting(cashPosting("target", LedgerPostingDirection.INBOUND)); //$NON-NLS-1$ + } + case SECURITY_TRANSFER -> { + entry.addPosting(securityPosting("source", LedgerPostingDirection.OUTBOUND)); //$NON-NLS-1$ + entry.addPosting(securityPosting("target", LedgerPostingDirection.INBOUND)); //$NON-NLS-1$ + } + default -> throw new IllegalArgumentException(type.name()); + } + + return entry; + } + + private LedgerEntry nativeEntry(LedgerEntryType type) + { + var entry = new LedgerEntry(); + entry.setType(type); + entry.setDateTime(LocalDateTime.of(2026, 1, 1, 10, 0)); + + switch (type) + { + case CORPORATE_ACTION -> { + entry.addParameter(LedgerParameter.ofCode(LedgerParameterType.CORPORATE_ACTION_KIND, + CorporateActionKind.SPIN_OFF)); + entry.addPosting(securityPosting(LedgerProjectionRole.OLD_SECURITY_LEG.name(), + LedgerPostingDirection.OUTBOUND, CorporateActionLeg.SOURCE_SECURITY)); + entry.addPosting(securityPosting(LedgerProjectionRole.NEW_SECURITY_LEG.name(), + LedgerPostingDirection.INBOUND, + CorporateActionLeg.TARGET_SECURITY)); + } + default -> throw new IllegalArgumentException(type.name()); + } + + return entry; + } + + private 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); + } + + private LedgerPosting accountPosting(String localKey, LedgerPostingType type, LedgerPostingSemanticRole role, + LedgerPostingDirection direction) + { + var posting = new LedgerPosting(); + posting.setType(type); + posting.setAmount(Values.Amount.factorize(100)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setAccount(account()); + markPrimary(posting, role, direction, localKey); + + return posting; + } + + private LedgerPosting securityPosting(String localKey, LedgerPostingDirection direction) + { + var posting = new LedgerPosting(); + posting.setType(LedgerPostingType.SECURITY); + posting.setAmount(Values.Amount.factorize(100)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setPortfolio(portfolio()); + posting.setSecurity(security()); + posting.setShares(Values.Share.factorize(10)); + markPrimary(posting, LedgerPostingSemanticRole.SECURITY, direction, localKey); + + return posting; + } + + private LedgerPosting securityPosting(String localKey, LedgerPostingDirection direction, CorporateActionLeg leg) + { + var posting = securityPosting(localKey, direction); + posting.setCorporateActionLeg(leg); + posting.setGroupKey(localKey); + return posting; + } + + private LedgerPosting 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, + LedgerPostingSemanticRole.CASH_COMPENSATION, LedgerPostingDirection.NEUTRAL); + posting.setCorporateActionLeg(CorporateActionLeg.CASH_COMPENSATION); + posting.setGroupKey(localKey); + return posting; + } + + private LedgerPosting rightPosting(String localKey, CorporateActionLeg leg) + { + var posting = securityPosting(localKey, LedgerPostingDirection.INBOUND, leg); + posting.setType(LedgerPostingType.RIGHT); + posting.setSemanticRole(LedgerPostingSemanticRole.RIGHT); + return posting; + } + + private LedgerPosting bondPosting(String localKey, LedgerPostingDirection direction, CorporateActionLeg leg) + { + var posting = securityPosting(localKey, direction, leg); + posting.setType(LedgerPostingType.BOND); + posting.setSemanticRole(LedgerPostingSemanticRole.BOND); + return posting; + } + + private LedgerPosting unitPosting(LedgerPostingType type, LedgerPostingUnitRole unitRole, String groupKey) + { + var posting = new LedgerPosting(); + posting.setType(type); + posting.setAmount(Values.Amount.factorize(1)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(type == LedgerPostingType.FEE ? LedgerPostingSemanticRole.FEE + : LedgerPostingSemanticRole.TAX); + posting.setUnitRole(unitRole); + posting.setGroupKey(groupKey); + + return posting; + } + + private void markPrimary(LedgerPosting posting, LedgerPostingSemanticRole role, LedgerPostingDirection direction, + String localKey) + { + posting.setSemanticRole(role); + posting.setDirection(direction); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setLocalKey(localKey); + posting.setGroupKey(localKey); + } + + private LedgerPosting posting(LedgerEntry entry, LedgerPostingDirection direction) + { + return entry.getPostings().stream().filter(posting -> posting.getDirection() == direction).findFirst() + .orElseThrow(); + } + + private LedgerPosting posting(LedgerEntry entry, LedgerPostingSemanticRole role) + { + return entry.getPostings().stream().filter(posting -> posting.getSemanticRole() == role).findFirst() + .orElseThrow(); + } + + private LedgerPosting posting(LedgerEntry entry, CorporateActionLeg leg) + { + return entry.getPostings().stream().filter(posting -> posting.getCorporateActionLeg() == leg).findFirst() + .orElseThrow(); + } + + private Account account() + { + var account = new Account(); + account.setName("Cash"); + return account; + } + + private Portfolio portfolio() + { + var portfolio = new Portfolio(); + portfolio.setName("Portfolio"); + return portfolio; + } + + private Security security() + { + return new Security("Security", CurrencyUnit.EUR); + } + + private void assertOK(LedgerStructuralValidator.ValidationResult result) + { + assertTrue(result.format(), result.isOK()); + } + + private void assertIssue(LedgerEntry entry, IssueCode code) + { + var result = LedgerStructuralValidator.validate(ledger(entry)); + + assertThat(result.format(), result.hasIssue(code), is(true)); + } +} diff --git a/name.abuchen.portfolio.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..0dd93c28b4 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/LedgerXmlPersistenceTest.java @@ -0,0 +1,980 @@ +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.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; +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)); + assertFalse(transaction.getUUID().equals(loaded.getAccounts().get(0).getTransactions().get(0).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 xml = save(client); + + assertTrue(xml.contains("")); + assertTrue(xml.contains(account.getUUID())); + assertFalse(xml.contains(" posting.getSemanticRole() == LedgerPostingSemanticRole.CASH).findFirst() + .orElseThrow(); + + 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")); + 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)); + 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. + */ + @Test + public void testLedgerXmlLoadDoesNotRemigrateCompatibilityRows() throws Exception + { + var client = new Client(); + var account = register(client, account()); + var transaction = accountTransaction(AccountTransaction.Type.DEPOSIT, 100); + + account.addTransaction(transaction); + new LegacyTransactionToLedgerMigrator().migrate(client); + + account.getTransactions().add(0, transaction); + + var loaded = load(save(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)); + } + + /** + * 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. + */ + @Test + public void testLegacyLedgerParameterAliasesLoadAndSaveCurrentForm() throws Exception + { + var client = legacyLedgerParameterCompatibilityClient(); + var entry = client.getLedger().getEntries().get(0); + var expectedParameterCount = parameterCount(entry); + var xml = save(client); + var legacyXml = legacyLedgerParameterXml(xml); + + assertTrue(legacyXml.contains("")); + assertTrue(legacyXml.contains("class=\"ledger-posting-parameter-type\"")); + + var loaded = load(legacyXml); + var loadedEntry = loaded.getLedger().getEntries().get(0); + 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); + + 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(loadedAccount, loadedCashPosting.getAccount()); + 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)); + + var currentXml = save(loaded); + + 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. + */ + @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 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 metadata roundtrips through XML and still resolves. + * A generated booking must be found from the ledger entry metadata after reload. + */ + @Test + public void testInvestmentPlanExecutionMetadataRoundtripAndResolve() 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); + + 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(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(), + instanceOf(LedgerBackedTransaction.class)); + } + + /** + * 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 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)); + + 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 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); + + 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 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 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.context() // + .portfolio(portfolio) // + .security(sourceSecurity) // + .shares(0L) // + .amount(money(0)) // + .groupKey("main") // + .localKey("context-1") // + .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 LedgerPosting semanticPosting(LedgerEntry entry, LedgerPostingSemanticRole semanticRole, + CorporateActionLeg corporateActionLeg) + { + return entry.getPostings().stream().filter(posting -> posting.getSemanticRole() == semanticRole) + .filter(posting -> posting.getCorporateActionLeg() == corporateActionLeg).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"); + } + } +} 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..d020c29f2e --- /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.CORPORATE_ACTION); + + 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.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..04ad66f9d8 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/ledger-v6-spin-off-siemens-energy-example.xml @@ -0,0 +1,618 @@ + + 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 + + + + + + + + + + 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 + + + + + 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 + + + + + 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 + + + + + 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 + + + + + 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 + + + + + FEE + 200 + EUR + 0 + + + + CORPORATE_ACTION_LEG + STRING + FEE + + + FEE_REASON + STRING + CORPORATE_ACTION_FEE + + + STAMP_DUTY + BOOLEAN + false + + + + + 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 + + + + + + + BUY + 2020-01-02T00:00 + 2026-06-15T10:41:34.896212600Z + + + + CASH + 118640 + EUR + 0 + + + + + SECURITY + 118640 + EUR + + 1000000000 + + + + + + + DEPOSIT + 2019-12-30T00:00 + 2026-06-15T10:41:50.210577100Z + + + + CASH + 1000000 + EUR + 0 + + + + + + + + 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..a5dc8854f3 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigratorTest.java @@ -0,0 +1,1487 @@ +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.assertTrue; + +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; +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.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.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 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() + { + 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)); + assertLedgerBacked(migrated, entry, LedgerProjectionRole.ACCOUNT); + 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(); + + migrate(client); + + var entry = onlyLedgerEntry(client); + var sourceTransaction = onlyAccountTransaction(source); + var targetTransaction = onlyAccountTransaction(target); + + assertThat(entry.getType(), is(LedgerEntryType.CASH_TRANSFER)); + 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())); + 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(); + + migrate(client); + + var entry = onlyLedgerEntry(client); + var sourceTransaction = onlyPortfolioTransaction(source); + var targetTransaction = onlyPortfolioTransaction(target); + + assertThat(entry.getType(), is(LedgerEntryType.SECURITY_TRANSFER)); + 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())); + 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)); + assertLedgerBacked(account.getTransactions().get(0), client.getLedger().getEntries().get(0), + LedgerProjectionRole.ACCOUNT); + } + + @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)); + } + + @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 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 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()); + assertLedgerBacked(client.getAllTransactions().get(0).getTransaction(), onlyLedgerEntry(client), + LedgerProjectionRole.PORTFOLIO); + } + + @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); + + 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(); + + assertTrue(plan.getTransactions().isEmpty()); + 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); + } + + 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 = 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.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())); + 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 result = migrate(client); + var ledgerEntry = onlyLedgerEntry(client); + var accountTransaction = onlyAccountTransaction(account); + var portfolioTransaction = onlyPortfolioTransaction(portfolio); + 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(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())); + 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 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(); + 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); + var entry = onlyLedgerEntry(client); + + assertFalse(result.hasDiagnostics()); + 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())); + 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 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("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 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(); + var posting = new LedgerPosting(); + + entry.setType(entryType); + entry.setDateTime(DATE_TIME); + posting.setType(LedgerPostingType.CASH); + posting.setAccount(account); + posting.setAmount(Values.Amount.factorize(1)); + posting.setCurrency(CurrencyUnit.EUR); + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + entry.addPosting(posting); + + return entry; + } + + private LedgerEntry existingPortfolioProjectionEntry(LedgerEntryType entryType, Portfolio portfolio, + String projectionUUID, LedgerProjectionRole role) + { + var entry = new LedgerEntry(); + var posting = new LedgerPosting(); + + 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)); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(role == LedgerProjectionRole.DELIVERY_OUTBOUND ? LedgerPostingDirection.OUTBOUND + : LedgerPostingDirection.INBOUND); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + entry.addPosting(posting); + + 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(); + + 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); + cashPosting.setSemanticRole(LedgerPostingSemanticRole.CASH); + 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)); + securityPosting.setCurrency(CurrencyUnit.EUR); + securityPosting.setSecurity(security()); + securityPosting.setShares(Values.Share.factorize(5)); + securityPosting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + 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); + + return entry; + } + + private LedgerEntry existingDeliveryEntry(LedgerEntryType entryType, Portfolio portfolio, String projectionUUID, + LedgerProjectionRole role) + { + var entry = new LedgerEntry(); + var posting = new LedgerPosting(); + + 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)); + posting.setSemanticRole(LedgerPostingSemanticRole.SECURITY); + posting.setDirection(role == LedgerProjectionRole.DELIVERY_OUTBOUND ? LedgerPostingDirection.OUTBOUND + : LedgerPostingDirection.INBOUND); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + entry.addPosting(posting); + + return entry; + } + + 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; + } + + 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 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)); + return client.getLedger().getEntries().get(0); + } + + private Money money(int amount) + { + return Money.of(CurrencyUnit.EUR, Values.Amount.factorize(amount)); + } + +} + 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..61d13eafd5 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionFluentEndToEndTest.java @@ -0,0 +1,790 @@ +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, + (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); + 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, + (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); + 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, + (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); + 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, + (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); + 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, + (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); + 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, + (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); + 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, + (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); + 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, + (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); + 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, + (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); + 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, + (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", + "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, + (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); + 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, + (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); + 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, + (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); + 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, + (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); + 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, + (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); + 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, + (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); + assertProjection(reloaded, LedgerProjectionRole.NEW_SECURITY_LEG, "claim-1"); + assertProjection(reloaded, LedgerProjectionRole.ACCOUNT, "cash-1"); + }); + } + + 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); + + LedgerProjectionService.materialize(fixture.client); + LedgerProjectionService.materialize(fixture.client); + assertProjectingMovementsAreMaterialized(entry); + assertNonProjectingFacts(entry); + + 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); + 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); + assertHandle(loadedFromXml, xmlEntry, handleLookup, expectedRole, expectedLocalKey, expectedGroupKey); + 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); + assertHandle(loadedFromProtobuf, protobufEntry, handleLookup, expectedRole, expectedLocalKey, expectedGroupKey); + 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 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); + + 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 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); + } + + 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.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.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..ca22954dce --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorServiceTest.java @@ -0,0 +1,528 @@ +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.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.LedgerProjectionRole; +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.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.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; +import name.abuchen.portfolio.money.Values; + +/** + * Tests runtime-only projection descriptors derived from posting semantics. + * Descriptor derivation is the active materialization source. + */ +@SuppressWarnings("nls") +public class DerivedProjectionDescriptorServiceTest +{ + private static final LocalDateTime DATE_TIME = LocalDateTime.of(2026, 1, 2, 0, 0); + + @Test + public void testAccountOnlyDescriptorDerivesRuntimeView() + { + var client = new Client(); + var account = account("Cash"); + var entry = creator(client).createDeposit(metadata(), LedgerAccountCashLeg.of(account, money(100))).getEntry(); + + var descriptors = descriptors(entry); + + assertThat(descriptors.size(), is(1)); + assertDescriptor(entry, descriptors.get(0), LedgerProjectionRole.ACCOUNT); + } + + @Test + public void testBuySellDescriptorsDeriveAccountAndPortfolioRuntimeViews() + { + 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(); + + var descriptors = descriptors(entry); + + assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.ACCOUNT, 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, + 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))); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.ACCOUNT), LedgerProjectionRole.ACCOUNT); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.PORTFOLIO), + LedgerProjectionRole.PORTFOLIO); + } + + @Test + public void testDeliveryDescriptorDerivesRuntimeView() + { + 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(); + + var descriptors = descriptors(entry); + + assertThat(roles(descriptors), is(Set.of(LedgerProjectionRole.DELIVERY_INBOUND))); + assertDescriptor(entry, descriptors.get(0), 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))); + assertDescriptor(entry, descriptors.get(0), LedgerProjectionRole.DELIVERY_OUTBOUND); + } + + @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(); + + moveFirstPostingToEnd(entry); + + var descriptors = descriptors(entry); + + assertSame(source, descriptor(descriptors, LedgerProjectionRole.SOURCE_ACCOUNT).getAccount()); + assertSame(target, descriptor(descriptors, LedgerProjectionRole.TARGET_ACCOUNT).getAccount()); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.SOURCE_ACCOUNT), + LedgerProjectionRole.SOURCE_ACCOUNT); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.TARGET_ACCOUNT), + 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(); + + moveFirstPostingToEnd(entry); + + var descriptors = descriptors(entry); + + assertSame(source, descriptor(descriptors, LedgerProjectionRole.SOURCE_PORTFOLIO).getPortfolio()); + assertSame(target, descriptor(descriptors, LedgerProjectionRole.TARGET_PORTFOLIO).getPortfolio()); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.SOURCE_PORTFOLIO), + LedgerProjectionRole.SOURCE_PORTFOLIO); + assertDescriptor(entry, descriptor(descriptors, LedgerProjectionRole.TARGET_PORTFOLIO), + LedgerProjectionRole.TARGET_PORTFOLIO); + } + + @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")); + + projection = factory.createProjection(entry, LedgerProjectionRole.NEW_SECURITY_LEG, "target-2"); + + assertThat(projection.getUUID(), is("spin-off-repeated:NEW_SECURITY_LEG:target-2")); + } + + @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); + posting.setAccount(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 assertDescriptor(LedgerEntry entry, DerivedProjectionDescriptor descriptor, LedgerProjectionRole role) + { + assertSame(entry, descriptor.getEntry()); + assertThat(descriptor.getRole(), is(role)); + assertSame(LedgerProjectionSupport.primaryPosting(entry, role), descriptor.getPrimaryPosting()); + } + + 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 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 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.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); + entry.addPosting(compensation); + entry.addPosting(fee); + entry.addPosting(tax); + fee.setGroupKey(compensation.getGroupKey()); + tax.setGroupKey(compensation.getGroupKey()); + + 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.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); + + 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 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 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) + { + 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 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.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..458a7952bf --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionCashProjectionTest.java @@ -0,0 +1,128 @@ +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.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.Client; +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.CorporateActionKind; +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 LedgerCorporateActionCashProjectionTest +{ + private static final LocalDateTime DATE = LocalDateTime.of(2026, 1, 2, 0, 0); + + @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()))); + assertFalse(LedgerProjectionSupport.descriptors(entry).stream() + .anyMatch(descriptor -> descriptor.getPrimaryPosting() + .getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); + } + + 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.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..f72aa7bad8 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionConversionExchangeProjectionTest.java @@ -0,0 +1,224 @@ +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.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.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.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 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 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)); + } + + 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.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..289a9580c2 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionOpenMovementProjectionTest.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.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.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 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 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()))); + } + + 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.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..129a76b894 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionRedemptionProjectionTest.java @@ -0,0 +1,254 @@ +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 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 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)); + } + + 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.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..23fe41b97e --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerCorporateActionSecurityInProjectionTest.java @@ -0,0 +1,137 @@ +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 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.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +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.CorporateActionKind; +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 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()))); + assertFalse(LedgerProjectionSupport.descriptors(entry).stream() + .anyMatch(descriptor -> descriptor.getPrimaryPosting() + .getCorporateActionLeg() == CorporateActionLeg.SECURITY_CONTEXT)); + } + + 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 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 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.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..be1b342ace --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorerTest.java @@ -0,0 +1,629 @@ +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.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.LedgerTransactionMetadata; +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.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; +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 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 testMissingOwnerListProjectionIsRestoredWithSameRuntimeProjectionId() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var projectionId = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) + .getRuntimeProjectionId(); + 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(projectionId)); + } + + /** + * 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 testSemanticPrimaryMaterializesAccountProjection() + { + var client = new Client(); + var account = register(client, account()); + var entry = creator(client).createDeposit(metadata(), cashLeg(account, 100)).getEntry(); + var alternatePosting = cashPosting("membership-primary-posting", account, 200); + + alternatePosting.setType(LedgerPostingType.FEE); + entry.addPosting(alternatePosting); + + 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(100))); + } + + /** + * 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 persisted projection membership targeting. + */ + @Test + public void testSemanticGroupKeyMaterializesNativeTargetedUnits() + { + 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); + + 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); + 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); + 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 descriptor = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); + + new LedgerProjectionMaterializer().materialize(client); + account.getTransactions().add((AccountTransaction) new LedgerProjectionFactory().createProjection(entry, + descriptor.getRole())); + + 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(descriptor.getRuntimeProjectionId())); + } + + /** + * 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 descriptor = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0); + + new LedgerProjectionMaterializer().materialize(client); + account.getTransactions().add((AccountTransaction) new LedgerProjectionFactory().createProjection(entry, + 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(descriptor.getRuntimeProjectionId()), Set.of()))); + } + + /** + * 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 testStaleLedgerBackedProjectionWithoutBackingDescriptorIsRemoved() + { + 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 projectionId = name.abuchen.portfolio.model.ledger.LedgerDescriptorTestSupport.descriptors(entry).get(0) + .getRuntimeProjectionId(); + + 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(projectionId)))); + } + + /** + * 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 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); + + 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(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)); + 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")); + } + + 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 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 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.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/snapshot/ClientPerformanceSnapshotTest.java index 583c88861d..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 @@ -28,6 +28,29 @@ 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.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.DerivedProjectionDescriptor; +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 +334,124 @@ public void testCapitalGainsWithPartialSellDuringReportPeriodWithFees() .subtract(snapshot.getValue(CategoryType.INITIAL_VALUE)))); } + @Test + 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()) // + .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)) // + .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.CORPORATE_ACTION, + 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 +846,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.SPIN_OFF) // + .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 +906,27 @@ 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 LedgerBackedAccountTransactionStub(LedgerEntry entry) + { + this.entry = entry; + } + + @Override + public LedgerEntry getLedgerEntry() + { + return entry; + } + + @Override + public DerivedProjectionDescriptor getLedgerProjectionDescriptor() + { + return null; + } + } } 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.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.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..6fc6870453 --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/AccountTransactionModelTest.java @@ -0,0 +1,229 @@ +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 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.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 transactionUUID = transaction.getUUID(); + 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)); + 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")); + assertTrue(hasLedgerPosting(transaction, "TAX", 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(transactionUUID)); + 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()); + } + } + + 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/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..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 @@ -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,155 @@ 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 accountTransactionUUID = accountTransaction.getUUID(); + var portfolioTransactionUUID = portfolioTransaction.getUUID(); + 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().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")); + + 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(accountTransactionUUID)); + assertTrue(fixture.portfolio().getTransactions().isEmpty()); + assertThat(otherPortfolio.getTransactions().size(), is(1)); + 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))); + 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/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/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..67db02fd4c --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerTransactionDuplicateCopyParityTest.java @@ -0,0 +1,536 @@ +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 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); + 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); + 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); + 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 posting 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); + 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); + 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); + 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) + { + assertThat(duplicate.getClass().getName().contains("LedgerBacked"), is(true)); + assertThat(original.getUUID(), not(duplicate.getUUID())); + assertFreshLedgerIdentity(originalSnapshot, entry(duplicate), expectedPostings); + } + + private void assertPortfolioProjectionDuplicate(PortfolioTransaction original, PortfolioTransaction duplicate, + EntrySnapshot originalSnapshot, int expectedPostings) + { + assertThat(duplicate.getClass().getName().contains("LedgerBacked"), is(true)); + assertThat(original.getUUID(), not(duplicate.getUUID())); + assertFreshLedgerIdentity(originalSnapshot, entry(duplicate), expectedPostings); + } + + private void assertFreshLedgerIdentity(EntrySnapshot originalSnapshot, Object duplicateEntry, + int expectedPostings) + { + assertThat(uuid(duplicateEntry), not(originalSnapshot.entryUUID())); + assertThat(postings(duplicateEntry).size(), is(expectedPostings)); + 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() + .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)); + assertNoDuplicateProjectionUUIDs(loaded); + assertFalse(originalEntryUUID.equals(duplicateEntryUUID)); + assertTrue(entries(loaded).stream().allMatch(e -> projections(e).isEmpty())); + } + + 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); + } + } + + private List projections(Object entry) + { + return List.of(); + } + + 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..c1c3fcc4a2 --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/dialogs/transactions/SecurityDeliveryModelTest.java @@ -0,0 +1,199 @@ +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 transactionUUID = transaction.getUUID(); + + 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().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")); + 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(transactionUUID)); + 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/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.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..83ffe74ad3 --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/LedgerTransactionRoutingTest.java @@ -0,0 +1,1825 @@ +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.LedgerInlineEditingPolicy; +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); + + 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).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); + + 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).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(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(LedgerTransactionUiSupport.supportsBuySellToDeliveryAction(List.of(buyPair, deliveryPair)), + is(false)); + assertThat(LedgerTransactionUiSupport.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(((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); + 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 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() + throws Exception + { + var buy = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var portfolioTransaction = buy.portfolio().getTransactions().get(0); + addInvestmentPlanRef(buy.client(), portfolioTransaction); + + var support = new TransactionTypeEditingSupport(buy.client()); + assertThat(support.canEdit(portfolioTransaction), is(true)); + setTypeValue(support, portfolioTransaction, PortfolioTransaction.Type.BUY, + PortfolioTransaction.Type.DELIVERY_INBOUND); + 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())); + } + + /** + * 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(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))); + 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(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))); + 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(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))); + 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(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))); + 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 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(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.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.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 = LedgerTransactionUiSupport.transactionsForView(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 = LedgerTransactionUiSupport.transactionsForView(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(LedgerTransactionUiSupport.matchesClientFilter( + new PortfolioClientFilter(List.of(), List.of(ledgerAccountTransfer.source())), + ledgerAccountTransactions.get(0)), is(true)); + assertThat(LedgerTransactionUiSupport.matchesClientFilter( + new PortfolioClientFilter(List.of(), List.of(ledgerAccountTransfer.source())), + ledgerAccountTransactions.get(1)), is(false)); + + var reloadedAccountTransfer = LedgerTransactionUiSupport + .transactionsForView(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 = LedgerTransactionUiSupport + .transactionsForView(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 = LedgerTransactionUiSupport + .transactionsForView(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(LedgerTransactionUiSupport.matchesClientFilter( + new PortfolioClientFilter(List.of(ledgerPortfolioTransfer.source()), List.of()), + ledgerPortfolioTransactions.get(0)), is(true)); + assertThat(LedgerTransactionUiSupport.matchesClientFilter( + new PortfolioClientFilter(List.of(ledgerPortfolioTransfer.source()), List.of()), + ledgerPortfolioTransactions.get(1)), is(false)); + + var reloadedPortfolioTransfer = LedgerTransactionUiSupport + .transactionsForView(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(LedgerTransactionUiSupport + .transactionsForView(legacyBuySellFixture(PortfolioTransaction.Type.BUY).client()) + .size(), is(1)); + var ledgerBuySell = ledgerBuySellFixture(PortfolioTransaction.Type.BUY); + var ledgerBuySellTransactions = LedgerTransactionUiSupport.transactionsForView(ledgerBuySell.client()); + assertThat(ledgerBuySellTransactions.size(), is(1)); + assertThat(LedgerTransactionUiSupport.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); + + 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(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), + null); + } + + /** + * 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(); + + 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)); + + 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)); + 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(); + + 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)); + + 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)); + 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 without duplicate owner-list rows. + */ + @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(); + + 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)); + + 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)); + 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 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(); + + 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)); + + 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)); + assertThat(newSourcePortfolio.getTransactions().size(), is(1)); + assertThat(newTargetPortfolio.getTransactions().size(), is(1)); + assertLedgerStructurallyValid(portfolioTransfer.client()); + } + + /** + * 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(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(LedgerTransactionUiSupport.supportsDeliveryToBuySellAction(List.of(pair)), is(true)); + assertThat(LedgerTransactionUiSupport.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); + + 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).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); + + 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).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); + + 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).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()); + if (projectionUUID != null) + 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("getLedgerProjectionDescriptor").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); + 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; + } + + 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..3a3d548614 --- /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(LedgerTransactionUiSupport.supportsBuySellToDeliveryAction( + List.of(new TransactionPair<>(fixture.portfolio(), transaction))), is(true)); + assertThat(LedgerTransactionUiSupport.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(LedgerTransactionUiSupport.supportsDeliveryToBuySellAction( + List.of(new TransactionPair<>(fixture.portfolio(), transaction))), is(true)); + assertThat(LedgerTransactionUiSupport.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(LedgerTransactionUiSupport.supportsBuySellToDeliveryAction(pairs), is(false)); + assertThat(LedgerTransactionUiSupport.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())); + + LedgerTransactionUiSupport.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())); + + LedgerTransactionUiSupport.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())); + + LedgerTransactionUiSupport.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); + + LedgerTransactionUiSupport.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"); + + LedgerTransactionUiSupport.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(); + + LedgerTransactionUiSupport.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(); + + LedgerTransactionUiSupport.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(); + + LedgerTransactionUiSupport.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..ab156c1420 --- /dev/null +++ b/name.abuchen.portfolio.ui.tests/src/name/abuchen/portfolio/ui/views/actions/AccountTransferLegacyActionGuardTest.java @@ -0,0 +1,965 @@ +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); + + 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).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); + + 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).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); + + 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).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); + + 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).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); + + 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).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); + + 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).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); + + 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).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); + + 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).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.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/Messages.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/Messages.java index 1b7aaaf65c..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 @@ -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 LedgerNativeComponentInspectorDerivedDescriptors; + 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/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..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 @@ -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; @@ -99,14 +101,25 @@ 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 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; + AccountTransaction t; if (sourceTransaction != null && sourceAccount.equals(account)) @@ -132,7 +145,7 @@ public void applyChanges() } } - 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); @@ -142,11 +155,18 @@ public void applyChanges() t.clearUnits(); + units.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 +175,31 @@ 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 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 +234,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..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 @@ -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,16 @@ public void applyChanges() if (targetAccount == null) throw new UnsupportedOperationException(Messages.MsgAccountToMissing); + 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 (new LedgerDialogCommitSupport(client).commitAccountTransfer(source, sourceAccount, targetAccount, dateTime, + sourceAmount, amount, sourceForex, sourceExchangeRate, note)) + return; + AccountTransferEntry t; if (source != null && sourceAccount.equals(source.getOwner(source.getSourceTransaction())) @@ -96,7 +107,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 +178,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..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 @@ -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,12 @@ public void applyChanges() if (account == null) throw new UnsupportedOperationException(Messages.MsgMissingReferenceAccount); + var dateTime = LocalDateTime.of(date, time); + var units = buildUnits(); + if (new LedgerDialogCommitSupport(client).commitBuySell(source, portfolio, account, type, dateTime, total, + security, shares, units, note)) + return; + BuySellEntry entry; if (source != null && source.getOwner(source.getPortfolioTransaction()).equals(portfolio) @@ -91,7 +162,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/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/LedgerNativeComponentInspectorDialog.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerNativeComponentInspectorDialog.java new file mode 100644 index 0000000000..166d4e3ea2 --- /dev/null +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/LedgerNativeComponentInspectorDialog.java @@ -0,0 +1,222 @@ +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.LedgerNativeComponentInspectorDerivedDescriptors, model.getDescriptors(), + new String[] { Messages.LedgerNativeComponentInspectorProjectionRole, + Messages.LedgerNativeComponentInspectorOwner, + Messages.LedgerNativeComponentInspectorProjectionUUID, + Messages.LedgerNativeComponentInspectorPrimaryPostingUUID, + Messages.LedgerNativeComponentInspectorPostingGroupUUID }, + row -> new String[] { row.projectionRole(), row.owner(), row.runtimeProjectionId(), + row.primaryPostingId(), row.unitPostings() }); + + 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_RUNTIME_PROJECTION_ID -> Messages.LedgerNativeComponentInspectorSelectedProjectionUUID; + case SELECTED_PRIMARY_POSTING_UUID -> Messages.LedgerNativeComponentInspectorSelectedPrimaryPostingUUID; + }; + } +} 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..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 @@ -68,6 +68,12 @@ public void applyChanges() if (portfolio.getReferenceAccount() == null) throw new UnsupportedOperationException(Messages.MsgMissingReferenceAccount); + 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; + TransactionPair entry; if (source != null && source.getOwner().equals(portfolio)) @@ -92,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); @@ -100,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 52773e9a2d..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 @@ -68,6 +68,12 @@ public void applyChanges() if (targetPortfolio == null) throw new UnsupportedOperationException(Messages.MsgPortfolioToMissing); + var dateTime = LocalDateTime.of(date, time); + + if (new LedgerDialogCommitSupport(client).commitSecurityTransfer(source, sourcePortfolio, targetPortfolio, + security, dateTime, shares, amount, note)) + return; + PortfolioTransferEntry t; if (source != null && sourcePortfolio.equals(source.getOwner(source.getSourceTransaction())) @@ -97,7 +103,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 +193,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/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) { } 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/messages.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties index 5087306ef2..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Derived descriptors + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Refer\u00E8ncies de projecci\u00F3 + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Odkazy projekc\u00ED + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Projektionsreferencer + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Projektionsreferenzen + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Referencias de proyecci\u00F3n + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Projektioviitteet + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = R\u00E9f\u00E9rences de projection + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Riferimenti di proiezione + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Projectieverwijzingen + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Referencje projekcji + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Refer\u00EAncias de proje\u00E7\u00E3o + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Refer\u00EAncias de proje\u00E7\u00E3o + +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 + +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..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 @@ -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 + +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 + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Odkazy projekci\u00ED + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Projeksiyon referanslar\u0131 + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = Tham chi\u1EBFu ph\u00E9p chi\u1EBFu + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = \u6295\u5F71\u5F15\u7528 + +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 + +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..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 @@ -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 + +LedgerNativeComponentInspectorDerivedDescriptors = \u6295\u5F71\u53C3\u7167 + +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 + +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.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..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,6 +18,7 @@ import org.eclipse.swt.widgets.Composite; import name.abuchen.portfolio.model.Client; +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; @@ -71,7 +72,12 @@ public CellEditor createEditor(Composite composite) public boolean canEdit(Object element) { - return true; + return !isLedgerNativeTargetedProjection(element); + } + + protected final boolean isLedgerNativeTargetedProjection(Object element) + { + return LedgerInlineEditingPolicy.isNativeTargetedProjection(element); } /** 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..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 @@ -12,8 +12,13 @@ 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.Adaptor; +import name.abuchen.portfolio.model.Client; +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.money.Values; import name.abuchen.portfolio.ui.Messages; @@ -27,11 +32,32 @@ 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 (LedgerInlineEditingPolicy.isNativeTargetedProjection(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 LedgerInlineEditingPolicy.canUpdateDividendExDate(client, tx) + || !LedgerInlineEditingPolicy.isLedgerBackedAccountTransaction(client, tx); } @Override @@ -57,7 +83,7 @@ public void setValue(Object element, Object value) { if (oldValue != null) { - tx.setExDate(null); + updateExDate(tx, null); notify(element, null, oldValue); } return; @@ -84,11 +110,40 @@ 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 (LedgerInlineEditingPolicy.canUpdateDividendExDate(client, transaction)) + { + LedgerInlineEditingPolicy.updateDividendExDate(client, ownerOf(transaction), transaction, exDate); + return; + } + + if (LedgerInlineEditingPolicy.isLedgerBackedAccountTransaction(client, transaction)) + throw new UnsupportedOperationException( + LedgerDiagnosticCode.LEDGER_UI_010 + .message(Messages.LedgerExDateEditingSupportNoSafeEditorLedgerBacked)); + + transaction.setExDate(exDate); + } + + private Account ownerOf(AccountTransaction transaction) + { + return client.getAccounts().stream().filter(account -> account.getTransactions().contains(transaction)) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + Messages.LedgerExDateEditingSupportDividendOwnerNotFound)); + } + @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..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 @@ -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; @@ -12,10 +13,13 @@ import name.abuchen.portfolio.model.Account; 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.Transaction; import name.abuchen.portfolio.model.TransactionOwner; import name.abuchen.portfolio.model.TransactionPair; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; +import name.abuchen.portfolio.ui.Messages; /** * Creates a cell editor with a combo box of accounts or portfolios. @@ -76,16 +80,14 @@ else if (element instanceof TransactionPair pair) return null; } - private CrossEntry getCrossEntry(Object element) - { - Transaction t = getTransaction(element); - return t != null ? t.getCrossEntry() : null; - } - @Override public boolean canEdit(Object element) { - return getCrossEntry(element) != null; + Transaction transaction = getTransaction(element); + if (LedgerInlineEditingPolicy.isNativeTargetedProjection(transaction)) + return false; + + return LedgerInlineEditingPolicy.canEditOwner(client, transaction); } @Override @@ -193,6 +195,16 @@ public final void setValue(Object element, Object value) throws Exception if (newValue.equals(oldValue)) return; + validateOwnerChange(crossEntry, transaction, newValue); + + if (LedgerInlineEditingPolicy.isLedgerBacked(client, transaction)) + { + if (!LedgerInlineEditingPolicy.updateOwner(client, transaction, oldValue, newValue)) + throw unsupportedLedgerOwnerEdit(crossEntry); + 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 +217,34 @@ 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 UnsupportedOperationException unsupportedLedgerOwnerEdit(CrossEntry crossEntry) + { + return new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_UI_013 + .message(MessageFormat.format( + Messages.LedgerTransactionOwnerListEditingSupportUnsupportedOwnerEdit, + crossEntry))); + } } 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..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 @@ -13,15 +13,21 @@ 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.Transaction; 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.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 +44,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 +53,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 +62,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 +71,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 +95,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 +176,16 @@ else if (t instanceof PortfolioTransaction pt) public boolean canEdit(Object element) { Transaction t = getTransaction(element); + if (LedgerInlineEditingPolicy.isNativeTargetedProjection(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 +210,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 +242,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 +271,11 @@ 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) + { + return LedgerInlineEditingPolicy.supportsTypeTransition(client, getTransactionPair(transaction), fromValue, + toValue); + } + } 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..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 @@ -61,7 +61,7 @@ public AllTransactionsView(IPreferenceStore preferenceStore) @Override public void notifyModelUpdated() { - var allTransactions = getClient().getAllTransactions(); + var allTransactions = LedgerTransactionUiSupport.transactionsForView(getClient()); table.setInput(allTransactions); updateTitle(Messages.LabelAllTransactions + " (" //$NON-NLS-1$ @@ -153,10 +153,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 LedgerTransactionUiSupport.matchesClientFilter(clientFilter, tx); } }); 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/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 78f6290e55..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 @@ -33,6 +33,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 +71,14 @@ 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 (!LedgerTransactionUiSupport.containsNativeTargetedProjection(selection)) + { + manager.add(new SimpleAction(Messages.MenuTransactionDelete, a -> { + LedgerTransactionUiSupport.deleteTransactions(owner.getClient(), selection.toArray()); - owner.markDirty(); - })); + owner.markDirty(); + })); + } } } @@ -87,6 +90,9 @@ public void handleEditKey(KeyEvent e, IStructuredSelection selection) return; TransactionPair tx = (TransactionPair) selection.getFirstElement(); + if (LedgerTransactionUiSupport.isNativeTargetedProjection(tx)) + return; + tx.withAccountTransaction().ifPresent(t -> createEditAccountTransactionAction(t).run()); tx.withPortfolioTransaction().ifPresent(t -> createEditPortfolioTransactionAction(t).run()); } @@ -96,6 +102,9 @@ public void handleEditKey(KeyEvent e, IStructuredSelection selection) return; TransactionPair tx = (TransactionPair) selection.getFirstElement(); + if (LedgerTransactionUiSupport.isNativeTargetedProjection(tx)) + return; + tx.withAccountTransaction().ifPresent(t -> createCopyAccountTransactionAction(t).run()); tx.withPortfolioTransaction().ifPresent(t -> createCopyPortfolioTransactionAction(t).run()); } @@ -112,6 +121,9 @@ private void fillContextMenuAccountTxList(IMenuManager manager, IStructuredSelec if (accountTxPairs.size() != selection.size()) return; + if (accountTxPairs.stream().anyMatch(LedgerTransactionUiSupport::isNativeTargetedProjection)) + return; + var transfers = accountTxPairs.stream() .filter(p -> p.getTransaction().getType() == AccountTransaction.Type.TRANSFER_IN || p.getTransaction().getType() == AccountTransaction.Type.TRANSFER_OUT) @@ -144,26 +156,15 @@ 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(LedgerTransactionUiSupport::isNativeTargetedProjection)) + return; - if (allBuyOrSellType) + if (LedgerTransactionUiSupport.supportsBuySellToDeliveryAction(txCollection)) { manager.add(new ConvertBuySellToDeliveryAction(owner.getClient(), txCollection)); } - if (allDelivery) + if (LedgerTransactionUiSupport.supportsDeliveryToBuySellAction(txCollection)) { manager.add(new ConvertDeliveryToBuySellAction(owner.getClient(), txCollection)); } @@ -172,6 +173,11 @@ private void fillContextMenuPortfolioTxList(IMenuManager manager, IStructuredSel private void fillContextMenuAccountTx(IMenuManager manager, boolean fullContextMenu, TransactionPair tx) { + LedgerNativeComponentInspectorAction.create(owner, tx.getTransaction()).ifPresent(manager::add); + + if (LedgerTransactionUiSupport.isNativeTargetedProjection(tx)) + return; + Action action = createEditAccountTransactionAction(tx); action.setAccelerator(SWT.MOD1 | 'E'); manager.add(action); @@ -193,6 +199,11 @@ private void fillContextMenuPortfolioTx(IMenuManager manager, boolean fullContex { PortfolioTransaction ptx = tx.getTransaction(); + LedgerNativeComponentInspectorAction.create(owner, tx.getTransaction()).ifPresent(manager::add); + + if (LedgerTransactionUiSupport.isNativeTargetedProjection(tx)) + return; + Action editAction = createEditPortfolioTransactionAction(tx); editAction.setAccelerator(SWT.MOD1 | 'E'); manager.add(editAction); @@ -299,4 +310,5 @@ else if (tx.getTransaction().getCrossEntry() instanceof PortfolioTransferEntry e .parameters(tx.getTransaction().getType()); } } + } 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..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 @@ -30,10 +30,13 @@ import org.eclipse.swt.widgets.Menu; 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; 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.Money; import name.abuchen.portfolio.money.Values; import name.abuchen.portfolio.ui.Images; @@ -44,6 +47,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 +70,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 (LedgerInlineEditingPolicy.updateShares(owner.getClient(), pair, newValue.longValue())) + { + notify(element, newValue, oldValue); + return; + } + + super.setValue(element, value); + } + } + private class TransactionLabelProvider extends ColumnLabelProvider { private Function label; @@ -344,12 +382,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 +508,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 +516,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 +525,18 @@ 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; + } + 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..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 @@ -55,6 +55,9 @@ public void run() { for (TransactionPair transaction : transactionList) { + if (LedgerConversionSupport.convertBuySellToDelivery(client, 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..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 @@ -58,6 +58,9 @@ public void run() { for (TransactionPair transaction : transactionList) { + if (LedgerConversionSupport.convertDeliveryToBuySell(client, 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..8bcc1f286f --- /dev/null +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/ConvertPortfolioCompositeTypeAction.java @@ -0,0 +1,46 @@ +package name.abuchen.portfolio.ui.views.actions; + +import org.eclipse.jface.action.Action; + +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.TransactionPair; + +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() + { + if (LedgerConversionSupport.convertPortfolioCompositeType(client, transaction)) + return; + + 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..0547015402 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 @@ -27,10 +27,16 @@ public ConvertTransferToDepositRemovalAction(Client client, Collection 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/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..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 @@ -24,6 +24,7 @@ public RevertBuySellAction(Client client, TransactionPair transaction) this.transaction = transaction; Transaction tx = transaction.getTransaction(); + if (tx instanceof PortfolioTransaction) { PortfolioTransaction.Type type = ((PortfolioTransaction) tx).getType(); @@ -49,6 +50,9 @@ public void run() { BuySellEntry buysell = (BuySellEntry) transaction.getTransaction().getCrossEntry(); + if (LedgerConversionSupport.reverseBuySell(client, buysell)) + return; + PortfolioTransaction tx = buysell.getPortfolioTransaction(); // when converting between buy and sell transactions, we keep the price 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..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 @@ -34,6 +34,9 @@ public void run() { PortfolioTransaction tx = transaction.getTransaction(); + 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 // depending on the new type of transaction 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..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 @@ -1,5 +1,6 @@ package name.abuchen.portfolio.ui.views.actions; + import org.eclipse.jface.action.Action; import name.abuchen.portfolio.model.AccountTransaction; @@ -26,6 +27,9 @@ public void run() { AccountTransaction accountTransaction = transaction.getTransaction(); + if (LedgerConversionSupport.toggleAccountType(client, transaction)) + return; + if (AccountTransaction.Type.DEPOSIT.equals(accountTransaction.getType())) accountTransaction.setType(AccountTransaction.Type.REMOVAL); else if (AccountTransaction.Type.REMOVAL.equals(accountTransaction.getType())) diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertFeeTaxAction.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertFeeTaxAction.java new file mode 100644 index 0000000000..f5980f4edc --- /dev/null +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/actions/RevertFeeTaxAction.java @@ -0,0 +1,50 @@ +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.TransactionPair; + +public class RevertFeeTaxAction extends Action +{ + private final Client client; + private final 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(); + + if (LedgerConversionSupport.toggleAccountType(client, transaction)) + 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..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 @@ -1,5 +1,6 @@ package name.abuchen.portfolio.ui.views.actions; + import org.eclipse.jface.action.Action; import name.abuchen.portfolio.model.AccountTransaction; @@ -29,6 +30,9 @@ public void run() { AccountTransaction tx = transaction.getTransaction(); + if (LedgerConversionSupport.toggleAccountType(client, transaction)) + return; + Type type = tx.getType(); if (AccountTransaction.Type.INTEREST.equals(type)) tx.setType(AccountTransaction.Type.INTEREST_CHARGE); 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..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 @@ -49,10 +49,14 @@ public void run() { Transaction tx = transaction.getTransaction(); CrossEntry e = tx.getCrossEntry(); + if (e instanceof PortfolioTransferEntry) { PortfolioTransferEntry entry = (PortfolioTransferEntry) transaction.getTransaction().getCrossEntry(); + if (LedgerConversionSupport.reverseTransfer(client, entry)) + return; + Portfolio oldSource = entry.getSourcePortfolio(); entry.setSourcePortfolio((Portfolio) entry.getTargetPortfolio()); entry.setTargetPortfolio(oldSource); @@ -67,6 +71,9 @@ else if (e instanceof AccountTransferEntry) { AccountTransferEntry entry = (AccountTransferEntry) transaction.getTransaction().getCrossEntry(); + if (LedgerConversionSupport.reverseTransfer(client, entry)) + 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..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 @@ -41,10 +41,13 @@ 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.LedgerInlineEditingField; +import name.abuchen.portfolio.model.ledger.compatibility.LedgerInlineEditingPolicy; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.MutableMoney; import name.abuchen.portfolio.money.Quote; @@ -60,6 +63,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,13 +79,16 @@ 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; 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; 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 +97,47 @@ 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 !LedgerInlineEditingPolicy.isNativeTargetedProjection(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; + + if (LedgerInlineEditingPolicy.updateDividendShares(client, account, transaction, newValue.longValue())) + { + notify(element, newValue, oldValue); + return; + } + + super.setValue(element, value); + } + } + @Inject private Client client; @@ -375,15 +423,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 +495,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 +515,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 +587,8 @@ public void keyPressed(KeyEvent e) AccountTransaction transaction = (AccountTransaction) ((IStructuredSelection) transactions .getSelection()).getFirstElement(); - if (account != null && transaction != null) + if (account != null && transaction != null + && !LedgerTransactionUiSupport.isNativeTargetedProjection(transaction)) createEditAction(account, transaction).run(); } if (e.keyCode == 'd' && e.stateMask == SWT.MOD1) @@ -554,7 +596,8 @@ public void keyPressed(KeyEvent e) AccountTransaction transaction = (AccountTransaction) ((IStructuredSelection) transactions .getSelection()).getFirstElement(); - if (account != null && transaction != null) + if (account != null && transaction != null + && !LedgerTransactionUiSupport.isNativeTargetedProjection(transaction)) createCopyAction(account, transaction).run(); } } @@ -571,6 +614,11 @@ private void fillTransactionsContextMenu(IMenuManager manager) // NOSONAR if (transaction != null) { + LedgerNativeComponentInspectorAction.create(view, transaction).ifPresent(manager::add); + + if (LedgerTransactionUiSupport.isNativeTargetedProjection(transaction)) + return; + Action action = createEditAction(account, transaction); action.setAccelerator(SWT.MOD1 | 'E'); manager.add(action); @@ -590,7 +638,7 @@ private void fillTransactionsContextMenu(IMenuManager manager) // NOSONAR fillCreateRemovalForDividendAction(manager, selection); } - if (transaction != null) + if (transaction != null && !LedgerTransactionUiSupport.containsNativeTargetedAccountTransaction(selection)) { manager.add(new Separator()); @@ -617,6 +665,9 @@ public void run() private void fillConvertTransferToDepositRemovalAction(IMenuManager manager, IStructuredSelection selection) { + if (LedgerTransactionUiSupport.containsNativeTargetedAccountTransaction(selection)) + return; + // create collection with all selected transactions Collection accountTxCollection = new ArrayList<>(selection.size()); Iterator it = selection.iterator(); @@ -644,6 +695,9 @@ private void fillConvertTransferToDepositRemovalAction(IMenuManager manager, ISt private void fillCreateRemovalForDividendAction(IMenuManager manager, IStructuredSelection selection) { + if (LedgerTransactionUiSupport.containsNativeTargetedAccountTransaction(selection)) + return; + // create collection with all selected transactions var dividendTransactionPairs = selection.stream() .filter(t -> ((AccountTransaction) t).getType() == AccountTransaction.Type.DIVIDENDS) 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/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/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/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/ClientProtos.java index 09bcf3ceeb..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 @@ -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\"\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" + @@ -283,77 +318,189 @@ 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\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\"\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\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, @@ -451,9 +598,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", "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 + 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", "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 + 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", "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 + 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 +660,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 +690,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 +726,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..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
@@ -25,6 +25,8 @@ private PInvestmentPlan() {
     transactions_ =
         com.google.protobuf.LazyStringArrayList.emptyList();
     type_ = 0;
+    ledgerExecutionRefs_ = java.util.Collections.emptyList();
+    planKey_ = "";
   }
 
   @java.lang.Override
@@ -579,6 +581,94 @@ 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);
+  }
+
+  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() {
@@ -635,6 +725,12 @@ 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));
+    }
+    if (((bitField0_ & 0x00000010) != 0)) {
+      com.google.protobuf.GeneratedMessageV3.writeString(output, 16, planKey_);
+    }
     getUnknownFields().writeTo(output);
   }
 
@@ -699,6 +795,13 @@ 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));
+    }
+    if (((bitField0_ & 0x00000010) != 0)) {
+      size += com.google.protobuf.GeneratedMessageV3.computeStringSize(16, planKey_);
+    }
     size += getUnknownFields().getSerializedSize();
     memoizedSize = size;
     return size;
@@ -753,6 +856,13 @@ 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 (hasPlanKey() != other.hasPlanKey()) return false;
+    if (hasPlanKey()) {
+      if (!getPlanKey()
+          .equals(other.getPlanKey())) return false;
+    }
     if (!getUnknownFields().equals(other.getUnknownFields())) return false;
     return true;
   }
@@ -809,6 +919,14 @@ 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();
+    }
+    if (hasPlanKey()) {
+      hash = (37 * hash) + PLANKEY_FIELD_NUMBER;
+      hash = (53 * hash) + getPlanKey().hashCode();
+    }
     hash = (29 * hash) + getUnknownFields().hashCode();
     memoizedHashCode = hash;
     return hash;
@@ -959,6 +1077,14 @@ 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);
+      planKey_ = "";
       return this;
     }
 
@@ -1001,6 +1127,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) {
@@ -1050,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_;
     }
 
@@ -1147,6 +1286,37 @@ 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_);
+          }
+        }
+      }
+      if (other.hasPlanKey()) {
+        planKey_ = other.planKey_;
+        bitField0_ |= 0x00008000;
+        onChanged();
+      }
       this.mergeUnknownFields(other.getUnknownFields());
       onChanged();
       return this;
@@ -1252,6 +1422,24 @@ 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
+            case 130: {
+              planKey_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00008000;
+              break;
+            } // case 130
             default: {
               if (!super.parseUnknownField(input, extensionRegistry, tag)) {
                 done = true; // was an endgroup tag
@@ -2288,6 +2476,325 @@ 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_;
+    }
+
+    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/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..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
@@ -198,4 +198,45 @@ 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);
+
+  /**
+   * 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/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..d904588666
--- /dev/null
+++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntry.java
@@ -0,0 +1,2902 @@
+// 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();
+    typeCode_ = "";
+    parameters_ = java.util.Collections.emptyList();
+    generatedByPlanKey_ = "";
+    preferredViewKind_ = "";
+  }
+
+  @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 [deprecated = true];
+   * @deprecated name.abuchen.portfolio.PLedgerEntry.uuid is deprecated.
+   *     See client.proto;l=278
+   * @return The uuid.
+   */
+  @java.lang.Override
+  @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) ref;
+      java.lang.String s = bs.toStringUtf8();
+      uuid_ = s;
+      return s;
+    }
+  }
+  /**
+   * 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
+  @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.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 [deprecated = true];
+   */
+  @java.lang.Override
+  @java.lang.Deprecated public java.util.List getProjectionRefsList() {
+    return projectionRefs_;
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true];
+   */
+  @java.lang.Override
+  @java.lang.Deprecated public java.util.List
+      getProjectionRefsOrBuilderList() {
+    return projectionRefs_;
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true];
+   */
+  @java.lang.Override
+  @java.lang.Deprecated public int getProjectionRefsCount() {
+    return projectionRefs_.size();
+  }
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true];
+   */
+  @java.lang.Override
+  @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 [deprecated = true];
+   */
+  @java.lang.Override
+  @java.lang.Deprecated public name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectionRefsOrBuilder(
+      int index) {
+    return projectionRefs_.get(index);
+  }
+
+  public static final int TYPECODE_FIELD_NUMBER = 9;
+  @SuppressWarnings("serial")
+  private volatile java.lang.Object typeCode_ = "";
+  /**
+   * optional string typeCode = 9;
+   * @return Whether the typeCode field is set.
+   */
+  @java.lang.Override
+  public boolean hasTypeCode() {
+    return ((bitField0_ & 0x00000004) != 0);
+  }
+  /**
+   * optional string typeCode = 9;
+   * @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 = 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;
+  @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);
+  }
+
+  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() {
+    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)) {
+      com.google.protobuf.GeneratedMessageV3.writeString(output, 9, typeCode_);
+    }
+    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);
+  }
+
+  @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.GeneratedMessageV3.computeStringSize(9, typeCode_);
+    }
+    for (int i = 0; i < parameters_.size(); i++) {
+      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;
+  }
+
+  @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 (hasTypeCode() != other.hasTypeCode()) return false;
+    if (hasTypeCode()) {
+      if (!getTypeCode()
+          .equals(other.getTypeCode())) return false;
+    }
+    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;
+  }
+
+  @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 (hasTypeCode()) {
+      hash = (37 * hash) + TYPECODE_FIELD_NUMBER;
+      hash = (53 * hash) + getTypeCode().hashCode();
+    }
+    if (getParametersCount() > 0) {
+      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;
+  }
+
+  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);
+      typeCode_ = "";
+      if (parametersBuilder_ == null) {
+        parameters_ = java.util.Collections.emptyList();
+      } else {
+        parameters_ = null;
+        parametersBuilder_.clear();
+      }
+      bitField0_ = (bitField0_ & ~0x00000100);
+      generatedByPlanKey_ = "";
+      planExecutionDate_ = 0L;
+      planExecutionSequence_ = 0;
+      preferredViewKind_ = "";
+      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.typeCode_ = typeCode_;
+        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_;
+    }
+
+    @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.hasTypeCode()) {
+        typeCode_ = other.typeCode_;
+        bitField0_ |= 0x00000080;
+        onChanged();
+      }
+      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_);
+          }
+        }
+      }
+      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;
+    }
+
+    @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 74: {
+              typeCode_ = input.readStringRequireUtf8();
+              bitField0_ |= 0x00000080;
+              break;
+            } // case 74
+            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
+            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
+              }
+              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 [deprecated = true];
+     * @deprecated name.abuchen.portfolio.PLedgerEntry.uuid is deprecated.
+     *     See client.proto;l=278
+     * @return The uuid.
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     * @deprecated name.abuchen.portfolio.PLedgerEntry.uuid is deprecated.
+     *     See client.proto;l=278
+     * @return The bytes for uuid.
+     */
+    @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.copyFromUtf8(
+                (java.lang.String) ref);
+        uuid_ = b;
+        return b;
+      } else {
+        return (com.google.protobuf.ByteString) ref;
+      }
+    }
+    /**
+     * 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.
+     */
+    @java.lang.Deprecated public Builder setUuid(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      uuid_ = value;
+      bitField0_ |= 0x00000001;
+      onChanged();
+      return this;
+    }
+    /**
+     * string uuid = 1 [deprecated = true];
+     * @deprecated name.abuchen.portfolio.PLedgerEntry.uuid is deprecated.
+     *     See client.proto;l=278
+     * @return This builder for chaining.
+     */
+    @java.lang.Deprecated public Builder clearUuid() {
+      uuid_ = getDefaultInstance().getUuid();
+      bitField0_ = (bitField0_ & ~0x00000001);
+      onChanged();
+      return this;
+    }
+    /**
+     * 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.
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     */
+    @java.lang.Deprecated public int getProjectionRefsCount() {
+      if (projectionRefsBuilder_ == null) {
+        return projectionRefs_.size();
+      } else {
+        return projectionRefsBuilder_.getCount();
+      }
+    }
+    /**
+     * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true];
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     */
+    @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 [deprecated = true];
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     */
+    @java.lang.Deprecated 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 [deprecated = true];
+     */
+    @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 [deprecated = true];
+     */
+    @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 [deprecated = true];
+     */
+    @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>
+        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 java.lang.Object typeCode_ = "";
+    /**
+     * optional string typeCode = 9;
+     * @return Whether the typeCode field is set.
+     */
+    public boolean hasTypeCode() {
+      return ((bitField0_ & 0x00000080) != 0);
+    }
+    /**
+     * optional string typeCode = 9;
+     * @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 = 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 setTypeCode(
+        java.lang.String value) {
+      if (value == null) { throw new NullPointerException(); }
+      typeCode_ = value;
+      bitField0_ |= 0x00000080;
+      onChanged();
+      return this;
+    }
+    /**
+     * optional string typeCode = 9;
+     * @return This builder for chaining.
+     */
+    public Builder clearTypeCode() {
+      typeCode_ = getDefaultInstance().getTypeCode();
+      bitField0_ = (bitField0_ & ~0x00000080);
+      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;
+    }
+
+    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_;
+    }
+
+    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) {
+      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..db363a5117
--- /dev/null
+++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerEntryOrBuilder.java
@@ -0,0 +1,234 @@
+// 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 [deprecated = true];
+   * @deprecated name.abuchen.portfolio.PLedgerEntry.uuid is deprecated.
+   *     See client.proto;l=278
+   * @return The uuid.
+   */
+  @java.lang.Deprecated java.lang.String getUuid();
+  /**
+   * 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.Deprecated 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 [deprecated = true];
+   */
+  @java.lang.Deprecated java.util.List
+      getProjectionRefsList();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true];
+   */
+  @java.lang.Deprecated name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRef getProjectionRefs(int index);
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true];
+   */
+  @java.lang.Deprecated int getProjectionRefsCount();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true];
+   */
+  @java.lang.Deprecated java.util.List
+      getProjectionRefsOrBuilderList();
+  /**
+   * repeated .name.abuchen.portfolio.PLedgerProjectionRef projectionRefs = 8 [deprecated = true];
+   */
+  @java.lang.Deprecated name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRefOrBuilder getProjectionRefsOrBuilder(
+      int index);
+
+  /**
+   * optional string typeCode = 9;
+   * @return Whether the typeCode field is set.
+   */
+  boolean hasTypeCode();
+  /**
+   * optional string typeCode = 9;
+   * @return The typeCode.
+   */
+  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;
+   */
+  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);
+
+  /**
+   * 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/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..d3a4c3ec3d
--- /dev/null
+++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameter.java
@@ -0,0 +1,2161 @@
+// 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..1b0b3a9c96 --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerParameterValueKind.java @@ -0,0 +1,194 @@ +// 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..92e02fde01 --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPosting.java @@ -0,0 +1,3174 @@ +// 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_ = ""; + semanticRole_ = ""; + direction_ = ""; + corporateActionLeg_ = ""; + unitRole_ = ""; + groupKey_ = ""; + localKey_ = ""; + } + + @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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerPosting.uuid is deprecated. + * See client.proto;l=295 + * @return The uuid. + */ + @java.lang.Override + @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) ref; + java.lang.String s = bs.toStringUtf8(); + uuid_ = s; + return s; + } + } + /** + * 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 + @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.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; + } + } + + 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() { + 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_); + } + 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); + } + + @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_); + } + 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; + } + + @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 (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; + } + + @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(); + } + 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; + } + + 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_ = ""; + semanticRole_ = ""; + direction_ = ""; + corporateActionLeg_ = ""; + unitRole_ = ""; + groupKey_ = ""; + localKey_ = ""; + 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; + } + 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_; + } + + @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(); + } + 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; + } + + @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 + 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 + } + 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerPosting.uuid is deprecated. + * See client.proto;l=295 + * @return The uuid. + */ + @java.lang.Deprecated 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerPosting.uuid is deprecated. + * See client.proto;l=295 + * @return The bytes for uuid. + */ + @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.copyFromUtf8( + (java.lang.String) ref); + uuid_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * 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. + */ + @java.lang.Deprecated public Builder setUuid( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + uuid_ = value; + bitField0_ |= 0x00000001; + onChanged(); + return this; + } + /** + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerPosting.uuid is deprecated. + * See client.proto;l=295 + * @return This builder for chaining. + */ + @java.lang.Deprecated public Builder clearUuid() { + uuid_ = getDefaultInstance().getUuid(); + bitField0_ = (bitField0_ & ~0x00000001); + onChanged(); + return this; + } + /** + * 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. + */ + @java.lang.Deprecated 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; + } + + 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) { + 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..e951f65b67 --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerPostingOrBuilder.java @@ -0,0 +1,291 @@ +// 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerPosting.uuid is deprecated. + * See client.proto;l=295 + * @return The uuid. + */ + @java.lang.Deprecated java.lang.String getUuid(); + /** + * 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.Deprecated 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(); + + /** + * 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 new file mode 100644 index 0000000000..6f781bf985 --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembership.java @@ -0,0 +1,629 @@ +// 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.postingUUID is deprecated. + * See client.proto;l=325 + * @return The postingUUID. + */ + @java.lang.Override + @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) ref; + java.lang.String s = bs.toStringUtf8(); + postingUUID_ = s; + return s; + } + } + /** + * 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 + @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.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 [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 @java.lang.Deprecated public int getRoleValue() { + return role_; + } + /** + * .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 @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; + } + + 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.postingUUID is deprecated. + * See client.proto;l=325 + * @return The postingUUID. + */ + @java.lang.Deprecated 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.postingUUID is deprecated. + * See client.proto;l=325 + * @return The bytes for postingUUID. + */ + @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.copyFromUtf8( + (java.lang.String) ref); + postingUUID_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * 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. + */ + @java.lang.Deprecated public Builder setPostingUUID( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + postingUUID_ = value; + bitField0_ |= 0x00000001; + onChanged(); + return this; + } + /** + * string postingUUID = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.postingUUID is deprecated. + * See client.proto;l=325 + * @return This builder for chaining. + */ + @java.lang.Deprecated public Builder clearPostingUUID() { + postingUUID_ = getDefaultInstance().getPostingUUID(); + bitField0_ = (bitField0_ & ~0x00000001); + onChanged(); + return this; + } + /** + * 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. + */ + @java.lang.Deprecated 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 [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 @java.lang.Deprecated public int getRoleValue() { + return role_; + } + /** + * .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. + */ + @java.lang.Deprecated public Builder setRoleValue(int value) { + role_ = value; + bitField0_ |= 0x00000002; + onChanged(); + return this; + } + /** + * .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 + @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 [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. + */ + @java.lang.Deprecated 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.role is deprecated. + * See client.proto;l=326 + * @return This builder for chaining. + */ + @java.lang.Deprecated 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..9332cfc215 --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipOrBuilder.java @@ -0,0 +1,40 @@ +// 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionMembership.postingUUID is deprecated. + * See client.proto;l=325 + * @return The postingUUID. + */ + @java.lang.Deprecated java.lang.String getPostingUUID(); + /** + * 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.Deprecated com.google.protobuf.ByteString + getPostingUUIDBytes(); + + /** + * .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.Deprecated int getRoleValue(); + /** + * .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.Deprecated 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..289a131d6d --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionMembershipRole.java @@ -0,0 +1,158 @@ +// 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..5922859927 --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRef.java @@ -0,0 +1,1336 @@ +// 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.uuid is deprecated. + * See client.proto;l=317 + * @return The uuid. + */ + @java.lang.Override + @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) ref; + java.lang.String s = bs.toStringUtf8(); + uuid_ = s; + return s; + } + } + /** + * 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 + @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.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 [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 @java.lang.Deprecated public int getRoleValue() { + return role_; + } + /** + * .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 @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; + } + + public static final int ACCOUNT_FIELD_NUMBER = 3; + @SuppressWarnings("serial") + private volatile java.lang.Object account_ = ""; + /** + * 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 + @java.lang.Deprecated public boolean hasAccount() { + return ((bitField0_ & 0x00000001) != 0); + } + /** + * 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 + @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) ref; + java.lang.String s = bs.toStringUtf8(); + account_ = s; + return s; + } + } + /** + * 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 + @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.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 [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 + @java.lang.Deprecated public boolean hasPortfolio() { + return ((bitField0_ & 0x00000002) != 0); + } + /** + * 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 + @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) ref; + java.lang.String s = bs.toStringUtf8(); + portfolio_ = s; + return s; + } + } + /** + * 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 + @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.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 [deprecated = true]; + */ + @java.lang.Override + @java.lang.Deprecated public java.util.List getMembershipsList() { + return memberships_; + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; + */ + @java.lang.Override + @java.lang.Deprecated public java.util.List + getMembershipsOrBuilderList() { + return memberships_; + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; + */ + @java.lang.Override + @java.lang.Deprecated public int getMembershipsCount() { + return memberships_.size(); + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; + */ + @java.lang.Override + @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 [deprecated = true]; + */ + @java.lang.Override + @java.lang.Deprecated 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.uuid is deprecated. + * See client.proto;l=317 + * @return The uuid. + */ + @java.lang.Deprecated 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.uuid is deprecated. + * See client.proto;l=317 + * @return The bytes for uuid. + */ + @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.copyFromUtf8( + (java.lang.String) ref); + uuid_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * 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. + */ + @java.lang.Deprecated public Builder setUuid( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + uuid_ = value; + bitField0_ |= 0x00000001; + onChanged(); + return this; + } + /** + * string uuid = 1 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.uuid is deprecated. + * See client.proto;l=317 + * @return This builder for chaining. + */ + @java.lang.Deprecated public Builder clearUuid() { + uuid_ = getDefaultInstance().getUuid(); + bitField0_ = (bitField0_ & ~0x00000001); + onChanged(); + return this; + } + /** + * 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. + */ + @java.lang.Deprecated 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 [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 @java.lang.Deprecated public int getRoleValue() { + return role_; + } + /** + * .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. + */ + @java.lang.Deprecated public Builder setRoleValue(int value) { + role_ = value; + bitField0_ |= 0x00000002; + onChanged(); + return this; + } + /** + * .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 + @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 [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. + */ + @java.lang.Deprecated 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.role is deprecated. + * See client.proto;l=318 + * @return This builder for chaining. + */ + @java.lang.Deprecated public Builder clearRole() { + bitField0_ = (bitField0_ & ~0x00000002); + role_ = 0; + onChanged(); + return this; + } + + private java.lang.Object account_ = ""; + /** + * 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.Deprecated public boolean hasAccount() { + return ((bitField0_ & 0x00000004) != 0); + } + /** + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 + * @return The account. + */ + @java.lang.Deprecated 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 + * @return The bytes for account. + */ + @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.copyFromUtf8( + (java.lang.String) ref); + account_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * 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. + */ + @java.lang.Deprecated public Builder setAccount( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + account_ = value; + bitField0_ |= 0x00000004; + onChanged(); + return this; + } + /** + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 + * @return This builder for chaining. + */ + @java.lang.Deprecated public Builder clearAccount() { + account_ = getDefaultInstance().getAccount(); + bitField0_ = (bitField0_ & ~0x00000004); + onChanged(); + return this; + } + /** + * 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. + */ + @java.lang.Deprecated 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 + * @return Whether the portfolio field is set. + */ + @java.lang.Deprecated public boolean hasPortfolio() { + return ((bitField0_ & 0x00000008) != 0); + } + /** + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 + * @return The portfolio. + */ + @java.lang.Deprecated 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 + * @return The bytes for portfolio. + */ + @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.copyFromUtf8( + (java.lang.String) ref); + portfolio_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * 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. + */ + @java.lang.Deprecated public Builder setPortfolio( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + portfolio_ = value; + bitField0_ |= 0x00000008; + onChanged(); + return this; + } + /** + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 + * @return This builder for chaining. + */ + @java.lang.Deprecated public Builder clearPortfolio() { + portfolio_ = getDefaultInstance().getPortfolio(); + bitField0_ = (bitField0_ & ~0x00000008); + onChanged(); + return this; + } + /** + * 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. + */ + @java.lang.Deprecated 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 [deprecated = true]; + */ + @java.lang.Deprecated 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 [deprecated = true]; + */ + @java.lang.Deprecated public int getMembershipsCount() { + if (membershipsBuilder_ == null) { + return memberships_.size(); + } else { + return membershipsBuilder_.getCount(); + } + } + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; + */ + @java.lang.Deprecated 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 [deprecated = true]; + */ + @java.lang.Deprecated 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 [deprecated = true]; + */ + @java.lang.Deprecated 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 [deprecated = true]; + */ + @java.lang.Deprecated 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 [deprecated = true]; + */ + @java.lang.Deprecated 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 [deprecated = true]; + */ + @java.lang.Deprecated 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 [deprecated = true]; + */ + @java.lang.Deprecated 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 [deprecated = true]; + */ + @java.lang.Deprecated 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 [deprecated = true]; + */ + @java.lang.Deprecated 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 [deprecated = true]; + */ + @java.lang.Deprecated 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 [deprecated = true]; + */ + @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 [deprecated = true]; + */ + @java.lang.Deprecated 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 [deprecated = true]; + */ + @java.lang.Deprecated 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 [deprecated = true]; + */ + @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 [deprecated = true]; + */ + @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 [deprecated = true]; + */ + @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> + 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..21091cea33 --- /dev/null +++ b/name.abuchen.portfolio/protos/name/abuchen/portfolio/model/proto/v1/PLedgerProjectionRefOrBuilder.java @@ -0,0 +1,110 @@ +// 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 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.uuid is deprecated. + * See client.proto;l=317 + * @return The uuid. + */ + @java.lang.Deprecated java.lang.String getUuid(); + /** + * 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.Deprecated com.google.protobuf.ByteString + getUuidBytes(); + + /** + * .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.Deprecated int getRoleValue(); + /** + * .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.Deprecated name.abuchen.portfolio.model.proto.v1.PLedgerProjectionRole getRole(); + + /** + * 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.Deprecated boolean hasAccount(); + /** + * optional string account = 3 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.account is deprecated. + * See client.proto;l=319 + * @return The account. + */ + @java.lang.Deprecated java.lang.String getAccount(); + /** + * 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.Deprecated com.google.protobuf.ByteString + getAccountBytes(); + + /** + * 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.Deprecated boolean hasPortfolio(); + /** + * optional string portfolio = 4 [deprecated = true]; + * @deprecated name.abuchen.portfolio.PLedgerProjectionRef.portfolio is deprecated. + * See client.proto;l=320 + * @return The portfolio. + */ + @java.lang.Deprecated java.lang.String getPortfolio(); + /** + * 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.Deprecated com.google.protobuf.ByteString + getPortfolioBytes(); + + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; + */ + @java.lang.Deprecated java.util.List + getMembershipsList(); + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; + */ + @java.lang.Deprecated name.abuchen.portfolio.model.proto.v1.PLedgerProjectionMembership getMemberships(int index); + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; + */ + @java.lang.Deprecated int getMembershipsCount(); + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; + */ + @java.lang.Deprecated java.util.List + getMembershipsOrBuilderList(); + /** + * repeated .name.abuchen.portfolio.PLedgerProjectionMembership memberships = 5 [deprecated = true]; + */ + @java.lang.Deprecated 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/Messages.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java index f3587dfbd3..c0e0a2a9aa 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/Messages.java @@ -242,6 +242,75 @@ 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 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 LedgerStructuralValidatorPostingPortfolioRequiredForSecurity; + public static String LedgerStructuralValidatorPostingSecurityRequired; + public static String LedgerStructuralValidatorPostingTypeRequired; + public static String LedgerStructuralValidatorPrimaryPostingRefNotFound; + public static String LedgerStructuralValidatorProjectionAccountRequired; + public static String LedgerStructuralValidatorProjectionGroupTargetConflict; + 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/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/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..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,11 @@ import java.text.MessageFormat; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.List; +import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import name.abuchen.portfolio.Messages; @@ -18,6 +22,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 +85,20 @@ public List getAvailableFixes() public List execute(Client client) { List issues = new ArrayList<>(); + Set reportedLedgerEntries = Collections.newSetFromMap(new IdentityHashMap<>()); for (Account account : client.getAccounts()) { for (AccountTransaction t : account.getTransactions()) // NOSONAR { + if (t instanceof LedgerBackedTransaction ledgerBacked) + { + if (reportedLedgerEntries.add(ledgerBacked.getLedgerEntry())) + addLedgerBackedIssue(client, issues, new TransactionPair<>(account, t), + ledgerBacked.getLedgerEntry()); + continue; + } + if (t.getCrossEntry() instanceof BuySellEntry) continue; @@ -107,6 +123,14 @@ public List execute(Client client) { for (PortfolioTransaction t : portfolio.getTransactions()) // NOSONAR { + if (t instanceof LedgerBackedTransaction ledgerBacked) + { + if (reportedLedgerEntries.add(ledgerBacked.getLedgerEntry())) + addLedgerBackedIssue(client, issues, new TransactionPair<>(portfolio, t), + ledgerBacked.getLedgerEntry()); + continue; + } + if (t.getType() == PortfolioTransaction.Type.TRANSFER_IN) continue; @@ -126,4 +150,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..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 @@ -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.LedgerEntry; +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; public class OnlyAccountTransactionsWithSecurityCanHaveExDateCheck implements Check { @@ -16,15 +24,57 @@ 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 = transaction.getLedgerProjectionDescriptor().getPrimaryPosting(); + + if (posting.getSecurity() != null || !removeExDateParameters(posting)) + return; + + transaction.getLedgerEntry().setUpdatedAt(Instant.now()); + refreshLedgerEntryProjections(client, transaction.getLedgerEntry()); + } + + 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, LedgerEntry entry) + { + client.getAccounts().forEach(owner -> owner.getTransactions() + .removeIf(transaction -> isProjectionOfEntry(transaction, entry))); + client.getPortfolios().forEach(owner -> owner.getTransactions() + .removeIf(transaction -> isProjectionOfEntry(transaction, entry))); + + LedgerProjectionService.materialize(client); + } + + private boolean isProjectionOfEntry(Transaction transaction, LedgerEntry entry) + { + return transaction instanceof LedgerBackedTransaction ledgerBacked + && ledgerBacked.getLedgerEntry() == entry; + } } 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..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,25 +1,21 @@ package name.abuchen.portfolio.datatransfer.actions; -import java.util.Iterator; -import java.util.List; - 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.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; 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; @@ -27,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) @@ -67,7 +64,7 @@ public Status process(AccountTransaction transaction, Account account) // security via the dialog) if (transaction.getSecurity() != null) process(transaction.getSecurity()); - account.addTransaction(transaction); + ledgerImportInsertionSupport.insert(transaction, account); if (removeDividends && transaction.getType() == AccountTransaction.Type.DIVIDENDS) { @@ -76,7 +73,7 @@ public Status process(AccountTransaction transaction, Account account) AccountTransaction.Type.REMOVAL); removal.setNote(transaction.getNote()); removal.setSource(transaction.getSource()); - account.addTransaction(removal); + ledgerImportInsertionSupport.insert(removal, account); } return Status.OK_STATUS; @@ -88,7 +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()); - portfolio.addTransaction(transaction); + ledgerImportInsertionSupport.insert(transaction, portfolio); return Status.OK_STATUS; } @@ -103,42 +100,9 @@ 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); - Transaction existingTransaction = null; - 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(); - existingTransaction = action.findInvestmentPlanTransaction(t, transactions); - // update existingTransaction when found and return - if (existingTransaction != null) - { - 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) @@ -157,13 +121,14 @@ public Status process(BuySellEntry entry, Account account, Portfolio portfolio) delivery.setShares(t.getShares()); delivery.addUnits(t.getUnits()); - return process(delivery, portfolio); + ledgerImportInsertionSupport.insert(delivery, portfolio); + return Status.OK_STATUS; } else { entry.setPortfolio(portfolio); entry.setAccount(account); - entry.insert(); + ledgerImportInsertionSupport.insert(entry, account, portfolio); return Status.OK_STATUS; } } @@ -173,7 +138,7 @@ public Status process(AccountTransferEntry entry, Account source, Account target { entry.setSourceAccount(source); entry.setTargetAccount(target); - entry.insert(); + ledgerImportInsertionSupport.insert(entry, source, target); return Status.OK_STATUS; } @@ -186,7 +151,7 @@ public Status process(PortfolioTransferEntry entry, Portfolio source, Portfolio entry.setSourcePortfolio(source); entry.setTargetPortfolio(target); - entry.insert(); + 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)); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties index 1e97e6b6b0..c23051f5c9 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/messages.properties @@ -472,6 +472,144 @@ 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. + +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. + +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. + +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. + +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..1699cb6c1f 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,142 @@ 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. + +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. + +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. + +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..4be2ebbdc9 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,142 @@ 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. + +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. + +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. + +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..bd833ab816 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,142 @@ 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. + +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. + +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. + +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..70d01dd693 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,142 @@ 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. + +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. + +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. + +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..d870a8ae46 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,142 @@ 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. + +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. + +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. + +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..60129117b9 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,142 @@ 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. + +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. + +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. + +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..680e122f1a 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,142 @@ 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. + +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. + +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. + +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..463d88410b 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,142 @@ 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. + +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. + +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. + +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..44daaf67e0 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,142 @@ 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. + +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. + +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. + +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..db727bb22e 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,142 @@ 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. + +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. + +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. + +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..1a58d96171 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,142 @@ 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. + +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. + +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. + +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..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 @@ -470,6 +470,142 @@ 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. + +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. + +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. + +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..cc45db9fc3 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,142 @@ 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. + +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. + +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. + +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..9ae96aaf14 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,142 @@ 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. + +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. + +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. + +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..f51d90ce89 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,142 @@ 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. + +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. + +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. + +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..4347a8519d 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,142 @@ 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. + +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. + +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. + +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..e928c107a9 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,142 @@ 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 + +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 + +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 + +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..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 @@ -472,6 +472,142 @@ 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 + +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 + +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 + +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/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..8078369bc8 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 @@ -31,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; @@ -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/ClientFactory.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java index fb3c1184a1..5ee66ec649 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ClientFactory.java @@ -146,6 +146,7 @@ public Client load(Reader input) throws IOException client.getVersion())); upgradeModel(client); + LedgerXmlPersistenceSupport.initializeAfterLoad(client); return client; } @@ -158,10 +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); - - makeXStream(false).toXML(client, writer); - - writer.flush(); + LedgerXmlPersistenceSupport.save(client, makeXStream(false), writer); } } @@ -717,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) @@ -947,6 +945,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; @@ -955,6 +956,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) @@ -1859,7 +1877,6 @@ private static XStream xstreamFactory() var xstream = new XStream(); xstream.allowTypesByWildcard(new String[] { "name.abuchen.portfolio.model.**" }); - xstream.setClassLoader(ClientFactory.class.getClassLoader()); // because we introduced LocalDate and LocalDateTime before Xstream @@ -1891,6 +1908,7 @@ private static XStream xstreamFactory() xstream.useAttributeFor(Transaction.Unit.class, "type"); xstream.alias("account-transaction", AccountTransaction.class); xstream.alias("portfolio-transaction", PortfolioTransaction.class); + LedgerXmlPersistenceSupport.configureXStream(xstream); xstream.alias("security", Security.class); xstream.addImplicitCollection(Security.class, "properties"); xstream.alias("latest", LatestSecurityPrice.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 f1149ea33a..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,12 +4,24 @@ 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; +import java.util.Objects; import java.util.Optional; +import java.util.UUID; 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.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; import name.abuchen.portfolio.money.Values; @@ -19,6 +31,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 @@ -64,8 +86,10 @@ public enum Type private long taxes; private Type type; + private String planKey; private List transactions = new ArrayList<>(); + private List ledgerExecutionRefs; public InvestmentPlan() { @@ -220,11 +244,48 @@ 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; + } + + boolean hasPlanKey() + { + return planKey != null && !planKey.isBlank(); + } + + void assignPlanKeyIfMissing(String planKey) + { + if (!hasPlanKey()) + this.planKey = planKey; + } + 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 @@ -232,6 +293,8 @@ public List getTransactions() */ public List> getTransactions(Client client) { + migrateLegacyLedgerExecutionRefs(client); + List> answer = new ArrayList<>(); for (Transaction t : transactions) @@ -243,9 +306,63 @@ public List> getTransactions(Client client) (PortfolioTransaction) t)); } + resolveGeneratedLedgerTransactions(client).forEach(answer::add); + + 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 @@ -277,11 +394,99 @@ 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; + + clearPlanExecution(ledgerBackedTransaction.getLedgerEntry()); + } + + public void removeLedgerExecutionRefs(LedgerEntry entry) + { + Objects.requireNonNull(entry); + + clearPlanExecution(entry); + getLedgerExecutionRefs().removeIf(ref -> entry.getUUID().equals(ref.getLedgerEntryUUID())); + } + + 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.getRuntimeProjectionId(), + transaction.getLedgerProjectionRole()); + } + + 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.getRuntimeProjectionId())) + && (projectionRole == null + || projectionRole == ledgerBackedTransaction.getLedgerProjectionRole()); + } } public String getCurrencyCode() @@ -318,6 +523,26 @@ public Optional getLastDate() return Optional.ofNullable(last); } + public Optional getLastDate(Client client) + { + migrateLegacyLedgerExecutionRefs(client); + + LocalDate last = getLastDate().orElse(null); + + for (var entry : client.getLedger().getEntries()) + { + if (!getPlanKey().equals(entry.getGeneratedByPlanKey())) + continue; + + LocalDate date = Optional.ofNullable(entry.getPlanExecutionDate()) + .orElse(entry.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 @@ -420,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); @@ -430,6 +656,118 @@ 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); + var ledgerBackedTransaction = (LedgerBackedTransaction) transaction.getTransaction(); + ledgerBackedTransaction.getLedgerEntry().setUpdatedAt(planExecutionUpdatedAt(transactionDate)); + markLedgerExecution(ledgerBackedTransaction, transactionDate); + newlyCreated.add(transaction); + + transactionDate = next(transactionDate); + } + + return newlyCreated; + } + + void markLedgerExecution(LedgerBackedTransaction transaction) + { + markLedgerExecution(transaction, transaction.getLedgerEntry().getDateTime().toLocalDate()); + } + + 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) + { + 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 { Type planType = getPlanType(); @@ -442,6 +780,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 +877,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 +982,60 @@ 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) + { + } + + public enum LedgerExecutionViewKind + { + ACCOUNT, PORTFOLIO + } + @Override public String toString() { 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..2ab91b3b17 --- /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.getRuntimeProjectionId(), + 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.getRuntimeProjectionId(), + ledgerTransaction.getLedgerProjectionRole(), + 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..f25de2f075 --- /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.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.getRuntimeProjectionId(), + sourceRole, targetRole)); + } + + public PortfolioTransaction reverse(TransactionPair transaction) + { + return delegate.reverse(transaction); + } +} 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..588fafc080 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerDiagnosticCode.java @@ -0,0 +1,332 @@ +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_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$ + 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/LedgerModelLoadSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerModelLoadSupport.java new file mode 100644 index 0000000000..071e2cac84 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerModelLoadSupport.java @@ -0,0 +1,173 @@ +package name.abuchen.portfolio.model; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +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.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; + +/** + * 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 static final String LEGACY_SPIN_OFF_TYPE_CODE = "SPIN_OFF"; //$NON-NLS-1$ + + 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 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); + } + + 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 setGeneratedByPlanKey(LedgerEntry entry, String generatedByPlanKey) + { + Objects.requireNonNull(entry).setGeneratedByPlanKey(generatedByPlanKey); + } + + static void setPlanExecutionDate(LedgerEntry entry, LocalDate planExecutionDate) + { + Objects.requireNonNull(entry).setPlanExecutionDate(planExecutionDate); + } + + static void setPlanExecutionSequence(LedgerEntry entry, Integer planExecutionSequence) + { + Objects.requireNonNull(entry).setPlanExecutionSequence(planExecutionSequence); + } + + static void setPreferredViewKind(LedgerEntry entry, String preferredViewKind) + { + Objects.requireNonNull(entry).setPreferredViewKind(preferredViewKind); + } + + 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 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); + } + +} 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..916fc2119f --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerPlanReferenceSupport.java @@ -0,0 +1,44 @@ +package name.abuchen.portfolio.model; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; + +/** + * Checks whether generated transaction references can follow a Ledger conversion. + * Plan executions are now linked by plan key and LedgerEntry execution metadata, + * not by projection UUID or projection role. + */ +final class LedgerPlanReferenceSupport +{ + private LedgerPlanReferenceSupport() + { + } + + static RoleChange roleChange(String runtimeProjectionId, LedgerProjectionRole sourceRole, + LedgerProjectionRole targetRole) + { + return new RoleChange(Objects.requireNonNull(runtimeProjectionId), Objects.requireNonNull(sourceRole), + Objects.requireNonNull(targetRole)); + } + + static boolean currentRefsResolveUniquely(Client client, LedgerEntry entry) + { + return true; + } + + static boolean refsFollowRoleChanges(Client client, LedgerEntry entry, RoleChange... changes) + { + return true; + } + + static String runtimeProjectionId(LedgerEntry entry, LedgerProjectionRole role) + { + return entry.getUUID() + ":" + role; //$NON-NLS-1$ + } + + record RoleChange(String runtimeProjectionId, LedgerProjectionRole sourceRole, LedgerProjectionRole targetRole) + { + } +} 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/LedgerProtobufPersistenceSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java new file mode 100644 index 0000000000..80ed44788f --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerProtobufPersistenceSupport.java @@ -0,0 +1,560 @@ +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()) + { + 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()) + { + var typeCode = newEntry.getTypeCode(); + LedgerEntry entry = LedgerModelLoadSupport.newEntry(UUID.randomUUID().toString(), + LedgerModelLoadSupport.entryTypeFromPersistedCode(typeCode), + 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)); + + if (LedgerModelLoadSupport.isLegacySpinOffTypeCode(typeCode)) + LedgerModelLoadSupport.addLegacySpinOffKindIfMissing(entry); + + 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.setTypeCode(entry.getType().getCode()); + 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/LedgerTransferDirectionConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerTransferDirectionConverter.java new file mode 100644 index 0000000000..617fd1f27e --- /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.runtimeProjectionId(entry, + LedgerProjectionRole.SOURCE_ACCOUNT), + LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT), + LedgerPlanReferenceSupport.roleChange( + LedgerPlanReferenceSupport.runtimeProjectionId(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.runtimeProjectionId(entry, + LedgerProjectionRole.SOURCE_PORTFOLIO), + LedgerProjectionRole.SOURCE_PORTFOLIO, LedgerProjectionRole.TARGET_PORTFOLIO), + LedgerPlanReferenceSupport.roleChange( + LedgerPlanReferenceSupport.runtimeProjectionId(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/LedgerXmlPersistenceSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerXmlPersistenceSupport.java new file mode 100644 index 0000000000..4a8592cbc5 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/LedgerXmlPersistenceSupport.java @@ -0,0 +1,667 @@ +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$ + var legacySpinOffTypeCode = false; + var typeAttribute = reader.getAttribute("type"); //$NON-NLS-1$ + + readAttribute(reader, "uuid").ifPresent(entry::setUUID); //$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()) + { + reader.moveDown(); + + switch (reader.getNodeName()) + { + case "uuid" -> entry.setUUID(reader.getValue()); //$NON-NLS-1$ + 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$ + 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( //$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$ + 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)); + + if (legacySpinOffTypeCode) + LedgerModelLoadSupport.addLegacySpinOffKindIfMissing(entry); + + 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); + } + } +} 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/ProtobufWriter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java index 0b2c38f4ae..ac88598132 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ProtobufWriter.java @@ -21,6 +21,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import com.google.protobuf.Any; @@ -57,7 +58,7 @@ /* package */ class ProtobufWriter implements ClientPersister { - private static class Lookup + static class Lookup { private Map uuid2security = new HashMap<>(); @@ -128,7 +129,10 @@ public Client load(InputStream input) throws IOException loadSecurities(newClient, client, lookup); loadAccounts(newClient, client, lookup); loadPortfolios(newClient, client, lookup); - loadTransactions(newClient, lookup); + + var ledgerLoadState = LedgerProtobufPersistenceSupport.loadLedgerTruth(newClient, client, lookup); + + loadTransactions(newClient, lookup, ledgerLoadState); client.getProperties().putAll(newClient.getPropertiesMap()); loadTaxonomies(newClient, client, lookup); @@ -141,6 +145,8 @@ public Client load(InputStream input) throws IOException ClientFactory.upgradeModel(client); + LedgerProtobufPersistenceSupport.finalizeAfterLoad(client, ledgerLoadState.hasLedgerTruth()); + return client; } @@ -304,10 +310,14 @@ private void loadPortfolios(PClient newClient, Client client, Lookup lookup) } } - private void loadTransactions(PClient newClient, Lookup lookup) + private void loadTransactions(PClient newClient, Lookup lookup, + LedgerProtobufPersistenceSupport.LoadState ledgerLoadState) { for (PTransaction newTransaction : newClient.getTransactionsList()) { + if (LedgerProtobufPersistenceSupport.isLedgerCompatibilityShadow(newTransaction, ledgerLoadState)) + continue; + PTransaction.Type type = newTransaction.getType(); switch (type) @@ -788,6 +798,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())); @@ -826,11 +838,21 @@ 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; + } + + if (LedgerProtobufPersistenceSupport.markPlanExecutionForProjectionUUID(client, plan, uuid)) + continue; + + throw new UnsupportedOperationException(uuid); } + newPlan.getLedgerExecutionRefsList().forEach( + ref -> LedgerProtobufPersistenceSupport.markPlanExecutionForLegacyRef(client, plan, ref)); + client.addPlan(plan); } } @@ -866,6 +888,8 @@ public void save(Client client, OutputStream output) throws IOException saveSecurities(client, newClient); saveAccounts(client, newClient); savePortfolios(client, newClient); + LedgerProtobufPersistenceSupport.prepareInvestmentPlanLedgerExecutions(client); + LedgerProtobufPersistenceSupport.saveLedger(client, newClient); saveTransactions(client, newClient); newClient.putAllProperties(client.getProperties()); @@ -1046,9 +1070,15 @@ private void savePortfolios(Client client, PClient.Builder newClient) private void saveTransactions(Client client, PClient.Builder 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) + portfolio.getTransactions().stream() + .filter(t -> t.getType() != PortfolioTransaction.Type.TRANSFER_IN) + .filter(t -> LedgerProtobufPersistenceSupport.shouldSaveLegacyTransaction(t, + ledgerProjectionUUIDs)) .forEach(t -> addTransaction(newClient, portfolio, t)); } @@ -1057,38 +1087,44 @@ 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 -> LedgerProtobufPersistenceSupport.shouldSaveLegacyTransaction(t, + ledgerProjectionUUIDs)) .forEach(t -> addTransaction(newClient, account, t)); } } - 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(t.getUUID()); + 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(t.getCrossEntry().getCrossTransaction(t).getUUID()); + 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(t.getCrossEntry().getCrossTransaction(t).getUUID()); + 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(t.getCrossEntry().getCrossTransaction(t).getUUID()); + newTransaction.setOtherUuid(LedgerProtobufPersistenceSupport.protobufTransactionUUID( + t.getCrossEntry().getCrossTransaction(t))); newTransaction.setOtherUpdatedAt( asUpdatedAtTimestamp(t.getCrossEntry().getCrossTransaction(t).getUpdatedAt())); break; @@ -1108,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(t.getUUID()); + newTransaction.setUuid(LedgerProtobufPersistenceSupport.protobufTransactionUUID(t)); switch (t.getType()) { @@ -1148,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(t.getCrossEntry().getCrossTransaction(t).getUUID()); + newTransaction.setOtherUuid(LedgerProtobufPersistenceSupport.protobufTransactionUUID( + t.getCrossEntry().getCrossTransaction(t))); newTransaction.setOtherUpdatedAt( asUpdatedAtTimestamp(t.getCrossEntry().getCrossTransaction(t).getUpdatedAt())); break; @@ -1374,6 +1411,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()); @@ -1411,7 +1449,10 @@ private void saveInvestmentPlans(Client client, PClient.Builder newClient) throw new UnsupportedOperationException(); } - plan.getTransactions().forEach(t -> newPlan.addTransactions(t.getUUID())); + plan.getTransactions().forEach(t -> { + if (!LedgerProtobufPersistenceSupport.markPlanExecution(plan, t)) + newPlan.addTransactions(t.getUUID()); + }); newClient.addPlans(newPlan); }); 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/client.proto b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/client.proto index a805d285fd..06ea1215fa 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,134 @@ message PInvestmentPlan { int64 taxes = 13; Type type = 14; + + repeated PInvestmentPlanLedgerExecutionRef ledgerExecutionRefs = 15; + optional string planKey = 16; +} + +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 [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 [deprecated = true]; + optional string typeCode = 9; + repeated PLedgerParameter parameters = 10; + optional string generatedByPlanKey = 11; + optional int64 planExecutionDate = 12; + optional int32 planExecutionSequence = 13; + optional string preferredViewKind = 14; +} + +message PLedgerPosting { + string uuid = 1 [deprecated = true]; + 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; + 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 [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 [deprecated = true]; + PLedgerProjectionMembershipRole role = 2 [deprecated = true]; +} + +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 +358,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 +374,7 @@ message PTaxonomy { string name = 2; optional string source = 3; repeated string dimensions = 4; - + repeated PTaxonomy.Classification classifications = 5; } @@ -256,12 +384,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 +427,7 @@ message PSettings { message PClient { int32 version = 1; - + repeated PSecurity securities = 2; repeated PAccount accounts = 3; repeated PPortfolio portfolios = 4; @@ -307,15 +435,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; } 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/Ledger.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/Ledger.java new file mode 100644 index 0000000000..446112922d --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/Ledger.java @@ -0,0 +1,31 @@ +package name.abuchen.portfolio.model.ledger; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Stores the persisted Ledger entries for a client. + * This is an internal Ledger model container. Normal contributor code should use creators, + * editors, converters, or mutation contexts instead of editing the ledger graph directly. + */ +public class Ledger +{ + private final List entries = new ArrayList<>(); + + public List getEntries() + { + return Collections.unmodifiableList(entries); + } + + public void addEntry(LedgerEntry entry) + { + entries.add(Objects.requireNonNull(entry)); + } + + public boolean removeEntry(LedgerEntry entry) + { + return entries.remove(entry); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatter.java new file mode 100644 index 0000000000..d24798d58e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerDiagnosticMessageFormatter.java @@ -0,0 +1,349 @@ +package name.abuchen.portfolio.model.ledger; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Values; + +/** + * Adds best-effort user context to Ledger diagnostics while preserving the + * original technical message and identifiers. + */ +@SuppressWarnings("nls") +public final class LedgerDiagnosticMessageFormatter +{ + private LedgerDiagnosticMessageFormatter() + { + } + + public static String formatValidationResult(Ledger ledger, LedgerStructuralValidator.ValidationResult result) + { + Objects.requireNonNull(result); + + if (result.isOK()) + return result.format(); + + try + { + var builder = new StringBuilder(); + + for (var issue : result.getIssues()) + { + if (!builder.isEmpty()) + builder.append("\n\n"); + + builder.append(issue.format()); + builder.append(formatEntryContext(findEntry(ledger, issue).orElse(null))); + } + + return builder.toString(); + } + catch (RuntimeException e) + { + return result.format(); + } + } + + public static String formatMigrationDiagnostic(Client client, String technicalMessage, + Transaction... transactions) + { + Objects.requireNonNull(technicalMessage); + + try + { + return technicalMessage + formatTransactionContext(client, transactions); + } + catch (RuntimeException e) + { + return technicalMessage; + } + } + + public static String formatEntryContext(LedgerEntry entry) + { + var context = new ContextLines(); + + if (entry != null) + { + context.add(Messages.LedgerDiagnosticMessageFormatterDate, entry.getDateTime()); + context.add(Messages.LedgerDiagnosticMessageFormatterType, entry.getType()); + context.add(Messages.LedgerDiagnosticMessageFormatterAccount, accountNames(entry)); + context.add(Messages.LedgerDiagnosticMessageFormatterPortfolio, portfolioNames(entry)); + context.add(Messages.LedgerDiagnosticMessageFormatterSecurity, securityNames(entry)); + context.add(Messages.LedgerDiagnosticMessageFormatterAmount, amounts(entry)); + context.add(Messages.LedgerDiagnosticMessageFormatterShares, shares(entry)); + context.add(Messages.LedgerDiagnosticMessageFormatterSource, entry.getSource()); + context.add(Messages.LedgerDiagnosticMessageFormatterNote, entry.getNote()); + } + + return context.format(); + } + + private static String formatTransactionContext(Client client, Transaction... transactions) + { + var context = new ContextLines(); + + if (transactions != null) + { + for (var transaction : transactions) + { + if (transaction == null) + continue; + + context.add(Messages.LedgerDiagnosticMessageFormatterDate, transaction.getDateTime()); + context.add(Messages.LedgerDiagnosticMessageFormatterType, transactionType(transaction)); + context.add(Messages.LedgerDiagnosticMessageFormatterAccount, accountName(client, transaction)); + context.add(Messages.LedgerDiagnosticMessageFormatterPortfolio, portfolioName(client, transaction)); + context.add(Messages.LedgerDiagnosticMessageFormatterSecurity, securitySummary(transaction.getSecurity())); + + if (transaction.getCurrencyCode() != null) + context.add(Messages.LedgerDiagnosticMessageFormatterAmount, + Values.Money.format(Money.of(transaction.getCurrencyCode(), + transaction.getAmount()))); + + if (transaction.getShares() != 0) + context.add(Messages.LedgerDiagnosticMessageFormatterShares, + Values.Share.format(transaction.getShares())); + + context.add(Messages.LedgerDiagnosticMessageFormatterSource, transaction.getSource()); + context.add(Messages.LedgerDiagnosticMessageFormatterNote, transaction.getNote()); + } + } + + return context.format(); + } + + private static Optional findEntry(Ledger ledger, LedgerStructuralValidator.ValidationIssue issue) + { + if (ledger == null) + return Optional.empty(); + + var details = issue.getDetails(); + var entryUUID = details.get("entryUUID"); + + if (entryUUID != null && !entryUUID.isBlank() && !"".equals(entryUUID)) + { + var entry = ledger.getEntries().stream().filter(candidate -> entryUUID.equals(candidate.getUUID())) + .findFirst(); + + if (entry.isPresent()) + return entry; + } + + return ledger.getEntries().stream().filter(entry -> matchesEntry(entry, details.get("postingUUID"))) + .findFirst(); + } + + private static boolean matchesEntry(LedgerEntry entry, String... uuids) + { + for (var uuid : uuids) + { + if (uuid == null || uuid.isBlank() || "".equals(uuid)) + continue; + + if (entry.getPostings().stream().anyMatch(posting -> uuid.equals(posting.getUUID()))) + return true; + } + + return false; + } + + private static Set accountNames(LedgerEntry entry) + { + var values = new LinkedHashSet(); + + for (var posting : entry.getPostings()) + add(values, accountSummary(posting.getAccount())); + + return values; + } + + private static Set portfolioNames(LedgerEntry entry) + { + var values = new LinkedHashSet(); + + for (var posting : entry.getPostings()) + add(values, portfolioSummary(posting.getPortfolio())); + + return values; + } + + private static Set securityNames(LedgerEntry entry) + { + var values = new LinkedHashSet(); + + for (var posting : entry.getPostings()) + add(values, securitySummary(posting.getSecurity())); + + return values; + } + + private static Set amounts(LedgerEntry entry) + { + var values = new LinkedHashSet(); + + for (var posting : entry.getPostings()) + { + if (posting.getCurrency() != null) + add(values, Values.Money.format(Money.of(posting.getCurrency(), posting.getAmount()))); + } + + return values; + } + + private static Set shares(LedgerEntry entry) + { + var values = new LinkedHashSet(); + + for (var posting : entry.getPostings()) + { + if (posting.getShares() != 0) + add(values, Values.Share.format(posting.getShares())); + } + + return values; + } + + private static String transactionType(Transaction transaction) + { + if (transaction instanceof AccountTransaction accountTransaction) + return String.valueOf(accountTransaction.getType()); + if (transaction instanceof PortfolioTransaction portfolioTransaction) + return String.valueOf(portfolioTransaction.getType()); + + return transaction.getClass().getSimpleName(); + } + + private static String accountName(Client client, Transaction transaction) + { + if (!(transaction instanceof AccountTransaction accountTransaction) || client == null) + return null; + + return client.getAccounts().stream() + .filter(account -> account.getTransactions().stream() + .anyMatch(candidate -> candidate == accountTransaction + || candidate.getUUID().equals(accountTransaction.getUUID()))) + .findFirst().map(LedgerDiagnosticMessageFormatter::accountSummary).orElse(null); + } + + private static String portfolioName(Client client, Transaction transaction) + { + if (!(transaction instanceof PortfolioTransaction portfolioTransaction) || client == null) + return null; + + return client.getPortfolios().stream() + .filter(portfolio -> portfolio.getTransactions().stream() + .anyMatch(candidate -> candidate == portfolioTransaction + || candidate.getUUID().equals(portfolioTransaction.getUUID()))) + .findFirst().map(LedgerDiagnosticMessageFormatter::portfolioSummary).orElse(null); + } + + private static String accountSummary(Account account) + { + return account == null ? null : nameOrFallback(account.getName(), + Messages.LedgerDiagnosticMessageFormatterUnnamedAccount); + } + + private static String portfolioSummary(Portfolio portfolio) + { + return portfolio == null ? null : nameOrFallback(portfolio.getName(), + Messages.LedgerDiagnosticMessageFormatterUnnamedPortfolio); + } + + private static String securitySummary(Security security) + { + if (security == null) + return null; + + var builder = new StringBuilder(nameOrFallback(security.getName(), + Messages.LedgerDiagnosticMessageFormatterUnnamedSecurity)); + var identifiers = new LinkedHashSet(); + + add(identifiers, labeled(Messages.LedgerDiagnosticMessageFormatterIsin, security.getIsin())); + add(identifiers, labeled(Messages.LedgerDiagnosticMessageFormatterWkn, security.getWkn())); + add(identifiers, labeled(Messages.LedgerDiagnosticMessageFormatterTicker, security.getTickerSymbol())); + + if (!identifiers.isEmpty()) + builder.append(" (").append(String.join(", ", identifiers)).append(")"); + + return builder.toString(); + } + + private static String labeled(String label, String value) + { + return isBlank(value) ? null : label + "=" + value; + } + + private static String nameOrFallback(String name, String fallback) + { + return isBlank(name) ? "<" + fallback + ">" : name; + } + + private static void add(Set values, Object value) + { + if (value == null) + return; + + var string = String.valueOf(value); + + if (!isBlank(string)) + values.add(string); + } + + private static boolean isBlank(String value) + { + return value == null || value.isBlank(); + } + + private static final class ContextLines + { + private final StringBuilder builder = new StringBuilder(); + + private void add(String label, Object value) + { + if (value instanceof Set values) + { + if (!values.isEmpty()) + append(label, String.join(", ", values.stream().map(String::valueOf).toList())); + return; + } + + if (value == null) + return; + + var string = String.valueOf(value); + + if (!string.isBlank()) + append(label, string); + } + + private void append(String label, String value) + { + if (builder.isEmpty()) + builder.append("\n\n").append(Messages.LedgerDiagnosticMessageFormatterTransactionContext) + .append(":"); + + builder.append("\n ").append(label).append(": ").append(value); + } + + private String format() + { + if (builder.isEmpty()) + return "\n\n" + Messages.LedgerDiagnosticMessageFormatterTransactionContext + ":\n " //$NON-NLS-1$ //$NON-NLS-2$ + + Messages.LedgerDiagnosticMessageFormatterContextUnavailable; + + return builder.toString(); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntry.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntry.java new file mode 100644 index 0000000000..101ca3b0fa --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntry.java @@ -0,0 +1,208 @@ +package name.abuchen.portfolio.model.ledger; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; + +/** + * Represents one persisted Ledger transaction entry. + * This is an internal Ledger model type. Normal contributor code should not mutate entries + * directly; it should use a creator, editor, converter, deleter, or mutation context. + */ +public class LedgerEntry +{ + private String uuid; + private LedgerEntryType type; + private LocalDateTime dateTime; + private String note; + private String source; + private Instant updatedAt; + private String generatedByPlanKey; + private LocalDate planExecutionDate; + private Integer planExecutionSequence; + private String preferredViewKind; + private List> parameters = new ArrayList<>(); + private final List postings = new ArrayList<>(); + + public LedgerEntry() + { + this(UUID.randomUUID().toString()); + } + + public LedgerEntry(String uuid) + { + this.uuid = Objects.requireNonNull(uuid); + this.updatedAt = Instant.now(); + } + + public String getUUID() + { + return uuid; + } + + public void setUUID(String uuid) + { + this.uuid = Objects.requireNonNull(uuid); + touch(); + } + + public LedgerEntryType getType() + { + return type; + } + + public void setType(LedgerEntryType type) + { + this.type = type; + touch(); + } + + public LocalDateTime getDateTime() + { + return dateTime; + } + + public void setDateTime(LocalDateTime dateTime) + { + this.dateTime = dateTime; + touch(); + } + + public String getNote() + { + return note; + } + + public void setNote(String note) + { + this.note = note; + touch(); + } + + public String getSource() + { + return source; + } + + public void setSource(String source) + { + this.source = source; + touch(); + } + + public Instant getUpdatedAt() + { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) + { + this.updatedAt = updatedAt; + } + + public String getGeneratedByPlanKey() + { + return generatedByPlanKey; + } + + public void setGeneratedByPlanKey(String generatedByPlanKey) + { + this.generatedByPlanKey = generatedByPlanKey; + touch(); + } + + public LocalDate getPlanExecutionDate() + { + return planExecutionDate; + } + + public void setPlanExecutionDate(LocalDate planExecutionDate) + { + this.planExecutionDate = planExecutionDate; + touch(); + } + + public Integer getPlanExecutionSequence() + { + return planExecutionSequence; + } + + public void setPlanExecutionSequence(Integer planExecutionSequence) + { + this.planExecutionSequence = planExecutionSequence; + touch(); + } + + public String getPreferredViewKind() + { + return preferredViewKind; + } + + public void setPreferredViewKind(String preferredViewKind) + { + this.preferredViewKind = preferredViewKind; + touch(); + } + + public List> getParameters() + { + return Collections.unmodifiableList(parameters()); + } + + public void addParameter(LedgerParameter parameter) + { + parameters().add(Objects.requireNonNull(parameter)); + touch(); + } + + public boolean removeParameter(LedgerParameter parameter) + { + var removed = parameters().remove(parameter); + + if (removed) + touch(); + + return removed; + } + + private List> parameters() + { + if (parameters == null) + parameters = new ArrayList<>(); + + return parameters; + } + + public List getPostings() + { + return Collections.unmodifiableList(postings); + } + + public void addPosting(LedgerPosting posting) + { + postings.add(Objects.requireNonNull(posting)); + touch(); + } + + public boolean removePosting(LedgerPosting posting) + { + var removed = postings.remove(posting); + + if (removed) + touch(); + + return removed; + } + + private void touch() + { + this.updatedAt = Instant.now(); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryEditSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryEditSupport.java new file mode 100644 index 0000000000..fc09f1c7d9 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryEditSupport.java @@ -0,0 +1,86 @@ +package name.abuchen.portfolio.model.ledger; + +import java.time.Instant; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; + +/** + * Provides shared support for safe edits on persisted Ledger entries. + * This is internal Ledger mutation support. Contributors should normally use creators, + * editors, converters, or deleters instead of calling it directly. + */ +public final class LedgerEntryEditSupport +{ + private LedgerEntryEditSupport() + { + } + + public static void applyValidated(LedgerEntry entry, EntryPatch patch) + { + validatePatch(entry, patch); + patch.apply(entry); + entry.setUpdatedAt(Instant.now()); + } + + public static void validatePatch(LedgerEntry entry, EntryPatch patch) + { + var candidate = LedgerModelCopy.copyEntry(entry); + + patch.apply(candidate); + validate(candidate); + } + + public static int postingIndex(LedgerEntry entry, LedgerPosting posting) + { + var index = entry.getPostings().indexOf(posting); + + if (index < 0) + throw new IllegalArgumentException("Ledger posting does not belong to entry"); //$NON-NLS-1$ + + return index; + } + + public static LedgerPosting postingAt(LedgerEntry entry, int index) + { + if (index < 0 || index >= entry.getPostings().size()) + throw new IllegalArgumentException("Ledger posting index out of range: " + index); //$NON-NLS-1$ + + return entry.getPostings().get(index); + } + + public static LedgerPosting postingByUUID(LedgerEntry entry, String uuid) + { + return entry.getPostings().stream() // + .filter(posting -> posting.getUUID().equals(uuid)) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Ledger posting not found: " + uuid)); //$NON-NLS-1$ + } + + static LedgerPosting firstPosting(LedgerEntry entry, LedgerPostingType type) + { + return entry.getPostings().stream() // + .filter(posting -> posting.getType() == type) // + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Ledger posting not found: " + type)); //$NON-NLS-1$ + } + + private static void validate(LedgerEntry entry) + { + var ledger = new Ledger(); + + LedgerGraphWriter.addEntry(ledger, entry); + + var result = LedgerStructuralValidator.validate(ledger); + + if (!result.isOK()) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_004.message("Invalid ledger edit: " + result.format())); //$NON-NLS-1$ + } + + @FunctionalInterface + public interface EntryPatch + { + void apply(LedgerEntry entry); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryMetadataPatch.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryMetadataPatch.java new file mode 100644 index 0000000000..708e6c87a9 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryMetadataPatch.java @@ -0,0 +1,93 @@ +package name.abuchen.portfolio.model.ledger; + +import java.time.LocalDateTime; + +/** + * Describes a metadata change for a persisted Ledger entry. + * This is internal edit data used by Ledger mutation support. Contributor code should + * normally use a compatibility write path that builds these patches. + */ +public final class LedgerEntryMetadataPatch +{ + private final LedgerFieldEdit dateTime; + private final LedgerFieldEdit note; + private final LedgerFieldEdit source; + + private LedgerEntryMetadataPatch(Builder builder) + { + this.dateTime = builder.dateTime; + this.note = builder.note; + this.source = builder.source; + } + + public static LedgerEntryMetadataPatch none() + { + return builder().build(); + } + + public static Builder builder() + { + return new Builder(); + } + + public LedgerFieldEdit getDateTime() + { + return dateTime; + } + + public LedgerFieldEdit getNote() + { + return note; + } + + public LedgerFieldEdit getSource() + { + return source; + } + + public static final class Builder + { + private LedgerFieldEdit dateTime = LedgerFieldEdit.omitted(); + private LedgerFieldEdit note = LedgerFieldEdit.omitted(); + private LedgerFieldEdit source = LedgerFieldEdit.omitted(); + + private Builder() + { + } + + public Builder dateTime(LocalDateTime dateTime) + { + this.dateTime = LedgerFieldEdit.set(dateTime); + return this; + } + + public Builder note(String note) + { + this.note = note != null ? LedgerFieldEdit.set(note) : LedgerFieldEdit.clear(); + return this; + } + + public Builder clearNote() + { + this.note = LedgerFieldEdit.clear(); + return this; + } + + public Builder source(String source) + { + this.source = source != null ? LedgerFieldEdit.set(source) : LedgerFieldEdit.clear(); + return this; + } + + public Builder clearSource() + { + this.source = LedgerFieldEdit.clear(); + return this; + } + + public LedgerEntryMetadataPatch build() + { + return new LedgerEntryMetadataPatch(this); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryMetadataPatchHelper.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryMetadataPatchHelper.java new file mode 100644 index 0000000000..bd800f6f0c --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerEntryMetadataPatchHelper.java @@ -0,0 +1,78 @@ +package name.abuchen.portfolio.model.ledger; + +import java.time.LocalDateTime; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; + +/** + * Applies safe metadata edits to persisted Ledger entries. + * This is internal Ledger mutation support. Contributor code should normally use + * higher-level Ledger editors or compatibility write paths. + */ +public final class LedgerEntryMetadataPatchHelper +{ + private LedgerEntryMetadataPatchHelper() + { + } + + public static void apply(LedgerEntry entry, LedgerEntryMetadataPatch patch) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(patch); + + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyDirect(editedEntry, patch)); + } + + public static void setDateTime(LedgerEntry entry, LocalDateTime dateTime) + { + Objects.requireNonNull(entry); + + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> editedEntry.setDateTime(dateTime)); + } + + public static void setNote(LedgerEntry entry, String note) + { + apply(entry, LedgerEntryMetadataPatch.builder().note(note).build()); + } + + public static void setSource(LedgerEntry entry, String source) + { + apply(entry, LedgerEntryMetadataPatch.builder().source(source).build()); + } + + private static void applyDirect(LedgerEntry entry, LedgerEntryMetadataPatch patch) + { + applyDateTime(entry, patch.getDateTime()); + applyNote(entry, patch.getNote()); + applySource(entry, patch.getSource()); + } + + private static void applyDateTime(LedgerEntry entry, LedgerFieldEdit edit) + { + if (edit.isOmitted()) + return; + + if (edit.isClear()) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_005.message("Ledger entry date/time cannot be cleared")); //$NON-NLS-1$ + + entry.setDateTime((java.time.LocalDateTime) edit.getValue()); + } + + private static void applyNote(LedgerEntry entry, LedgerFieldEdit edit) + { + if (edit.isOmitted()) + return; + + entry.setNote(edit.isClear() ? null : edit.getValue()); + } + + private static void applySource(LedgerEntry entry, LedgerFieldEdit edit) + { + if (edit.isOmitted()) + return; + + entry.setSource(edit.isClear() ? null : edit.getValue()); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerFieldEdit.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerFieldEdit.java new file mode 100644 index 0000000000..9692d6dc4a --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerFieldEdit.java @@ -0,0 +1,78 @@ +package name.abuchen.portfolio.model.ledger; + +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; + +/** + * Describes one field-level metadata edit for a persisted Ledger entry. + * This is internal edit data used by Ledger mutation support. It does not mutate Ledger + * truth by itself. + */ +public final class LedgerFieldEdit +{ + public enum State + { + OMITTED, + SET, + CLEAR + } + + private static final LedgerFieldEdit OMITTED = new LedgerFieldEdit<>(State.OMITTED, null); + private static final LedgerFieldEdit CLEAR = new LedgerFieldEdit<>(State.CLEAR, null); + + private final State state; + private final T value; + + private LedgerFieldEdit(State state, T value) + { + this.state = state; + this.value = value; + } + + @SuppressWarnings("unchecked") + public static LedgerFieldEdit omitted() + { + return (LedgerFieldEdit) OMITTED; + } + + public static LedgerFieldEdit set(T value) + { + return new LedgerFieldEdit<>(State.SET, Objects.requireNonNull(value)); + } + + @SuppressWarnings("unchecked") + public static LedgerFieldEdit clear() + { + return (LedgerFieldEdit) CLEAR; + } + + public State getState() + { + return state; + } + + public T getValue() + { + if (state != State.SET) + throw new IllegalStateException( + LedgerDiagnosticCode.LEDGER_CORE_006.message("Only set edits have a value")); //$NON-NLS-1$ + + return value; + } + + public boolean isOmitted() + { + return state == State.OMITTED; + } + + public boolean isSet() + { + return state == State.SET; + } + + public boolean isClear() + { + return state == State.CLEAR; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriter.java new file mode 100644 index 0000000000..b8ba3a70d4 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerGraphWriter.java @@ -0,0 +1,55 @@ +package name.abuchen.portfolio.model.ledger; + +import java.util.List; +import java.util.Objects; + +/** + * Writes a replacement Ledger graph back to the client after validation. + * This is internal mutation infrastructure. Contributor code should reach it through a + * mutation context or a higher-level Ledger write path. + */ +final class LedgerGraphWriter +{ + private LedgerGraphWriter() + { + } + + static void addEntry(Ledger ledger, LedgerEntry entry) + { + Objects.requireNonNull(ledger).addEntry(Objects.requireNonNull(entry)); + } + + static void removeEntry(Ledger ledger, LedgerEntry entry) + { + Objects.requireNonNull(ledger).removeEntry(Objects.requireNonNull(entry)); + } + + static void replaceEntry(Ledger ledger, LedgerEntry current, LedgerEntry replacement) + { + removeEntry(ledger, current); + addEntry(ledger, replacement); + } + + static void replaceEntryContents(LedgerEntry target, LedgerEntry source) + { + Objects.requireNonNull(target); + + var copy = LedgerModelCopy.copyEntry(source); + + target.setUUID(copy.getUUID()); + target.setType(copy.getType()); + target.setDateTime(copy.getDateTime()); + target.setNote(copy.getNote()); + target.setSource(copy.getSource()); + + for (var parameter : List.copyOf(target.getParameters())) + target.removeParameter(parameter); + + for (var posting : List.copyOf(target.getPostings())) + target.removePosting(posting); + + copy.getParameters().forEach(target::addParameter); + copy.getPostings().forEach(target::addPosting); + target.setUpdatedAt(copy.getUpdatedAt()); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerModelCopy.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerModelCopy.java new file mode 100644 index 0000000000..506cbfcb82 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerModelCopy.java @@ -0,0 +1,71 @@ +package name.abuchen.portfolio.model.ledger; + +import java.util.Objects; + +/** + * Copies Ledger model objects for safe mutation and rollback paths. + * This is internal Ledger infrastructure. Normal contributor code should rely on mutation + * contexts rather than copying Ledger graph objects directly. + */ +public final class LedgerModelCopy +{ + private LedgerModelCopy() + { + } + + public static Ledger copyLedger(Ledger source) + { + var copy = new Ledger(); + + Objects.requireNonNull(source).getEntries().stream().map(LedgerModelCopy::copyEntry).forEach(copy::addEntry); + + return copy; + } + + static LedgerEntry copyEntry(LedgerEntry source) + { + var copy = new LedgerEntry(Objects.requireNonNull(source).getUUID()); + + copy.setType(source.getType()); + copy.setDateTime(source.getDateTime()); + copy.setNote(source.getNote()); + copy.setSource(source.getSource()); + + source.getParameters().stream().map(LedgerModelCopy::copyParameter).forEach(copy::addParameter); + source.getPostings().stream().map(LedgerModelCopy::copyPosting).forEach(copy::addPosting); + copy.setUpdatedAt(source.getUpdatedAt()); + + return copy; + } + + static LedgerPosting copyPosting(LedgerPosting source) + { + var copy = new LedgerPosting(Objects.requireNonNull(source).getUUID()); + + copy.setType(source.getType()); + copy.setAmount(source.getAmount()); + copy.setCurrency(source.getCurrency()); + copy.setForexAmount(source.getForexAmount()); + copy.setForexCurrency(source.getForexCurrency()); + copy.setExchangeRate(source.getExchangeRate()); + copy.setSecurity(source.getSecurity()); + copy.setShares(source.getShares()); + copy.setAccount(source.getAccount()); + copy.setPortfolio(source.getPortfolio()); + copy.setSemanticRole(source.getSemanticRole()); + copy.setDirection(source.getDirection()); + copy.setCorporateActionLeg(source.getCorporateActionLeg()); + copy.setUnitRole(source.getUnitRole()); + copy.setGroupKey(source.getGroupKey()); + copy.setLocalKey(source.getLocalKey()); + source.getParameters().stream().map(LedgerModelCopy::copyParameter).forEach(copy::addParameter); + + return copy; + } + + static LedgerParameter copyParameter(LedgerParameter source) + { + Objects.requireNonNull(source); + return LedgerParameter.unchecked(source.getType(), source.getValueKind(), source.getValue()); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerMutationContext.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerMutationContext.java new file mode 100644 index 0000000000..6ccbd23aa4 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerMutationContext.java @@ -0,0 +1,249 @@ +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 prepared = LedgerModelCopy.copyEntry(replacement); + + prepared.setUUID(currentEntry.getUUID()); + + return prepared; + } + + 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 -> LedgerProjectionService.createProjections(entry).stream()) // + .map(name.abuchen.portfolio.model.Transaction::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/LedgerParameter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerParameter.java new file mode 100644 index 0000000000..23ab372929 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerParameter.java @@ -0,0 +1,161 @@ +package name.abuchen.portfolio.model.ledger; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Objects; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.configuration.LedgerCode; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; +import name.abuchen.portfolio.money.Money; + +/** + * Represents an extra typed fact stored on a Ledger entry or posting. + * This is internal Ledger model data. Contributor code should use the appropriate creator, + * editor, converter, or native assembler instead of editing parameters directly. + */ +public class LedgerParameter +{ + public enum ValueKind + { + STRING(String.class), + DECIMAL(BigDecimal.class), + LONG(Long.class), + MONEY(Money.class), + SECURITY(Security.class), + ACCOUNT(Account.class), + PORTFOLIO(Portfolio.class), + BOOLEAN(Boolean.class), + LOCAL_DATE(LocalDate.class), + LOCAL_DATE_TIME(LocalDateTime.class); + + private final Class valueType; + + private ValueKind(Class valueType) + { + this.valueType = Objects.requireNonNull(valueType); + } + + public Class getValueType() + { + return valueType; + } + + public boolean supportsValue(Object value) + { + return value != null && valueType.isInstance(value); + } + } + + private final LedgerParameterType type; + private final ValueKind valueKind; + private final T value; + + private LedgerParameter(LedgerParameterType type, ValueKind valueKind, T value) + { + this.type = Objects.requireNonNull(type); + this.valueKind = Objects.requireNonNull(valueKind); + this.value = Objects.requireNonNull(value); + } + + public static LedgerParameter ofString(LedgerParameterType type, String value) + { + return of(type, ValueKind.STRING, value); + } + + public static LedgerParameter ofCode(LedgerParameterType type, LedgerCode code) + { + Objects.requireNonNull(type).requireValueKind(ValueKind.STRING); + Objects.requireNonNull(code); + + if (!type.hasCodeDomain()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_012 + .message(type + " does not define a controlled code domain")); //$NON-NLS-1$ + + if (type.getCodeDomain() != code.getDomain()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_013.message( + type + " expects code domain " + type.getCodeDomain() + " but got " + code.getDomain())); //$NON-NLS-1$ //$NON-NLS-2$ + + if (!type.supportsCode(code.getCode())) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_014.message(type + " does not support code " + code.getCode())); //$NON-NLS-1$ + + return ofString(type, code.getCode()); + } + + public static LedgerParameter ofDecimal(LedgerParameterType type, BigDecimal value) + { + return of(type, ValueKind.DECIMAL, value); + } + + public static LedgerParameter ofLong(LedgerParameterType type, long value) + { + return of(type, ValueKind.LONG, Long.valueOf(value)); + } + + public static LedgerParameter ofMoney(LedgerParameterType type, Money value) + { + return of(type, ValueKind.MONEY, value); + } + + public static LedgerParameter ofSecurity(LedgerParameterType type, Security value) + { + return of(type, ValueKind.SECURITY, value); + } + + public static LedgerParameter ofAccount(LedgerParameterType type, Account value) + { + return of(type, ValueKind.ACCOUNT, value); + } + + public static LedgerParameter ofPortfolio(LedgerParameterType type, Portfolio value) + { + return of(type, ValueKind.PORTFOLIO, value); + } + + public static LedgerParameter ofBoolean(LedgerParameterType type, Boolean value) + { + return of(type, ValueKind.BOOLEAN, value); + } + + public static LedgerParameter ofLocalDate(LedgerParameterType type, LocalDate value) + { + return of(type, ValueKind.LOCAL_DATE, value); + } + + public static LedgerParameter ofLocalDateTime(LedgerParameterType type, + LocalDateTime value) + { + return of(type, ValueKind.LOCAL_DATE_TIME, value); + } + + private static LedgerParameter of(LedgerParameterType type, ValueKind valueKind, T value) + { + Objects.requireNonNull(type).requireValueKind(valueKind); + return unchecked(type, valueKind, value); + } + + static LedgerParameter unchecked(LedgerParameterType type, ValueKind valueKind, T value) + { + return new LedgerParameter<>(type, valueKind, value); + } + + public LedgerParameterType getType() + { + return type; + } + + public ValueKind getValueKind() + { + return valueKind; + } + + public T getValue() + { + return value; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPosting.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPosting.java new file mode 100644 index 0000000000..29d020c935 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPosting.java @@ -0,0 +1,236 @@ +package name.abuchen.portfolio.model.ledger; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.LedgerPostingType; + +/** + * Represents one persisted posting inside a Ledger entry. + * This is an internal Ledger model type for cash, security, fee, tax, and related facts. + * Contributor code should write postings through Ledger creators, editors, or converters. + */ +public class LedgerPosting +{ + private String uuid; + private LedgerPostingType type; + private long amount; + private String currency; + private Long forexAmount; + private String forexCurrency; + private BigDecimal exchangeRate; + private Security security; + private long shares; + private Account account; + private Portfolio portfolio; + private LedgerPostingSemanticRole semanticRole; + private LedgerPostingDirection direction; + private CorporateActionLeg corporateActionLeg; + private LedgerPostingUnitRole unitRole; + private String groupKey; + private String localKey; + private final List> parameters = new ArrayList<>(); + + public LedgerPosting() + { + this(UUID.randomUUID().toString()); + } + + public LedgerPosting(String uuid) + { + this.uuid = Objects.requireNonNull(uuid); + } + + public String getUUID() + { + return uuid; + } + + public void setUUID(String uuid) + { + this.uuid = Objects.requireNonNull(uuid); + } + + public LedgerPostingType getType() + { + return type; + } + + public void setType(LedgerPostingType type) + { + this.type = type; + } + + public long getAmount() + { + return amount; + } + + public void setAmount(long amount) + { + this.amount = amount; + } + + public String getCurrency() + { + return currency; + } + + public void setCurrency(String currency) + { + this.currency = currency; + } + + public Long getForexAmount() + { + return forexAmount; + } + + public void setForexAmount(Long forexAmount) + { + this.forexAmount = forexAmount; + } + + public String getForexCurrency() + { + return forexCurrency; + } + + public void setForexCurrency(String forexCurrency) + { + this.forexCurrency = forexCurrency; + } + + public BigDecimal getExchangeRate() + { + return exchangeRate; + } + + public void setExchangeRate(BigDecimal exchangeRate) + { + this.exchangeRate = exchangeRate; + } + + public Security getSecurity() + { + return security; + } + + public void setSecurity(Security security) + { + this.security = security; + } + + public long getShares() + { + return shares; + } + + public void setShares(long shares) + { + this.shares = shares; + } + + public Account getAccount() + { + return account; + } + + public void setAccount(Account account) + { + this.account = account; + } + + public Portfolio getPortfolio() + { + return portfolio; + } + + public void setPortfolio(Portfolio portfolio) + { + this.portfolio = portfolio; + } + + public LedgerPostingSemanticRole getSemanticRole() + { + return semanticRole; + } + + public void setSemanticRole(LedgerPostingSemanticRole semanticRole) + { + this.semanticRole = semanticRole; + } + + public LedgerPostingDirection getDirection() + { + return direction; + } + + public void setDirection(LedgerPostingDirection direction) + { + this.direction = direction; + } + + public CorporateActionLeg getCorporateActionLeg() + { + return corporateActionLeg; + } + + public void setCorporateActionLeg(CorporateActionLeg corporateActionLeg) + { + this.corporateActionLeg = corporateActionLeg; + } + + public LedgerPostingUnitRole getUnitRole() + { + return unitRole; + } + + public void setUnitRole(LedgerPostingUnitRole unitRole) + { + this.unitRole = unitRole; + } + + public String getGroupKey() + { + return groupKey; + } + + public void setGroupKey(String groupKey) + { + this.groupKey = groupKey; + } + + public String getLocalKey() + { + return localKey; + } + + public void setLocalKey(String localKey) + { + this.localKey = localKey; + } + + public List> getParameters() + { + return Collections.unmodifiableList(parameters); + } + + public void addParameter(LedgerParameter parameter) + { + parameters.add(Objects.requireNonNull(parameter)); + } + + public boolean removeParameter(LedgerParameter parameter) + { + return parameters.remove(parameter); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingDirection.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingDirection.java new file mode 100644 index 0000000000..d7959b7f83 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingDirection.java @@ -0,0 +1,13 @@ +package name.abuchen.portfolio.model.ledger; + +/** + * Describes the semantic movement direction of a Ledger posting. + * It is additive metadata for future projection derivation and does not replace + * derived descriptors in the current materialization path. + */ +public enum LedgerPostingDirection +{ + INBOUND, + OUTBOUND, + NEUTRAL +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingSemanticRole.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingSemanticRole.java new file mode 100644 index 0000000000..bb78dc75c0 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingSemanticRole.java @@ -0,0 +1,20 @@ +package name.abuchen.portfolio.model.ledger; + +/** + * Names the business role a posting plays inside an entry. + * This vocabulary is intentionally separate from persisted projection identity. + */ +public enum LedgerPostingSemanticRole +{ + CASH, + SECURITY, + RIGHT, + BOND, + CASH_COMPENSATION, + ACCRUED_INTEREST, + PRINCIPAL_REDEMPTION, + FEE, + TAX, + GROSS_VALUE, + FOREX_CONTEXT +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingUnitRole.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingUnitRole.java new file mode 100644 index 0000000000..0a2d38ccef --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerPostingUnitRole.java @@ -0,0 +1,14 @@ +package name.abuchen.portfolio.model.ledger; + +/** + * Describes how a posting contributes to a generated compatibility transaction + * unit once projections are derived from posting semantics. + */ +public enum LedgerPostingUnitRole +{ + PRIMARY, + FEE, + TAX, + GROSS_VALUE, + FOREX_CONTEXT +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRole.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRole.java new file mode 100644 index 0000000000..0e7438f708 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerProjectionRole.java @@ -0,0 +1,28 @@ +package name.abuchen.portfolio.model.ledger; + +/** + * Defines the role of a runtime projection for a Ledger entry. + * This is internal Ledger model metadata used by projection and compatibility code. + * Contributor code should not invent roles outside the configured Ledger write paths. + * + *

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

+ */ +public enum LedgerProjectionRole +{ + ACCOUNT, + PORTFOLIO, + SOURCE_ACCOUNT, + TARGET_ACCOUNT, + SOURCE_PORTFOLIO, + TARGET_PORTFOLIO, + DELIVERY, + DELIVERY_INBOUND, + DELIVERY_OUTBOUND, + CASH_COMPENSATION, + OLD_SECURITY_LEG, + NEW_SECURITY_LEG +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java new file mode 100644 index 0000000000..824eb8ff2a --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerStructuralValidator.java @@ -0,0 +1,857 @@ +package name.abuchen.portfolio.model.ledger; + +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import name.abuchen.portfolio.Messages; +import name.abuchen.portfolio.model.Account; +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.Portfolio; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionLeg; +import name.abuchen.portfolio.model.ledger.configuration.CorporateActionKind; +import name.abuchen.portfolio.model.ledger.configuration.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; + +/** + * Validates the structural consistency of Ledger entries. + * This class checks Ledger facts and semantic projection descriptors. It does not apply business + * repairs or guess missing transaction data. + */ +public final class LedgerStructuralValidator +{ + public enum IssueCode + { + LEDGER_REQUIRED, + ENTRY_TYPE_REQUIRED, + ENTRY_DATE_TIME_REQUIRED, + POSTING_TYPE_REQUIRED, + POSTING_CURRENCY_REQUIRED, + POSTING_SECURITY_REQUIRED, + POSTING_PORTFOLIO_REQUIRED_FOR_SECURITY, + POSTING_EXCHANGE_RATE_POSITIVE, + DIVIDEND_SECURITY_REQUIRED, + PARAMETER_TYPE_REQUIRED, + PARAMETER_VALUE_KIND_REQUIRED, + PARAMETER_VALUE_REQUIRED, + PARAMETER_VALUE_KIND_MISMATCH, + PARAMETER_CODE_NOT_ALLOWED, + EX_DATE_VALUE_KIND_REQUIRED, + EX_DATE_SECURITY_REQUIRED, + SIGNED_FACT_NOT_ALLOWED, + SEMANTIC_PRIMARY_REQUIRED, + SEMANTIC_PRIMARY_AMBIGUOUS, + SEMANTIC_SOURCE_REQUIRED, + SEMANTIC_TARGET_REQUIRED, + SEMANTIC_SOURCE_AMBIGUOUS, + SEMANTIC_TARGET_AMBIGUOUS, + SEMANTIC_OWNER_REQUIRED, + SEMANTIC_UNIT_GROUP_REQUIRED, + SEMANTIC_UNIT_GROUP_AMBIGUOUS, + SEMANTIC_LOCAL_KEY_REQUIRED, + CORPORATE_ACTION_LEG_REQUIRED, + CORPORATE_ACTION_LEG_AMBIGUOUS + } + + private LedgerStructuralValidator() + { + } + + public static ValidationResult validate(Ledger ledger) + { + var issues = new ArrayList(); + + if (ledger == null) + { + issues.add(new ValidationIssue(IssueCode.LEDGER_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_001 + .message(Messages.LedgerStructuralValidatorLedgerRequired))); + return new ValidationResult(issues); + } + + validateEntries(ledger, issues); + + return new ValidationResult(issues); + } + + private static void validateEntries(Ledger ledger, List issues) + { + for (var entry : ledger.getEntries()) + { + if (entry.getType() == null) + issues.add(entryIssue(IssueCode.ENTRY_TYPE_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_007 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorEntryTypeRequired, + entry.getUUID())), + entry)); + + if (entry.getDateTime() == null) + issues.add(entryIssue(IssueCode.ENTRY_DATE_TIME_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_008.message(MessageFormat.format( + Messages.LedgerStructuralValidatorEntryDateTimeRequired, + entry.getUUID())), + entry)); + + validateParameters(entry, null, entry.getParameters(), issues); + + validatePostings(entry, issues); + } + } + + private static void validatePostings(LedgerEntry entry, List issues) + { + for (var posting : entry.getPostings()) + { + if (posting.getType() == null) + issues.add(postingIssue(IssueCode.POSTING_TYPE_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_011.message(MessageFormat.format( + Messages.LedgerStructuralValidatorPostingTypeRequired, + posting.getUUID())), + entry, posting)); + + if (entry.getType() != null && !entry.getType().usesSignedTargetedProjectionFacts() + && (posting.getAmount() < 0 || posting.getShares() < 0)) + issues.add(postingIssue(IssueCode.SIGNED_FACT_NOT_ALLOWED, + LedgerDiagnosticCode.LEDGER_STRUCT_012.message(MessageFormat.format( + Messages.LedgerStructuralValidatorSignedFactsNotAllowed, + entry.getType())), + entry, + posting)); + + validatePostingShape(entry, posting, issues); + validateParameters(entry, posting, posting.getParameters(), issues); + } + + validateEntryPostingShape(entry, issues); + validateSemanticShape(entry, issues); + } + + private static void validateEntryPostingShape(LedgerEntry entry, List issues) + { + if (entry.getType() == LedgerEntryType.DIVIDENDS + && entry.getPostings().stream().noneMatch(posting -> posting.getSecurity() != null)) + issues.add(entryIssue(IssueCode.DIVIDEND_SECURITY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_013 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorDividendSecurityRequired, + entry.getUUID())), + entry)); + } + + private static void validatePostingShape(LedgerEntry entry, LedgerPosting posting, List issues) + { + if (posting.getType() == null) + return; + + if (posting.getType().requiresCurrency() && isBlank(posting.getCurrency())) + issues.add(postingIssue(IssueCode.POSTING_CURRENCY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_014.message(MessageFormat.format( + Messages.LedgerStructuralValidatorPostingCurrencyRequired, + posting.getUUID())), + entry, posting)); + + if (posting.getType().requiresSecurity() && posting.getSecurity() == null) + issues.add(postingIssue(IssueCode.POSTING_SECURITY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_015 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorPostingSecurityRequired, + posting.getUUID())), + entry, posting)); + + if (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( + Messages.LedgerStructuralValidatorPostingExchangeRatePositive, + posting.getUUID())), + entry, posting)); + } + + private static void validateSemanticShape(LedgerEntry entry, List issues) + { + if (entry.getType() == null) + return; + + switch (entry.getType()) + { + case DEPOSIT, REMOVAL, INTEREST, INTEREST_CHARGE, DIVIDENDS -> + requirePrimary(entry, LedgerProjectionRole.ACCOUNT, LedgerPostingSemanticRole.CASH, + LedgerPostingDirection.NEUTRAL, OwnerKind.ACCOUNT, null, false, issues); + case FEES, FEES_REFUND -> requirePrimary(entry, LedgerProjectionRole.ACCOUNT, LedgerPostingSemanticRole.FEE, + LedgerPostingDirection.NEUTRAL, OwnerKind.ACCOUNT, null, false, issues); + case TAXES, TAX_REFUND -> requirePrimary(entry, LedgerProjectionRole.ACCOUNT, LedgerPostingSemanticRole.TAX, + LedgerPostingDirection.NEUTRAL, OwnerKind.ACCOUNT, null, false, issues); + case BUY -> { + requirePrimary(entry, LedgerProjectionRole.ACCOUNT, LedgerPostingSemanticRole.CASH, + LedgerPostingDirection.OUTBOUND, OwnerKind.ACCOUNT, null, false, issues); + requirePrimary(entry, LedgerProjectionRole.PORTFOLIO, LedgerPostingSemanticRole.SECURITY, + LedgerPostingDirection.INBOUND, OwnerKind.PORTFOLIO, null, false, issues); + } + case SELL -> { + requirePrimary(entry, LedgerProjectionRole.ACCOUNT, LedgerPostingSemanticRole.CASH, + LedgerPostingDirection.INBOUND, OwnerKind.ACCOUNT, null, false, issues); + requirePrimary(entry, LedgerProjectionRole.PORTFOLIO, LedgerPostingSemanticRole.SECURITY, + LedgerPostingDirection.OUTBOUND, OwnerKind.PORTFOLIO, null, false, issues); + } + case DELIVERY_INBOUND -> requirePrimary(entry, LedgerProjectionRole.DELIVERY_INBOUND, + LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.INBOUND, OwnerKind.PORTFOLIO, + null, false, issues); + case DELIVERY_OUTBOUND -> requirePrimary(entry, LedgerProjectionRole.DELIVERY_OUTBOUND, + LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.OUTBOUND, OwnerKind.PORTFOLIO, + null, false, issues); + case CASH_TRANSFER -> { + requirePrimary(entry, LedgerProjectionRole.SOURCE_ACCOUNT, LedgerPostingSemanticRole.CASH, + LedgerPostingDirection.OUTBOUND, OwnerKind.ACCOUNT, null, false, issues); + requirePrimary(entry, LedgerProjectionRole.TARGET_ACCOUNT, LedgerPostingSemanticRole.CASH, + LedgerPostingDirection.INBOUND, OwnerKind.ACCOUNT, null, false, issues); + } + case SECURITY_TRANSFER -> { + requirePrimary(entry, LedgerProjectionRole.SOURCE_PORTFOLIO, LedgerPostingSemanticRole.SECURITY, + LedgerPostingDirection.OUTBOUND, OwnerKind.PORTFOLIO, null, false, issues); + requirePrimary(entry, LedgerProjectionRole.TARGET_PORTFOLIO, LedgerPostingSemanticRole.SECURITY, + LedgerPostingDirection.INBOUND, OwnerKind.PORTFOLIO, null, false, issues); + } + case CORPORATE_ACTION -> validateCorporateActionSemanticShape(entry, issues); + default -> { + // No semantic shape rule. + } + } + + validateUnitGrouping(entry, issues); + } + + private static void validateCorporateActionSemanticShape(LedgerEntry entry, List issues) + { + var kind = CorporateActionKind.fromEntry(entry); + + if (kind.filter(CorporateActionKind.SPIN_OFF::equals).isEmpty()) + return; + + requireRepeatableCorporatePrimary(entry, LedgerProjectionRole.OLD_SECURITY_LEG, + LedgerPostingSemanticRole.SECURITY, CorporateActionLeg.SOURCE_SECURITY, + LedgerPostingDirection.OUTBOUND, true, issues); + requireRepeatableCorporatePrimary(entry, LedgerProjectionRole.NEW_SECURITY_LEG, + LedgerPostingSemanticRole.SECURITY, CorporateActionLeg.TARGET_SECURITY, + LedgerPostingDirection.INBOUND, true, issues, LedgerProjectionRole.DELIVERY_INBOUND); + requireRepeatableCorporatePrimary(entry, LedgerProjectionRole.CASH_COMPENSATION, + LedgerPostingSemanticRole.CASH_COMPENSATION, CorporateActionLeg.CASH_COMPENSATION, + LedgerPostingDirection.NEUTRAL, true, issues); + } + + private static void requireRepeatableCorporatePrimary(LedgerEntry entry, LedgerProjectionRole role, + LedgerPostingSemanticRole semanticRole, CorporateActionLeg leg, LedgerPostingDirection direction, + boolean optional, List issues, LedgerProjectionRole... excludedLocalKeys) + { + var matches = entry.getPostings().stream() // + .filter(posting -> matchesPrimary(posting, semanticRole, direction, leg)) // + .filter(posting -> !hasExcludedLocalKey(posting, excludedLocalKeys)) // + .toList(); + + validateRepeatablePrimaryMatches(entry, role, OwnerKind.PORTFOLIO_OR_ACCOUNT, optional, matches, issues); + } + + private static boolean hasExcludedLocalKey(LedgerPosting posting, LedgerProjectionRole... excludedLocalKeys) + { + for (var role : excludedLocalKeys) + if (role.name().equals(posting.getLocalKey())) + return true; + + return false; + } + + private static void requirePrimary(LedgerEntry entry, LedgerProjectionRole role, + LedgerPostingSemanticRole semanticRole, LedgerPostingDirection direction, OwnerKind ownerKind, + CorporateActionLeg leg, boolean optional, List issues) + { + var matches = entry.getPostings().stream() // + .filter(posting -> matchesPrimary(posting, semanticRole, direction, leg)) // + .toList(); + + validatePrimaryMatches(entry, role, ownerKind, optional, matches, issues); + } + + private static void validatePrimaryMatches(LedgerEntry entry, LedgerProjectionRole role, OwnerKind ownerKind, + boolean optional, List matches, List issues) + { + if (matches.isEmpty()) + { + if (!optional) + issues.add(entryIssue(missingIssueCode(role), + LedgerDiagnosticCode.LEDGER_STRUCT_016 + .message("Required semantic primary posting is missing for " + role), //$NON-NLS-1$ + entry).withDetail("projectionRole", role)); //$NON-NLS-1$ + return; + } + + if (matches.size() > 1) + issues.add(entryIssue(ambiguousIssueCode(role), + LedgerDiagnosticCode.LEDGER_STRUCT_017 + .message("Semantic primary posting is ambiguous for " + role), //$NON-NLS-1$ + entry).withDetail("projectionRole", role) //$NON-NLS-1$ + .withDetail("actualCount", matches.size())); //$NON-NLS-1$ + + for (var posting : matches) + validateOwner(entry, role, posting, ownerKind, issues); + } + + private static void validateRepeatablePrimaryMatches(LedgerEntry entry, LedgerProjectionRole role, + OwnerKind ownerKind, boolean optional, List matches, List issues) + { + if (matches.isEmpty()) + { + if (!optional) + issues.add(entryIssue(missingIssueCode(role), + LedgerDiagnosticCode.LEDGER_STRUCT_016 + .message("Required semantic primary posting is missing for " + role), //$NON-NLS-1$ + entry).withDetail("projectionRole", role)); //$NON-NLS-1$ + return; + } + + if (matches.size() > 1) + validateRepeatablePrimaryLocalKeys(entry, role, matches, issues); + + for (var posting : matches) + validateOwner(entry, role, posting, ownerKind, issues); + } + + private static void validateRepeatablePrimaryLocalKeys(LedgerEntry entry, LedgerProjectionRole role, + List matches, List issues) + { + var localKeys = new HashSet(); + + for (var posting : matches) + { + if (isBlank(posting.getLocalKey())) + { + issues.add(postingIssue(IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_027 + .message("Repeated semantic primary posting requires a local key for " //$NON-NLS-1$ + + role), + entry, posting).withDetail("projectionRole", role)); //$NON-NLS-1$ + } + else if (!localKeys.add(posting.getLocalKey())) + { + issues.add(entryIssue(ambiguousIssueCode(role), + LedgerDiagnosticCode.LEDGER_STRUCT_017 + .message("Repeated semantic primary posting local key is duplicated for " //$NON-NLS-1$ + + role), + entry).withDetail("projectionRole", role) //$NON-NLS-1$ + .withDetail("localKey", posting.getLocalKey())); //$NON-NLS-1$ + } + } + } + + private static IssueCode missingIssueCode(LedgerProjectionRole role) + { + return switch (role) + { + case SOURCE_ACCOUNT, SOURCE_PORTFOLIO, OLD_SECURITY_LEG -> IssueCode.SEMANTIC_SOURCE_REQUIRED; + case TARGET_ACCOUNT, TARGET_PORTFOLIO, NEW_SECURITY_LEG -> IssueCode.SEMANTIC_TARGET_REQUIRED; + default -> IssueCode.SEMANTIC_PRIMARY_REQUIRED; + }; + } + + private static IssueCode ambiguousIssueCode(LedgerProjectionRole role) + { + return switch (role) + { + case SOURCE_ACCOUNT, SOURCE_PORTFOLIO, OLD_SECURITY_LEG -> IssueCode.SEMANTIC_SOURCE_AMBIGUOUS; + case TARGET_ACCOUNT, TARGET_PORTFOLIO, NEW_SECURITY_LEG -> IssueCode.SEMANTIC_TARGET_AMBIGUOUS; + default -> IssueCode.SEMANTIC_PRIMARY_AMBIGUOUS; + }; + } + + private static boolean matchesPrimary(LedgerPosting posting, LedgerPostingSemanticRole semanticRole, + LedgerPostingDirection direction, CorporateActionLeg leg) + { + return posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY && posting.getSemanticRole() == semanticRole + && posting.getDirection() == direction + && (leg == null || posting.getCorporateActionLeg() == leg); + } + + private static void validateOwner(LedgerEntry entry, LedgerProjectionRole role, LedgerPosting posting, + OwnerKind ownerKind, List issues) + { + var ownerPresent = switch (ownerKind) + { + case ACCOUNT -> posting.getAccount() != null; + case PORTFOLIO -> posting.getPortfolio() != null; + case PORTFOLIO_OR_ACCOUNT -> posting.getAccount() != null || posting.getPortfolio() != null; + }; + + if (!ownerPresent) + issues.add(postingIssue(IssueCode.SEMANTIC_OWNER_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_018 + .message("Semantic primary owner is missing for " + role), //$NON-NLS-1$ + entry, posting).withDetail("projectionRole", role)); //$NON-NLS-1$ + } + + private static void validateUnitGrouping(LedgerEntry entry, List issues) + { + var primaryGroupKeys = entry.getPostings().stream() // + .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY) // + .map(LedgerPosting::getGroupKey) // + .filter(groupKey -> !isBlank(groupKey)) // + .collect(java.util.stream.Collectors.toSet()); + var primaryCount = entry.getPostings().stream() + .filter(posting -> posting.getUnitRole() == LedgerPostingUnitRole.PRIMARY).count(); + var repeatedUnitKeys = new HashSet(); + var seenUnitKeys = new HashSet(); + + for (var posting : entry.getPostings()) + { + if (!isUnit(posting)) + continue; + + if (primaryCount > 1 && primaryGroupKeys.size() > 1 && isBlank(posting.getGroupKey())) + issues.add(postingIssue(IssueCode.SEMANTIC_UNIT_GROUP_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_019 + .message("Grouped unit posting requires a group key"), //$NON-NLS-1$ + entry, posting)); + + if (!primaryGroupKeys.isEmpty() && !isBlank(posting.getGroupKey()) + && !primaryGroupKeys.contains(posting.getGroupKey())) + issues.add(postingIssue(IssueCode.SEMANTIC_UNIT_GROUP_AMBIGUOUS, + LedgerDiagnosticCode.LEDGER_STRUCT_020 + .message("Unit posting group key has no semantic primary anchor"), //$NON-NLS-1$ + entry, posting).withDetail("groupKey", posting.getGroupKey())); //$NON-NLS-1$ + + var key = posting.getUnitRole() + ":" + posting.getGroupKey(); //$NON-NLS-1$ + if (!seenUnitKeys.add(key)) + repeatedUnitKeys.add(key); + } + + for (var posting : entry.getPostings()) + { + if (!isUnit(posting)) + continue; + + var key = posting.getUnitRole() + ":" + posting.getGroupKey(); //$NON-NLS-1$ + if (repeatedUnitKeys.contains(key) && isBlank(posting.getLocalKey())) + issues.add(postingIssue(IssueCode.SEMANTIC_LOCAL_KEY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_021 + .message("Repeated unit posting requires a local key"), //$NON-NLS-1$ + entry, posting).withDetail("groupKey", posting.getGroupKey()) //$NON-NLS-1$ + .withDetail("unitRole", posting.getUnitRole())); //$NON-NLS-1$ + } + } + + private static boolean isUnit(LedgerPosting posting) + { + var unitRole = posting.getUnitRole(); + return unitRole == LedgerPostingUnitRole.FEE || unitRole == LedgerPostingUnitRole.TAX + || unitRole == LedgerPostingUnitRole.GROSS_VALUE + || unitRole == LedgerPostingUnitRole.FOREX_CONTEXT; + } + + private static void validateParameters(LedgerEntry entry, LedgerPosting posting, + List> parameters, List issues) + { + var owner = parameterOwnerDescription(entry, posting); + + for (var parameter : parameters) + { + if (parameter.getType() == null) + issues.add(parameterIssue(IssueCode.PARAMETER_TYPE_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_028 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorParameterTypeRequired, + owner)), + entry, posting, + parameter)); + + if (parameter.getValueKind() == null) + { + issues.add(parameterIssue(IssueCode.PARAMETER_VALUE_KIND_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_029.message(MessageFormat.format( + Messages.LedgerStructuralValidatorParameterValueKindRequired, + owner)), + entry, + posting, parameter)); + continue; + } + + if (parameter.getValue() == null) + issues.add(parameterIssue(IssueCode.PARAMETER_VALUE_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_030 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorParameterValueRequired, + owner)), + entry, posting, + parameter)); + + if (parameter.getType() != null && !parameter.getType().supportsValueKind(parameter.getValueKind())) + issues.add(parameterValueKindIssue(entry, posting, parameter)); + + if (posting != null && parameter.getType() == LedgerParameterType.EX_DATE + && posting.getSecurity() == null) + issues.add(parameterIssue(IssueCode.EX_DATE_SECURITY_REQUIRED, + LedgerDiagnosticCode.LEDGER_STRUCT_031 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorExDateSecurityRequired, + posting.getUUID())), + entry, posting, + parameter)); + + if (parameter.getValue() != null && !isCompatible(parameter)) + issues.add(parameterIssue(IssueCode.PARAMETER_VALUE_KIND_MISMATCH, + LedgerDiagnosticCode.LEDGER_STRUCT_032.message(MessageFormat.format( + Messages.LedgerStructuralValidatorParameterValueKindMismatch, + parameter.getValueKind())), + entry, posting, parameter)); + + if (hasUnsupportedCode(parameter)) + issues.add(parameterIssue(IssueCode.PARAMETER_CODE_NOT_ALLOWED, + LedgerDiagnosticCode.LEDGER_STRUCT_033.message(MessageFormat.format( + Messages.LedgerStructuralValidatorParameterCodeNotAllowed, + parameter.getType())), + entry, posting, + parameter).withDetail("codeDomain", parameter.getType().getCodeDomain()) //$NON-NLS-1$ + .withDetail("allowedCodes", //$NON-NLS-1$ + parameter.getType().getCodeDomain().getAllowedCodes())); + } + } + + private static String parameterOwnerDescription(LedgerEntry entry, LedgerPosting posting) + { + if (posting != null) + return "posting " + posting.getUUID(); //$NON-NLS-1$ + + return "entry " + entry.getUUID(); //$NON-NLS-1$ + } + + private static boolean isCompatible(LedgerParameter parameter) + { + return parameter.getValueKind().supportsValue(parameter.getValue()); + } + + private static boolean hasUnsupportedCode(LedgerParameter parameter) + { + return parameter.getType() != null && parameter.getType().hasCodeDomain() + && parameter.getType().supportsValueKind(parameter.getValueKind()) && isCompatible(parameter) + && !parameter.getType().supportsCode((String) parameter.getValue()); + } + + private static boolean isBlank(String value) + { + return value == null || value.isBlank(); + } + + private static ValidationIssue entryIssue(IssueCode code, String message, LedgerEntry entry) + { + return new ValidationIssue(code, message).withEntry(entry); + } + + private static ValidationIssue postingIssue(IssueCode code, String message, LedgerEntry entry, + LedgerPosting posting) + { + return entryIssue(code, message, entry).withPosting(posting); + } + + private static ValidationIssue parameterIssue(IssueCode code, String message, LedgerEntry entry, + LedgerPosting posting, LedgerParameter parameter) + { + return postingIssue(code, message, entry, posting).withParameter(parameter); + } + + private static ValidationIssue parameterValueKindIssue(LedgerEntry entry, LedgerPosting posting, + LedgerParameter parameter) + { + var type = parameter.getType(); + var code = type == LedgerParameterType.EX_DATE ? IssueCode.EX_DATE_VALUE_KIND_REQUIRED + : IssueCode.PARAMETER_VALUE_KIND_MISMATCH; + + return parameterIssue(code, + LedgerDiagnosticCode.LEDGER_STRUCT_034 + .message(MessageFormat.format( + Messages.LedgerStructuralValidatorParameterMustUseValueKind, + type, type.getExpectedValueKind())), + entry, posting, parameter) + .withDetail("expectedValueKind", type.getExpectedValueKind()) //$NON-NLS-1$ + .withDetail("actualValueKind", parameter.getValueKind()); //$NON-NLS-1$ + } + + private static String ownerSummary(Account account) + { + if (account == null) + return null; + + return summary(account.getName(), account.getUUID()); + } + + private static String ownerSummary(Portfolio portfolio) + { + if (portfolio == null) + return null; + + return summary(portfolio.getName(), portfolio.getUUID()); + } + + private static String securitySummary(Security security) + { + if (security == null) + return null; + + return summary(security.getName(), security.getUUID()); + } + + private static String summary(String name, String uuid) + { + var displayName = isBlank(name) ? "" : name; //$NON-NLS-1$ + var displayUUID = isBlank(uuid) ? "" : uuid; //$NON-NLS-1$ + + return displayName + " (" + displayUUID + ")"; //$NON-NLS-1$ //$NON-NLS-2$ + } + + private static String valueSummary(Object value) + { + if (value == null) + return null; + + if (value instanceof Account account) + return ownerSummary(account); + + if (value instanceof Portfolio portfolio) + return ownerSummary(portfolio); + + if (value instanceof Security security) + return securitySummary(security); + + var summary = String.valueOf(value); + + return summary.length() > 120 ? summary.substring(0, 117) + "..." : summary; //$NON-NLS-1$ + } + + public static final class ValidationResult + { + private final List issues; + + private ValidationResult(List issues) + { + this.issues = List.copyOf(issues); + } + + public boolean isOK() + { + return issues.isEmpty(); + } + + public List getIssues() + { + return Collections.unmodifiableList(issues); + } + + public boolean hasIssue(IssueCode code) + { + return issues.stream().anyMatch(issue -> issue.getCode() == code); + } + + public String format() + { + if (issues.isEmpty()) + return "OK"; //$NON-NLS-1$ + + var builder = new StringBuilder(); + + for (var issue : issues) + { + if (!builder.isEmpty()) + builder.append("\n\n"); //$NON-NLS-1$ + + builder.append(issue.format()); + } + + return builder.toString(); + } + + @Override + public String toString() + { + return format(); + } + } + + public static final class ValidationIssue + { + private final IssueCode code; + private final String message; + private final Map details = new LinkedHashMap<>(); + + private ValidationIssue(IssueCode code, String message) + { + this.code = code; + this.message = message; + } + + public IssueCode getCode() + { + return code; + } + + public String getMessage() + { + return message; + } + + public Map getDetails() + { + return Collections.unmodifiableMap(details); + } + + public String format() + { + if (details.isEmpty()) + return "[" + code + "] " + message; //$NON-NLS-1$ //$NON-NLS-2$ + + var builder = new StringBuilder(); + + builder.append("[").append(code).append("] ").append(message); //$NON-NLS-1$ //$NON-NLS-2$ + appendGroup(builder, "Entry", //$NON-NLS-1$ + detail("UUID", "entryUUID"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Type", "entryType"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("DateTime", "entryDateTime")); //$NON-NLS-1$ //$NON-NLS-2$ + appendGroup(builder, "Posting", //$NON-NLS-1$ + detail("UUID", "postingUUID"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Type", "postingType"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Amount", "amount"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Currency", "currency"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Shares", "shares"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("ExchangeRate", "exchangeRate"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Security", "security"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Account", "postingAccount"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Portfolio", "postingPortfolio")); //$NON-NLS-1$ //$NON-NLS-2$ + appendGroup(builder, "Parameter", //$NON-NLS-1$ + detail("Type", "parameterType"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("ExpectedValueKind", "expectedValueKind"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("ValueKind", "actualValueKind"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("ValueType", "actualValueType"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("Value", "actualValue"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("CodeDomain", "codeDomain"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("AllowedCodes", "allowedCodes")); //$NON-NLS-1$ //$NON-NLS-2$ + appendGroup(builder, "Duplicate", //$NON-NLS-1$ + detail("ObjectType", "objectType"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("DuplicateUUID", "duplicateUUID"), //$NON-NLS-1$ //$NON-NLS-2$ + detail("OccurrenceCount", "occurrenceCount")); //$NON-NLS-1$ //$NON-NLS-2$ + + return builder.toString(); + } + + @Override + public String toString() + { + return format(); + } + + private ValidationIssue withEntry(LedgerEntry entry) + { + if (entry == null) + return this; + + return withDetail("entryUUID", entry.getUUID()) //$NON-NLS-1$ + .withDetail("entryType", entry.getType()) //$NON-NLS-1$ + .withDetail("entryDateTime", entry.getDateTime()); //$NON-NLS-1$ + } + + private ValidationIssue withPosting(LedgerPosting posting) + { + if (posting == null) + return this; + + return withDetail("postingUUID", posting.getUUID()) //$NON-NLS-1$ + .withDetail("postingType", posting.getType()) //$NON-NLS-1$ + .withDetail("amount", posting.getAmount()) //$NON-NLS-1$ + .withDetail("currency", posting.getCurrency()) //$NON-NLS-1$ + .withDetail("shares", posting.getShares()) //$NON-NLS-1$ + .withDetail("exchangeRate", posting.getExchangeRate()) //$NON-NLS-1$ + .withDetail("security", securitySummary(posting.getSecurity())) //$NON-NLS-1$ + .withDetail("postingAccount", ownerSummary(posting.getAccount())) //$NON-NLS-1$ + .withDetail("postingPortfolio", ownerSummary(posting.getPortfolio())); //$NON-NLS-1$ + } + + private ValidationIssue withParameter(LedgerParameter parameter) + { + if (parameter == null) + return this; + + var value = parameter.getValue(); + + return withDetail("parameterType", parameter.getType()) //$NON-NLS-1$ + .withDetail("actualValueKind", parameter.getValueKind()) //$NON-NLS-1$ + .withDetail("actualValueType", value == null ? null : value.getClass().getSimpleName()) //$NON-NLS-1$ + .withDetail("actualValue", valueSummary(value)); //$NON-NLS-1$ + } + + private ValidationIssue withDetail(String key, Object value) + { + details.put(key, detailValue(value)); + + return this; + } + + private Detail detail(String label, String key) + { + return new Detail(label, key); + } + + private void appendGroup(StringBuilder builder, String group, Detail... groupDetails) + { + var hasDetails = false; + + for (var groupDetail : groupDetails) + { + if (details.containsKey(groupDetail.key)) + { + hasDetails = true; + break; + } + } + + if (!hasDetails) + return; + + builder.append("\n\n ").append(group).append(":"); //$NON-NLS-1$ //$NON-NLS-2$ + + for (var groupDetail : groupDetails) + { + if (details.containsKey(groupDetail.key)) + builder.append("\n ").append(groupDetail.label).append(": ") //$NON-NLS-1$ //$NON-NLS-2$ + .append(details.get(groupDetail.key)); + } + } + + private String detailValue(Object value) + { + if (value == null) + return ""; //$NON-NLS-1$ + + var string = String.valueOf(value); + + return string.isBlank() ? "" : string; //$NON-NLS-1$ + } + } + + private record Detail(String label, String key) + { + } + + private enum OwnerKind + { + ACCOUNT, + PORTFOLIO, + PORTFOLIO_OR_ACCOUNT + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerTransactionMetadata.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerTransactionMetadata.java new file mode 100644 index 0000000000..a608ce53a1 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/LedgerTransactionMetadata.java @@ -0,0 +1,53 @@ +package name.abuchen.portfolio.model.ledger; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Carries common transaction metadata for Ledger creator and editor calls. + * This is compatibility-layer input data for date, note, and source values. It is not a + * direct Ledger mutation API. + */ +public final class LedgerTransactionMetadata +{ + private final LocalDateTime dateTime; + private final String note; + private final String source; + + private LedgerTransactionMetadata(LocalDateTime dateTime, String note, String source) + { + this.dateTime = Objects.requireNonNull(dateTime); + this.note = note; + this.source = source; + } + + public static LedgerTransactionMetadata of(LocalDateTime dateTime) + { + return new LedgerTransactionMetadata(dateTime, null, null); + } + + public LedgerTransactionMetadata withNote(String note) + { + return new LedgerTransactionMetadata(dateTime, note, source); + } + + public LedgerTransactionMetadata withSource(String source) + { + return new LedgerTransactionMetadata(dateTime, note, source); + } + + public LocalDateTime getDateTime() + { + return dateTime; + } + + public String getNote() + { + return note; + } + + public String getSource() + { + return source; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/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..9f3749ba7c --- /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.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; +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.getLedgerProjectionDescriptor().getAccount() != account) + { + var projectionUUID = ledgerTransaction.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.getRuntimeProjectionId(name.abuchen.portfolio.model.ledger.LedgerProjectionRole.ACCOUNT); + + 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..d13e853c80 --- /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 role = transaction.getLedgerProjectionRole(); + var postingIndex = LedgerEntryEditSupport.postingIndex(entry, + transaction.getLedgerProjectionDescriptor().getPrimaryPosting()); + + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingIndex)); + } + + 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 role = transaction.getLedgerProjectionRole(); + var postingIndex = LedgerEntryEditSupport.postingIndex(entry, + transaction.getLedgerProjectionDescriptor().getPrimaryPosting()); + + LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingIndex)); + } + + private void applyEdit(LedgerEntry editedEntry, LedgerAccountTransactionEdit edit, + name.abuchen.portfolio.model.ledger.LedgerProjectionRole role, + int postingIndex) + { + LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); + var posting = LedgerEntryEditSupport.postingAt(editedEntry, postingIndex); + edit.getPosting().applyTo(posting); + applyExDate(posting, edit.getExDate()); + unitPostingUpdater.apply(editedEntry, edit.getUnits()); + ensureDescriptorExists(editedEntry, role); + } + + 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 ensureDescriptorExists(LedgerEntry entry, name.abuchen.portfolio.model.ledger.LedgerProjectionRole role) + { + LedgerProjectionSupport.descriptor(entry, role); + } +} 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..c186bded4b --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferEditor.java @@ -0,0 +1,90 @@ +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.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 = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.TARGET_ACCOUNT); + 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, sourcePostingIndex, targetPostingIndex)); + } + + 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 = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.TARGET_ACCOUNT); + 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, sourcePostingIndex, targetPostingIndex)); + } + + private void applyEdit(LedgerEntry editedEntry, LedgerAccountTransferEdit edit, + name.abuchen.portfolio.model.Account sourceAccount, + name.abuchen.portfolio.model.Account targetAccount, + int sourcePostingIndex, int targetPostingIndex) + { + LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); + edit.getSourcePosting().applyTo(LedgerEntryEditSupport.postingAt(editedEntry, sourcePostingIndex)); + edit.getTargetPosting().applyTo(LedgerEntryEditSupport.postingAt(editedEntry, targetPostingIndex)); + unitPostingUpdater.apply(editedEntry, edit.getUnits()); + ensureOwners(editedEntry, sourceAccount, targetAccount); + } + + private void ensureOwners(LedgerEntry entry, name.abuchen.portfolio.model.Account source, + name.abuchen.portfolio.model.Account target) + { + 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 + .message("Account transfer owner changes are not supported")); //$NON-NLS-1$ + } +} 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..cfcaec5912 --- /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.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; +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.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 sourceAccount = sourceTransaction.getLedgerProjectionDescriptor().getAccount(); + var targetAccount = targetTransaction.getLedgerProjectionDescriptor().getAccount(); + + preflight(entry, sourceTransaction, targetTransaction); + + 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, LedgerProjectionRole.SOURCE_ACCOUNT, LedgerProjectionRole.TARGET_ACCOUNT, removal, + deposit); + + new LedgerMutationContext(client).splitEntry(entry, List.of(removal, deposit)); + executionRefUpdates.apply(); + + return new SplitResult(find(sourceAccount, sourceRuntimeProjectionId), find(targetAccount, targetRuntimeProjectionId)); + } + + 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.getLedgerProjectionDescriptor(); + var targetProjection = targetTransaction.getLedgerProjectionDescriptor(); + + 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.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 = 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$ + + 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, DerivedProjectionDescriptor sourceProjection, + LedgerPosting sourcePosting, LedgerPosting targetPosting) + { + var entry = baseEntry(source, source.getUUID(), LedgerEntryType.REMOVAL); + var posting = cashPosting(sourcePosting, sourceProjection.getAccount(), targetPosting); + + markAccountPrimary(posting); + entry.addPosting(posting); + + return entry; + } + + private LedgerEntry depositEntry(LedgerEntry source, DerivedProjectionDescriptor targetProjection, + LedgerPosting targetPosting) + { + var entry = baseEntry(source, null, LedgerEntryType.DEPOSIT); + var posting = cashPosting(targetPosting, targetProjection.getAccount(), null); + + markAccountPrimary(posting); + entry.addPosting(posting); + + 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 void markAccountPrimary(LedgerPosting posting) + { + posting.setSemanticRole(LedgerPostingSemanticRole.CASH); + posting.setDirection(LedgerPostingDirection.NEUTRAL); + posting.setUnitRole(LedgerPostingUnitRole.PRIMARY); + posting.setLocalKey(LedgerProjectionRole.ACCOUNT.name()); + } + + 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_010 + .message("Ledger account transfer must have exactly one " + role + " projection")); //$NON-NLS-1$ //$NON-NLS-2$ + + 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() // + .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/LedgerAccountTransferTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreator.java new file mode 100644 index 0000000000..376b0308bb --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTransferTransactionCreator.java @@ -0,0 +1,241 @@ +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.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; +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.getLedgerProjectionDescriptor(); + 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.getUUID(); + var targetProjectionUUID = targetTransaction.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.getLedgerProjectionDescriptor().getAccount() != sourceAccount + || targetTransaction.getLedgerProjectionDescriptor().getAccount() != targetAccount) + editor.validate(ledgerEntry, edit); + + if (sourceTransaction.getLedgerProjectionDescriptor().getAccount() != sourceAccount) + ownerPatchHelper.moveAccountTransferSource(ledgerEntry, sourceAccount); + + if (targetTransaction.getLedgerProjectionDescriptor().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.getRuntimeProjectionId(LedgerProjectionRole.SOURCE_ACCOUNT); + var targetProjectionUUID = created.getRuntimeProjectionId(LedgerProjectionRole.TARGET_ACCOUNT); + + 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/LedgerAccountTypeToggleConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverter.java new file mode 100644 index 0000000000..c434f1f1df --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerAccountTypeToggleConverter.java @@ -0,0 +1,126 @@ +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.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; +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.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 account = ledgerTransaction.getLedgerProjectionDescriptor().getAccount(); + var projectionUUID = ledgerTransaction.getRuntimeProjectionId(); + + preflight(entry, transaction, account); + LedgerInvestmentPlanRefSupport.requireCurrentRefsResolveUniquely(client, entry); + + new LedgerMutationContext(client).mutateEntry(entry, this::toggle); + + return find(account, projectionUUID); + } + + 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 (transaction.getOwner() != account) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_013.message("Selected account does not own the ledger projection")); //$NON-NLS-1$ + + var primaryPosting = requirePrimaryPosting(entry); + + if (primaryPosting.getAccount() != account) + 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) + { + 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$ + + 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 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..a4dc6eceeb --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellDeliveryConverter.java @@ -0,0 +1,326 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; +import java.util.List; +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.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.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; + +/** + * 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 descriptor = ledgerTransaction.getLedgerProjectionDescriptor(); + var portfolio = descriptor.getPortfolio(); + var oldRuntimeProjectionId = ledgerTransaction.getRuntimeProjectionId(); + + preflightBuySell(entry, ledgerTransaction.getLedgerProjectionRole(), transaction, portfolio); + var targetRole = entry.getType() == LedgerEntryType.BUY ? LedgerProjectionRole.DELIVERY_INBOUND + : LedgerProjectionRole.DELIVERY_OUTBOUND; + var targetRuntimeProjectionId = runtimeProjectionId(entry, targetRole); + var roleChange = LedgerInvestmentPlanRefSupport.roleChange(oldRuntimeProjectionId, + LedgerProjectionRole.PORTFOLIO, targetRole); + LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, roleChange); + + new LedgerMutationContext(client).mutateEntry(entry, this::convert); + LedgerInvestmentPlanRefSupport.updateProjectionRoles(client, entry, roleChange); + + return find(portfolio, targetRuntimeProjectionId); + } + + 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 descriptor = ledgerTransaction.getLedgerProjectionDescriptor(); + var portfolio = descriptor.getPortfolio(); + var account = requireReferenceAccount(portfolio); + var oldRuntimeProjectionId = ledgerTransaction.getRuntimeProjectionId(); + var portfolioRuntimeProjectionId = runtimeProjectionId(entry, LedgerProjectionRole.PORTFOLIO); + var accountRuntimeProjectionId = runtimeProjectionId(entry, LedgerProjectionRole.ACCOUNT); + + preflightDelivery(entry, ledgerTransaction.getLedgerProjectionRole(), transaction, portfolio, account); + var roleChange = LedgerInvestmentPlanRefSupport.roleChange(oldRuntimeProjectionId, + ledgerTransaction.getLedgerProjectionRole(), LedgerProjectionRole.PORTFOLIO); + LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, roleChange); + + new LedgerMutationContext(client).mutateEntry(entry, editedEntry -> convertDeliveryToBuySell(editedEntry, + account)); + LedgerInvestmentPlanRefSupport.updateProjectionRoles(client, entry, roleChange); + + var portfolioTransaction = find(portfolio, portfolioRuntimeProjectionId); + var accountTransaction = find(account, accountRuntimeProjectionId); + + return BuySellEntry.readOnly(portfolio, portfolioTransaction, account, accountTransaction); + } + + 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 (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) + 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, LedgerProjectionRole role, + 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 (role != 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 (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); + 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) + { + 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); + + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + + entry.setType(targetType); + markPrimary(securityPosting, LedgerPostingSemanticRole.SECURITY, direction(targetRole), targetRole); + } + + 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(); + var unitPostings = entry.getPostings().stream() // + .filter(posting -> posting != securityPosting) // + .toList(); + + cashPosting.setType(LedgerPostingType.CASH); + cashPosting.setAccount(account); + applyDeliveryCashPosting(securityPosting, cashPosting, account); + + 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); + + 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 = 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() // + .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/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..cca9c7cf31 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellEditor.java @@ -0,0 +1,82 @@ +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.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 = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.ACCOUNT); + var portfolioProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.PORTFOLIO); + 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, cashPostingIndex, + securityPostingIndex)); + } + + 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 = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.ACCOUNT); + var portfolioProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.PORTFOLIO); + 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, cashPostingIndex, + securityPostingIndex)); + } + + private void applyEdit(LedgerEntry editedEntry, LedgerBuySellEdit edit, LedgerProjectionRole accountRole, + LedgerProjectionRole portfolioRole, int cashPostingIndex, int securityPostingIndex) + { + LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); + 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/LedgerBuySellReversalConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverter.java new file mode 100644 index 0000000000..2bd4bfb0b8 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellReversalConverter.java @@ -0,0 +1,226 @@ +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.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; +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.getRuntimeProjectionId(); + var portfolioProjectionUUID = portfolioTransaction.getRuntimeProjectionId(); + var account = accountTransaction.getLedgerProjectionDescriptor().getAccount(); + var portfolio = portfolioTransaction.getLedgerProjectionDescriptor().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.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); + var portfolioProjection = uniqueProjection(entry, LedgerProjectionRole.PORTFOLIO); + var cashPosting = requireOnePosting(entry, LedgerPostingType.CASH); + var securityPosting = requireOnePosting(entry, LedgerPostingType.SECURITY); + + 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() + || 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()); + cashPosting.setDirection(cashDirection(targetType)); + securityPosting.setDirection(securityDirection(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.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 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_032 + .message("Expected one projection for role " + role + " but found " //$NON-NLS-1$ //$NON-NLS-2$ + + projections.size())); + + 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() // + .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/LedgerBuySellTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreator.java new file mode 100644 index 0000000000..bac9c67152 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerBuySellTransactionCreator.java @@ -0,0 +1,312 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.time.LocalDateTime; +import java.util.List; +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.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.getUUID(); + var portfolioProjectionUUID = portfolioTransaction.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.getLedgerProjectionDescriptor().getAccount() != account + || portfolioTransaction.getLedgerProjectionDescriptor().getPortfolio() != portfolio) + editor.validate(ledgerEntry, edit); + + if (accountTransaction.getLedgerProjectionDescriptor().getAccount() != account) + ownerPatchHelper.moveBuySellAccountSide(ledgerEntry, account); + + if (portfolioTransaction.getLedgerProjectionDescriptor().getPortfolio() != portfolio) + ownerPatchHelper.moveBuySellPortfolioSide(ledgerEntry, portfolio); + + accountTransaction = (LedgerBackedAccountTransaction) find(account, accountProjectionUUID); + portfolioTransaction = (LedgerBackedPortfolioTransaction) find(portfolio, portfolioProjectionUUID); + entry = (BuySellEntry) accountTransaction.getCrossEntry(); + + editor.apply(portfolioTransaction, edit); + LedgerProjectionService.restoreIfValid(client); + accountTransaction = (LedgerBackedAccountTransaction) find(account, accountProjectionUUID); + + return (BuySellEntry) accountTransaction.getCrossEntry(); + } + + 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.getRuntimeProjectionId(LedgerProjectionRole.ACCOUNT); + var portfolioProjectionUUID = created.getRuntimeProjectionId(LedgerProjectionRole.PORTFOLIO); + + 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/LedgerDeliveryDirectionConverter.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverter.java new file mode 100644 index 0000000000..9771426351 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryDirectionConverter.java @@ -0,0 +1,196 @@ +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.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; +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 descriptor = ledgerTransaction.getLedgerProjectionDescriptor(); + var portfolio = descriptor.getPortfolio(); + var oldRuntimeProjectionId = descriptor.getRuntimeProjectionId(); + + preflight(entry, descriptor, transaction, portfolio); + var targetRole = role(entry.getType() == LedgerEntryType.DELIVERY_INBOUND + ? LedgerEntryType.DELIVERY_OUTBOUND + : LedgerEntryType.DELIVERY_INBOUND); + var targetRuntimeProjectionId = runtimeProjectionId(entry, targetRole); + var roleChange = LedgerInvestmentPlanRefSupport.roleChange(oldRuntimeProjectionId, descriptor.getRole(), + targetRole); + LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, roleChange); + + new LedgerMutationContext(client).mutateEntry(entry, this::reverse); + LedgerInvestmentPlanRefSupport.updateProjectionRoles(client, entry, roleChange); + + return find(portfolio, targetRuntimeProjectionId); + } + + private void preflight(LedgerEntry entry, DerivedProjectionDescriptor descriptor, + 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 (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) + 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.getRuntimeProjectionId().equals(descriptor.getRuntimeProjectionId())) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_036.message("Selected projection is not the unique delivery projection")); //$NON-NLS-1$ + + 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()) + 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) + { + 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 targetRole = role(targetType); + + securityPosting.setAmount(amount.getAmount()); + securityPosting.setCurrency(amount.getCurrencyCode()); + securityPosting.setDirection(direction(targetRole)); + securityPosting.setLocalKey(targetRole.name()); + 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 DerivedProjectionDescriptor requireOneProjection(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_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 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() // + .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/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..3636e4d2fa --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionCreator.java @@ -0,0 +1,248 @@ +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.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.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.getLedgerProjectionDescriptor().getPortfolio() != portfolio) + { + var projectionUUID = ledgerTransaction.getUUID(); + + editor.validate(ledgerTransaction, edit); + new LedgerOwnerPatchHelper(client).moveDelivery(ledgerTransaction, portfolio); + ledgerTransaction = (LedgerBackedPortfolioTransaction) find(portfolio, projectionUUID); + } + + editor.apply(ledgerTransaction, edit); + LedgerProjectionService.restoreIfValid(client); + + return (PortfolioTransaction) find(portfolio, ledgerTransaction.getUUID()); + } + + 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.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); + + 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..c2380fe43d --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerDeliveryTransactionEditor.java @@ -0,0 +1,67 @@ +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 role = transaction.getLedgerProjectionRole(); + var postingIndex = LedgerEntryEditSupport.postingIndex(entry, + transaction.getLedgerProjectionDescriptor().getPrimaryPosting()); + + LedgerEntryEditSupport.applyValidated(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingIndex)); + } + + 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 role = transaction.getLedgerProjectionRole(); + var postingIndex = LedgerEntryEditSupport.postingIndex(entry, + transaction.getLedgerProjectionDescriptor().getPrimaryPosting()); + + LedgerEntryEditSupport.validatePatch(entry, editedEntry -> applyEdit(editedEntry, edit, role, postingIndex)); + } + + private void applyEdit(LedgerEntry editedEntry, LedgerDeliveryTransactionEdit edit, + name.abuchen.portfolio.model.ledger.LedgerProjectionRole role, + int postingIndex) + { + LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); + 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/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..342ecc6861 --- /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.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; +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.getLedgerProjectionDescriptor().getAccount() != account) + { + var projectionUUID = ledgerTransaction.getRuntimeProjectionId(); + + 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.getRuntimeProjectionId(name.abuchen.portfolio.model.ledger.LedgerProjectionRole.ACCOUNT); + + 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/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..210b3fd5fd --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInlineEditingPolicy.java @@ -0,0 +1,490 @@ +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; +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.getLedgerProjectionRole(), 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); + } + + 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) + return pair.getTransaction(); + + if (element instanceof Transaction transaction) + return transaction; + + 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>>( + 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/LedgerInvestmentPlanRefSupport.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupport.java new file mode 100644 index 0000000000..4a47bda6c9 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerInvestmentPlanRefSupport.java @@ -0,0 +1,65 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.List; +import java.util.Objects; + +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; + +/** + * 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 +{ + private LedgerInvestmentPlanRefSupport() + { + } + + static RoleChange roleChange(String runtimeProjectionId, LedgerProjectionRole sourceRole, + LedgerProjectionRole targetRole) + { + return new RoleChange(Objects.requireNonNull(runtimeProjectionId), Objects.requireNonNull(sourceRole), + Objects.requireNonNull(targetRole)); + } + + static void requireCurrentRefsResolveUniquely(Client client, LedgerEntry entry) + { + // Plan linkage is entry metadata; there are no projection-scoped plan refs to validate. + } + + static SplitExecutionRefUpdates prepareAccountTransferSplitExecutionRefUpdates(Client client, LedgerEntry entry, + LedgerProjectionRole sourceRole, LedgerProjectionRole targetRole, + LedgerEntry removalEntry, LedgerEntry depositEntry) + { + return new SplitExecutionRefUpdates(List.of()); + } + + static void requireRefsFollowRoleChanges(Client client, LedgerEntry entry, RoleChange... changes) + { + // Role changes do not affect plan key / execution-date linkage. + } + + static void updateProjectionRoles(Client client, LedgerEntry entry, RoleChange... changes) + { + // Projection role rewrites are no longer part of InvestmentPlan linkage. + } + + record RoleChange(String runtimeProjectionId, LedgerProjectionRole sourceRole, LedgerProjectionRole targetRole) + { + } + + record SplitExecutionRefUpdates(List updates) + { + void apply() + { + // No projection-scoped plan refs remain to update. + } + } + + private record ExecutionRefUpdate() + { + } +} 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..d24777fda6 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerNativeComponentInspectorModel.java @@ -0,0 +1,308 @@ +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.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; + +/** + * 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_RUNTIME_PROJECTION_ID, + SELECTED_PRIMARY_POSTING_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 DescriptorRow(String projectionRole, String owner, String runtimeProjectionId, + String primaryPostingId, String unitPostings) + { + } + + private final List headerRows; + private final List entryParameters; + private final List legs; + private final List postings; + private final List postingParameters; + private final List descriptors; + private final boolean nativeEntryDefinitionAvailable; + + private LedgerNativeComponentInspectorModel(List headerRows, List entryParameters, + List legs, List postings, List postingParameters, + 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.descriptors = List.copyOf(descriptors); + 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.getLedgerProjectionDescriptor(), LedgerEntryDefinitionRegistry::lookup); + } + + static Optional from(LedgerEntry entry, DerivedProjectionDescriptor selectedDescriptor, + 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, selectedDescriptor), + parameterRows(entry.getParameters()), + definition.map(d -> legRows(d.getLegDefinitions())).orElseGet(List::of), + postingRows(entry.getPostings()), postingParameterRows(entry.getPostings()), + descriptorRows(entry), 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 getDescriptors() + { + return descriptors; + } + + public boolean isNativeEntryDefinitionAvailable() + { + return nativeEntryDefinitionAvailable; + } + + 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())), + 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, + 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, + selectedDescriptor != null + ? format(selectedDescriptor.getPrimaryPosting().getUUID()) + : "")); //$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 descriptorRows(LedgerEntry entry) + { + 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(DerivedProjectionDescriptor descriptor) + { + if (descriptor.getAccount() != null) + return format(descriptor.getAccount()); + + return format(descriptor.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); + } +} 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/LedgerOwnerPatchHelper.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOwnerPatchHelper.java new file mode 100644 index 0000000000..48422311a0 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerOwnerPatchHelper.java @@ -0,0 +1,210 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.Objects; + +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; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerMutationContext; +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.getLedgerProjectionRole(), 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 posting = LedgerProjectionSupport.descriptor(editedEntry, role).getPrimaryPosting(); + posting.setAccount(newAccount); + }); + } + + private void movePortfolioProjection(LedgerEntry entry, LedgerProjectionRole role, Portfolio newPortfolio) + { + mutationContext.mutateEntry(entry, editedEntry -> { + var posting = LedgerProjectionSupport.descriptor(editedEntry, role).getPrimaryPosting(); + posting.setPortfolio(newPortfolio); + }); + } + + 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..466b244943 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioCompositeTypeConverter.java @@ -0,0 +1,462 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; + +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.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; +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 descriptor = ledgerTransaction.getLedgerProjectionDescriptor(); + var type = entry.getType(); + + if (type == LedgerEntryType.BUY || type == LedgerEntryType.SELL) + return prepareBuySellToOppositeDelivery(entry, descriptor, transaction); + + if (type == LedgerEntryType.DELIVERY_INBOUND || type == LedgerEntryType.DELIVERY_OUTBOUND) + 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, DerivedProjectionDescriptor descriptor, + TransactionPair transaction) + { + var portfolio = descriptor.getPortfolio(); + var oldRuntimeProjectionId = descriptor.getRuntimeProjectionId(); + + preflightBuySell(entry, descriptor, transaction, portfolio); + + var targetType = entry.getType() == LedgerEntryType.BUY ? LedgerEntryType.DELIVERY_OUTBOUND + : LedgerEntryType.DELIVERY_INBOUND; + var targetRole = role(targetType); + var targetRuntimeProjectionId = runtimeProjectionId(entry, targetRole); + var roleChange = LedgerInvestmentPlanRefSupport.roleChange(oldRuntimeProjectionId, + LedgerProjectionRole.PORTFOLIO, targetRole); + + LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, roleChange); + LedgerEntryEditSupport.validatePatch(entry, this::applyBuySellToOppositeDelivery); + + return new Operation(entry, portfolio, targetRuntimeProjectionId, roleChange, + this::applyBuySellToOppositeDelivery); + } + + private Operation prepareDeliveryToOppositeBuySell(LedgerEntry entry, DerivedProjectionDescriptor descriptor, + TransactionPair transaction) + { + var portfolio = descriptor.getPortfolio(); + var account = requireReferenceAccount(portfolio); + var oldRuntimeProjectionId = descriptor.getRuntimeProjectionId(); + var targetRuntimeProjectionId = runtimeProjectionId(entry, LedgerProjectionRole.PORTFOLIO); + + preflightDelivery(entry, descriptor, transaction, portfolio); + + var roleChange = LedgerInvestmentPlanRefSupport.roleChange(oldRuntimeProjectionId, descriptor.getRole(), + LedgerProjectionRole.PORTFOLIO); + + LedgerInvestmentPlanRefSupport.requireRefsFollowRoleChanges(client, entry, roleChange); + LedgerEntryEditSupport.validatePatch(entry, + editedEntry -> applyDeliveryToOppositeBuySell(editedEntry, account)); + + return new Operation(entry, portfolio, targetRuntimeProjectionId, roleChange, + editedEntry -> applyDeliveryToOppositeBuySell(editedEntry, account)); + } + + 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 (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) + 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.getRuntimeProjectionId().equals(descriptor.getRuntimeProjectionId())) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_046.message("Selected projection is not the unique portfolio projection")); //$NON-NLS-1$ + + 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() + || 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, DerivedProjectionDescriptor descriptor, + 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 (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) + 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 (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.getRuntimeProjectionId().equals(descriptor.getRuntimeProjectionId())) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_PROJ_053.message("Selected projection is not the unique delivery projection")); //$NON-NLS-1$ + + 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()) + 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) + { + 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); + + securityPosting.setAmount(amount.getAmount()); + securityPosting.setCurrency(amount.getCurrencyCode()); + + List.copyOf(entry.getPostings()).stream() // + .filter(posting -> posting.getType() == LedgerPostingType.CASH) // + .forEach(entry::removePosting); + + markPrimary(securityPosting, LedgerPostingSemanticRole.SECURITY, direction(targetRole), targetRole); + entry.setType(targetType); + } + + 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(); + 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); + + 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); + + 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 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_056 + .message("Expected one projection for role " + role + " but found " //$NON-NLS-1$ //$NON-NLS-2$ + + projections.size())); + + return projections.get(0); + } + + private boolean hasProjection(LedgerEntry entry, LedgerProjectionRole role) + { + return LedgerProjectionSupport.descriptors(entry).stream().anyMatch(projection -> projection.getRole() == role); + } + + private LedgerProjectionRole role(LedgerEntryType entryType) + { + return entryType == LedgerEntryType.DELIVERY_INBOUND ? LedgerProjectionRole.DELIVERY_INBOUND + : 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() // + .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/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..f505fb0412 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferEditor.java @@ -0,0 +1,90 @@ +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.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 = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.TARGET_PORTFOLIO); + 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, sourcePostingIndex, targetPostingIndex)); + } + + 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 = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetProjection = LedgerProjectionSupport.descriptor(entry, LedgerProjectionRole.TARGET_PORTFOLIO); + 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, sourcePostingIndex, targetPostingIndex)); + } + + private void applyEdit(LedgerEntry editedEntry, LedgerPortfolioTransferEdit edit, + name.abuchen.portfolio.model.Portfolio sourcePortfolio, + name.abuchen.portfolio.model.Portfolio targetPortfolio, + int sourcePostingIndex, int targetPostingIndex) + { + LedgerEntryMetadataPatchHelper.apply(editedEntry, edit.getMetadata()); + edit.getSourcePosting().applyTo(LedgerEntryEditSupport.postingAt(editedEntry, sourcePostingIndex)); + edit.getTargetPosting().applyTo(LedgerEntryEditSupport.postingAt(editedEntry, targetPostingIndex)); + unitPostingUpdater.apply(editedEntry, edit.getUnits()); + ensureOwners(editedEntry, sourcePortfolio, targetPortfolio); + } + + private void ensureOwners(LedgerEntry entry, name.abuchen.portfolio.model.Portfolio source, + name.abuchen.portfolio.model.Portfolio target) + { + 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 + .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..a2397aecad --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerPortfolioTransferTransactionCreator.java @@ -0,0 +1,203 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.time.LocalDateTime; +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.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.getUUID(); + var targetProjectionUUID = targetTransaction.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.getLedgerProjectionDescriptor().getPortfolio() != sourcePortfolio + || targetTransaction.getLedgerProjectionDescriptor().getPortfolio() != targetPortfolio) + editor.validate(ledgerEntry, edit); + + if (sourceTransaction.getLedgerProjectionDescriptor().getPortfolio() != sourcePortfolio) + ownerPatchHelper.movePortfolioTransferSource(ledgerEntry, sourcePortfolio); + + if (targetTransaction.getLedgerProjectionDescriptor().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.getRuntimeProjectionId(LedgerProjectionRole.SOURCE_PORTFOLIO); + var targetProjectionUUID = created.getRuntimeProjectionId(LedgerProjectionRole.TARGET_PORTFOLIO); + + 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/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/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/LedgerShareAdjustmentHelper.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerShareAdjustmentHelper.java new file mode 100644 index 0000000000..f4ef2a577c --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerShareAdjustmentHelper.java @@ -0,0 +1,149 @@ +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.function.LongUnaryOperator; + +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; +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.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) + { + return transaction.getLedgerProjectionDescriptor().getPrimaryPosting(); + } + + 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/LedgerTransactionCreator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java new file mode 100644 index 0000000000..4afaff1354 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransactionCreator.java @@ -0,0 +1,475 @@ +package name.abuchen.portfolio.model.ledger.compatibility; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +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; +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.LedgerTransactionMetadata; +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()); + markPrimary(cashPosting, LedgerPostingSemanticRole.CASH, LedgerPostingDirection.NEUTRAL, + LedgerProjectionRole.ACCOUNT); + + if (dividend.hasExDate()) + cashPosting.addParameter( + LedgerParameter.ofLocalDateTime(LedgerParameterType.EX_DATE, + dividend.getExDate())); + + entry.addPosting(cashPosting); + addUnitPostings(entry, dividend.getUnits()); + + 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); + markPrimary(sourcePosting, LedgerPostingSemanticRole.CASH, LedgerPostingDirection.OUTBOUND, + LedgerProjectionRole.SOURCE_ACCOUNT); + markPrimary(targetPosting, LedgerPostingSemanticRole.CASH, LedgerPostingDirection.INBOUND, + LedgerProjectionRole.TARGET_ACCOUNT); + + entry.addPosting(sourcePosting); + entry.addPosting(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); + markPrimary(sourcePosting, LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.OUTBOUND, + LedgerProjectionRole.SOURCE_PORTFOLIO); + markPrimary(targetPosting, LedgerPostingSemanticRole.SECURITY, LedgerPostingDirection.INBOUND, + LedgerProjectionRole.TARGET_PORTFOLIO); + + entry.addPosting(sourcePosting); + entry.addPosting(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); + markPrimary(posting, semanticRole(postingType), LedgerPostingDirection.NEUTRAL, LedgerProjectionRole.ACCOUNT); + + entry.addPosting(posting); + addUnitPostings(entry, units); + + 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()); + markPrimary(posting, LedgerPostingSemanticRole.SECURITY, direction(role), role); + + entry.addPosting(posting); + addUnitPostings(entry, deliveryLeg.getUnits()); + + 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); + markPrimary(cashPosting, LedgerPostingSemanticRole.CASH, cashDirection(entryType), + LedgerProjectionRole.ACCOUNT); + markPrimary(securityPosting, LedgerPostingSemanticRole.SECURITY, securityDirection(entryType), + LedgerProjectionRole.PORTFOLIO); + + entry.addPosting(cashPosting); + entry.addPosting(securityPosting); + addUnitPostings(entry, units); + + 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()); + markUnit(posting); + entry.addPosting(posting); + postings.add(posting); + } + + return List.copyOf(postings); + } + + 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 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; + }; + } + + 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 String getRuntimeProjectionId(LedgerProjectionRole role) + { + return entry.getUUID() + ":" + role; //$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..e48752170e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerTransferDirectionConverter.java @@ -0,0 +1,272 @@ +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.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.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.getRuntimeProjectionId(); + var targetProjectionUUID = targetTransaction.getRuntimeProjectionId(); + var sourceAccount = sourceTransaction.getLedgerProjectionDescriptor().getAccount(); + var targetAccount = targetTransaction.getLedgerProjectionDescriptor().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, entry, LedgerProjectionRole.SOURCE_ACCOUNT), sourceAccount, + find(sourceAccount, entry, LedgerProjectionRole.TARGET_ACCOUNT)); + } + + 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.getRuntimeProjectionId(); + var targetProjectionUUID = targetTransaction.getRuntimeProjectionId(); + var sourcePortfolio = sourceTransaction.getLedgerProjectionDescriptor().getPortfolio(); + var targetPortfolio = targetTransaction.getLedgerProjectionDescriptor().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, entry, LedgerProjectionRole.SOURCE_PORTFOLIO), + sourcePortfolio, find(sourcePortfolio, entry, LedgerProjectionRole.TARGET_PORTFOLIO)); + } + + 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.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 = 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$ + + 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.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 = 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$ + + 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 = sourceProjection.getPrimaryPosting(); + var targetPosting = targetProjection.getPrimaryPosting(); + + reverseAccountTransferForex(sourcePosting, targetPosting); + + markPrimary(sourcePosting, LedgerProjectionRole.TARGET_ACCOUNT); + markPrimary(targetPosting, 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); + + markPrimary(sourceProjection.getPrimaryPosting(), LedgerProjectionRole.TARGET_PORTFOLIO); + markPrimary(targetProjection.getPrimaryPosting(), LedgerProjectionRole.SOURCE_PORTFOLIO); + } + + private void markPrimary(LedgerPosting posting, LedgerProjectionRole role) + { + 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 + .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, 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, 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/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..319417e5ad --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/compatibility/LedgerUnitPostingUpdater.java @@ -0,0 +1,89 @@ +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.LedgerPostingSemanticRole; +import name.abuchen.portfolio.model.ledger.LedgerPostingUnitRole; + +/** + * 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)); + } + + 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); + markUnit(posting); + entry.addPosting(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); + } + + 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/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/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/CorporateActionKind.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionKind.java new file mode 100644 index 0000000000..13ddd06f79 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionKind.java @@ -0,0 +1,84 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.Optional; + +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerParameter; + +/** + * Defines the corporate action kind Ledger code domain used by configuration or validation. + * This is configuration metadata. Normal transaction-editing code should not treat these + * values as a direct mutation API. + * + *

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

+ */ +@SuppressWarnings("nls") +public enum CorporateActionKind implements LedgerCode +{ + CASH_DISTRIBUTION("CASH_DISTRIBUTION"), + STOCK_DIVIDEND("STOCK_DIVIDEND"), + SPIN_OFF("SPIN_OFF"), + BONUS_ISSUE("BONUS_ISSUE"), + RIGHTS_DISTRIBUTION("RIGHTS_DISTRIBUTION"), + COUPON_PAYMENT("COUPON_PAYMENT"), + PIK_INTEREST("PIK_INTEREST"), + DEFAULTED_INTEREST("DEFAULTED_INTEREST"), + MATURITY("MATURITY"), + PARTIAL_REDEMPTION("PARTIAL_REDEMPTION"), + CALL("CALL"), + PUT("PUT"), + CONVERSION("CONVERSION"), + EXCHANGE("EXCHANGE"), + RESTRUCTURING("RESTRUCTURING"), + DEFAULT("DEFAULT"); + + private final String code; + + private CorporateActionKind(String code) + { + this.code = code; + } + + @Override + public LedgerParameterCodeDomain getDomain() + { + return LedgerParameterCodeDomain.CORPORATE_ACTION_KIND; + } + + @Override + public String getCode() + { + return code; + } + + public static Optional fromCode(String code) + { + if (code == null || code.isBlank()) + return Optional.empty(); + + for (var kind : values()) + if (kind.code.equals(code)) + return Optional.of(kind); + + return Optional.empty(); + } + + public static Optional fromEntry(LedgerEntry entry) + { + if (entry == null) + return Optional.empty(); + + return entry.getParameters().stream() // + .filter(parameter -> parameter.getType() == LedgerParameterType.CORPORATE_ACTION_KIND) // + .map(LedgerParameter::getValue) // + .filter(String.class::isInstance) // + .map(String.class::cast) // + .map(CorporateActionKind::fromCode) // + .filter(Optional::isPresent) // + .map(Optional::get) // + .findFirst(); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionLeg.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionLeg.java new file mode 100644 index 0000000000..db7562b923 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/CorporateActionLeg.java @@ -0,0 +1,50 @@ +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"), + SECURITY_CONTEXT("SECURITY_CONTEXT"), + 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..b09d34f28a --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryDefinition.java @@ -0,0 +1,449 @@ +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.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; +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 componentRequirements; + 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 componentRequirements, + 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.componentRequirements = copyRuleSet(componentRequirements); + 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(), 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(), + 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, + 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() + { + 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 getComponentRequirements() + { + return componentRequirements; + } + + 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..b531457e79 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryDefinitionRegistry.java @@ -0,0 +1,846 @@ +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.LinkedHashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.configuration.rule.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; +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; + +/** + * Registers Ledger configuration definitions used by validation and assembly. + * This is configuration infrastructure. Normal transaction-editing code should not update + * the registry directly. + * + *

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

+ */ +public final class LedgerEntryDefinitionRegistry +{ + private static final String CASH_COMPENSATION_GROUP = "CASH_COMPENSATION_GROUP"; //$NON-NLS-1$ + + private static final SetBuilder SETS = new SetBuilder(); + + private static final Map DEFINITIONS = definitions(); + private static final Map CORPORATE_ACTION_DEFINITIONS = corporateActionDefinitions(); + + private LedgerEntryDefinitionRegistry() + { + } + + public static Optional lookup(LedgerEntryType entryType) + { + if (entryType == LedgerEntryType.CORPORATE_ACTION) + return lookup(entryType, CorporateActionKind.SPIN_OFF); + + return Optional.ofNullable(DEFINITIONS.get(entryType)); + } + + public static Optional lookup(LedgerEntry entry) + { + if (entry == null || entry.getType() == null) + return Optional.empty(); + + if (entry.getType() == LedgerEntryType.CORPORATE_ACTION) + return CorporateActionKind.fromEntry(entry).flatMap(kind -> lookup(entry.getType(), kind)); + + return lookup(entry.getType()); + } + + public static Optional lookup(LedgerEntryType entryType, CorporateActionKind kind) + { + if (entryType != LedgerEntryType.CORPORATE_ACTION) + return lookup(entryType); + + if (kind == null) + return Optional.empty(); + + return Optional.ofNullable(CORPORATE_ACTION_DEFINITIONS.get(kind)); + } + + public static Collection getDefinitions() + { + var definitions = new ArrayList(); + definitions.addAll(DEFINITIONS.values()); + definitions.addAll(CORPORATE_ACTION_DEFINITIONS.values()); + return Collections.unmodifiableList(definitions); + } + + public static boolean hasDefinition(LedgerEntryType entryType) + { + if (entryType == LedgerEntryType.CORPORATE_ACTION) + return !CORPORATE_ACTION_DEFINITIONS.isEmpty(); + + return DEFINITIONS.containsKey(entryType); + } + + private static Map definitions() + { + var definitions = new EnumMap(LedgerEntryType.class); + + return Collections.unmodifiableMap(definitions); + } + + private static Map corporateActionDefinitions() + { + var definitions = new EnumMap(CorporateActionKind.class); + + register(definitions, CorporateActionKind.CASH_DISTRIBUTION, cashDistribution()); + 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.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); + } + + private static void register(Map definitions, + LedgerEntryDefinition definition) + { + if (definitions.put(definition.getEntryType(), definition) != null) + throw new IllegalStateException(LedgerDiagnosticCode.LEDGER_CORE_016 + .message("Duplicate Ledger entry definition: " + definition.getEntryType())); //$NON-NLS-1$ + } + + private static void register(Map definitions, + CorporateActionKind kind, LedgerEntryDefinition definition) + { + if (definitions.put(kind, definition) != null) + throw new IllegalStateException(LedgerDiagnosticCode.LEDGER_CORE_016 + .message("Duplicate Ledger corporate action definition: " + kind)); //$NON-NLS-1$ + } + + private static LedgerEntryDefinition spinOff() + { + return LedgerEntryDefinition.of(LedgerEntryType.CORPORATE_ACTION, + LedgerNativeEntryShape.DUAL_INSTRUMENT_PLUS_ACCOUNT, + SETS.postingRules( + optionalPosting(LedgerPostingType.SECURITY, requiredSecurityLegParameters(), + spinOffSecurityOptionalParameters()), + optionalPosting(LedgerPostingType.CASH_COMPENSATION, SETS.parameterTypes(), + cashCompensationOptionalParameters()), + optionalPosting(LedgerPostingType.FEE, SETS.parameterTypes(), + feeOptionalParameters()), + optionalPosting(LedgerPostingType.TAX, SETS.parameterTypes(), + taxOptionalParameters()), + optionalPosting(LedgerPostingType.FOREX, SETS.parameterTypes(), + forexOptionalParameters())), + corporateActionEntryParameterRules(), + SETS.parameterRules(repeatableRequiredPostingParameter(LedgerParameterType.CORPORATE_ACTION_LEG), + repeatableRequiredPostingParameter(LedgerParameterType.SOURCE_SECURITY), + repeatableRequiredPostingParameter(LedgerParameterType.TARGET_SECURITY), + repeatableRequiredPostingParameter(LedgerParameterType.RATIO_NUMERATOR), + repeatableRequiredPostingParameter(LedgerParameterType.RATIO_DENOMINATOR), + repeatableOptionalPostingParameter(LedgerParameterType.CASH_COMPENSATION_KIND), + repeatableOptionalPostingParameter(LedgerParameterType.CASH_IN_LIEU_AMOUNT), + repeatableOptionalPostingParameter(LedgerParameterType.CASH_IN_LIEU_APPLIED), + repeatableOptionalPostingParameter(LedgerParameterType.FRACTION_QUANTITY), + repeatableOptionalPostingParameter(LedgerParameterType.FRACTION_TREATMENT), + repeatableOptionalPostingParameter(LedgerParameterType.ROUNDING_MODE), + repeatableOptionalPostingParameter(LedgerParameterType.COST_ALLOCATION_METHOD), + repeatableOptionalPostingParameter(LedgerParameterType.SOURCE_COST_PERCENT), + repeatableOptionalPostingParameter(LedgerParameterType.TARGET_COST_PERCENT), + repeatableOptionalPostingParameter(LedgerParameterType.REFERENCE_PRICE), + repeatableOptionalPostingParameter(LedgerParameterType.FAIR_MARKET_VALUE), + repeatableOptionalPostingParameter(LedgerParameterType.VALUATION_PRICE), + repeatableOptionalPostingParameter(LedgerParameterType.FEE_REASON), + repeatableOptionalPostingParameter(LedgerParameterType.TAX_REASON), + repeatableOptionalPostingParameter(LedgerParameterType.TAXABLE_DISTRIBUTION), + repeatableOptionalPostingParameter(LedgerParameterType.WITHHOLDING_TAX), + repeatableOptionalPostingParameter(LedgerParameterType.RECLAIMABLE_TAX), + repeatableOptionalPostingParameter(LedgerParameterType.MANUAL_VALUATION_OVERRIDE)), + SETS.projectionRules( + optionalProjection(LedgerProjectionRole.OLD_SECURITY_LEG, true, false), + optionalProjection(LedgerProjectionRole.NEW_SECURITY_LEG, true, false), + optionalProjection(LedgerProjectionRole.CASH_COMPENSATION, true, true), + optionalProjection(LedgerProjectionRole.DELIVERY_INBOUND, true, false)), + cashCompensationPostingGroupRules(), + SETS.alternativeGroups(dateAlternative("SPIN_OFF_DATE")), //$NON-NLS-1$ + spinOffLegDefinitions(), + LedgerReportingClass.SECURITIES_DISTRIBUTION, + LedgerPerformanceTreatment.COST_BASIS_REALLOCATION, downstreamResults()); + } + + private static LedgerEntryDefinition stockDividend() + { + return corporateActionDefinition( + SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.OPTIONAL), + targetSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), cashCompensationLeg(), + feeLeg(), taxLeg(), forexLeg()), + LedgerReportingClass.SECURITIES_DISTRIBUTION, + 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( + SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.OPTIONAL), + targetSecurityLeg(LedgerLegCardinality.AT_LEAST_ONE), cashCompensationLeg(), + feeLeg(), taxLeg(), forexLeg()), + LedgerReportingClass.SECURITIES_DISTRIBUTION, + LedgerPerformanceTreatment.SECURITY_DISTRIBUTION); + } + + private static LedgerEntryDefinition rightsDistribution() + { + return corporateActionDefinition( + SETS.legDefinitions(securityContextLeg(LedgerLegCardinality.OPTIONAL), + distributedRightLeg(LedgerLegCardinality.AT_LEAST_ONE), + cashCompensationLeg(), feeLeg(), taxLeg(), forexLeg()), + LedgerReportingClass.RIGHTS_EVENT, + LedgerPerformanceTreatment.SECURITY_DISTRIBUTION); + } + + private static LedgerEntryDefinition couponPayment() + { + 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 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); + } + + 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(); + } + + 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(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(), + 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(securityContextLeg(LedgerLegCardinality.AT_LEAST_ONE), + 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 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(), 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, + optionalPostingRulesFor(legDefinitions), + corporateActionEntryParameterRules(), + corporateActionPostingParameterRulesFor(legDefinitions), + Set.of(), + Set.of(), + alternativeRequirementGroups, + componentRequirements, + 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.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), + 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 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, + cardinality) + .requiredParameters(SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG, + LedgerParameterType.RIGHT_SECURITY)) + .optionalParameters(corporateActionPostingOptionalParametersFor(LedgerPostingType.RIGHT)) + .build(); + } + + private static LedgerLegDefinition cashLeg(LedgerLegCardinality cardinality) + { + return LedgerLegDefinition.of(LedgerLegRole.CASH_LEG, LedgerPostingType.CASH, cardinality) + .optionalParameters(corporateActionPostingOptionalParametersFor(LedgerPostingType.CASH)) + .build(); + } + + private static LedgerLegDefinition cashCompensationLeg() + { + return LedgerLegDefinition.of(LedgerLegRole.CASH_COMPENSATION_LEG, + LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.REPEATABLE) + .optionalParameters(cashCompensationOptionalParameters()).build(); + } + + private static LedgerLegDefinition accruedInterestLeg(LedgerLegCardinality cardinality) + { + return LedgerLegDefinition.of(LedgerLegRole.ACCRUED_INTEREST_LEG, + LedgerPostingType.ACCRUED_INTEREST, cardinality) + .optionalParameters(corporateActionPostingOptionalParametersFor( + LedgerPostingType.ACCRUED_INTEREST)) + .build(); + } + + private static LedgerLegDefinition principalRedemptionLeg(LedgerLegCardinality cardinality) + { + return LedgerLegDefinition.of(LedgerLegRole.PRINCIPAL_REDEMPTION_LEG, + LedgerPostingType.PRINCIPAL_REDEMPTION, cardinality) + .requiredParameters(SETS.parameterTypes(LedgerParameterType.CORPORATE_ACTION_LEG)) + .optionalParameters(corporateActionPostingOptionalParametersFor( + LedgerPostingType.PRINCIPAL_REDEMPTION)) + .build(); + } + + private static LedgerLegDefinition feeLeg() + { + return LedgerLegDefinition.of(LedgerLegRole.FEE_LEG, LedgerPostingType.FEE, + LedgerLegCardinality.REPEATABLE) + .optionalParameters(feeOptionalParameters()).build(); + } + + private static LedgerLegDefinition taxLeg() + { + return LedgerLegDefinition.of(LedgerLegRole.TAX_LEG, LedgerPostingType.TAX, + LedgerLegCardinality.REPEATABLE) + .optionalParameters(taxOptionalParameters()).build(); + } + + private static LedgerLegDefinition forexLeg() + { + return LedgerLegDefinition.of(LedgerLegRole.FOREX_CONTEXT_LEG, LedgerPostingType.FOREX, + LedgerLegCardinality.OPTIONAL) + .optionalParameters(forexOptionalParameters()).build(); + } + + private static LedgerPostingRule optionalPosting(LedgerPostingType postingType, + EnumSet requiredParameterTypes, + EnumSet optionalParameterTypes) + { + return LedgerPostingRule.optional(postingType, requiredParameterTypes, optionalParameterTypes); + } + + private static LedgerParameterRule requiredEntryParameter(LedgerParameterType parameterType) + { + return LedgerParameterRule.required(parameterType); + } + + private static LedgerParameterRule optionalEntryParameter(LedgerParameterType parameterType) + { + return LedgerParameterRule.optional(parameterType); + } + + private static LedgerParameterRule repeatableRequiredPostingParameter(LedgerParameterType parameterType) + { + return LedgerParameterRule.repeatable(parameterType, LedgerRequirement.REQUIRED); + } + + private static LedgerParameterRule repeatableOptionalEntryParameter(LedgerParameterType parameterType) + { + return LedgerParameterRule.repeatable(parameterType, LedgerRequirement.OPTIONAL); + } + + private static LedgerParameterRule repeatableOptionalPostingParameter(LedgerParameterType parameterType) + { + return LedgerParameterRule.repeatable(parameterType, LedgerRequirement.OPTIONAL); + } + + private static LedgerProjectionRule optionalProjection(LedgerProjectionRole role, boolean primaryPostingExpected, + boolean postingGroupExpected) + { + return LedgerProjectionRule.optional(role, primaryPostingExpected, postingGroupExpected); + } + + private static LedgerRequirementGroup dateAlternative(String name) + { + return LedgerRequirementGroup.parameterTypes(name, LedgerRequirement.REQUIRED, + SETS.parameterTypes(LedgerParameterType.EX_DATE, LedgerParameterType.EFFECTIVE_DATE)); + } + + private static Set primaryMovementGroup(String name, LedgerPrimaryMovement first, + LedgerPrimaryMovement... rest) + { + return SETS.alternativeGroups(LedgerRequirementGroup.primaryMovements(name, LedgerRequirement.REQUIRED, + 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, + 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.REPEATABLE) + .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.AT_LEAST_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.SECURITY_CONTEXT_LEG, LedgerPostingType.SECURITY, + LedgerLegCardinality.AT_LEAST_ONE) + .requiredParameters(SETS.parameterTypes( + LedgerParameterType.CORPORATE_ACTION_LEG)) + .optionalParameters(spinOffSecurityOptionalParameters()).build(), + LedgerLegDefinition.of(LedgerLegRole.CASH_COMPENSATION_LEG, + LedgerPostingType.CASH_COMPENSATION, LedgerLegCardinality.REPEATABLE) + .optionalParameters(cashCompensationOptionalParameters()) + .projection(LedgerProjectionRole.CASH_COMPENSATION, true, true) + .group(CASH_COMPENSATION_GROUP).build(), + LedgerLegDefinition.of(LedgerLegRole.FEE_LEG, LedgerPostingType.FEE, + LedgerLegCardinality.REPEATABLE) + .optionalParameters(feeOptionalParameters()) + .group(CASH_COMPENSATION_GROUP).build(), + LedgerLegDefinition.of(LedgerLegRole.TAX_LEG, LedgerPostingType.TAX, + LedgerLegCardinality.REPEATABLE) + .optionalParameters(taxOptionalParameters()) + .group(CASH_COMPENSATION_GROUP).build(), + LedgerLegDefinition.of(LedgerLegRole.FOREX_CONTEXT_LEG, LedgerPostingType.FOREX, + LedgerLegCardinality.OPTIONAL) + .optionalParameters(forexOptionalParameters()).build()); + } + + private static EnumSet spinOffSourceSecurityLegOptionalParameters() + { + var parameters = spinOffSecurityOptionalParameters(); + parameters.add(LedgerParameterType.TARGET_SECURITY); + return parameters; + } + + private static EnumSet spinOffTargetSecurityLegOptionalParameters() + { + var parameters = spinOffSecurityOptionalParameters(); + parameters.add(LedgerParameterType.SOURCE_SECURITY); + return parameters; + } + + private static EnumSet spinOffSecurityOptionalParameters() + { + return SETS.parameterTypes(LedgerParameterType.FRACTION_QUANTITY, + LedgerParameterType.FRACTION_TREATMENT, LedgerParameterType.ROUNDING_MODE, + LedgerParameterType.COST_ALLOCATION_METHOD, LedgerParameterType.SOURCE_COST_PERCENT, + LedgerParameterType.TARGET_COST_PERCENT, LedgerParameterType.REFERENCE_PRICE, + LedgerParameterType.FAIR_MARKET_VALUE, LedgerParameterType.VALUATION_PRICE, + LedgerParameterType.MANUAL_VALUATION_OVERRIDE); + } + + private static EnumSet cashCompensationOptionalParameters() + { + return SETS.parameterTypes(LedgerParameterType.CASH_ACCOUNT, + LedgerParameterType.CASH_COMPENSATION_KIND, LedgerParameterType.CASH_IN_LIEU_AMOUNT, + LedgerParameterType.CASH_IN_LIEU_APPLIED, LedgerParameterType.FRACTION_QUANTITY, + LedgerParameterType.FRACTION_TREATMENT, LedgerParameterType.ROUNDING_MODE, + LedgerParameterType.PAYMENT_DATE, LedgerParameterType.SETTLEMENT_DATE, + LedgerParameterType.FEE_REASON, LedgerParameterType.TAX_REASON, + LedgerParameterType.CORPORATE_ACTION_LEG); + } + + private static EnumSet feeOptionalParameters() + { + return SETS.parameterTypes(LedgerParameterType.FEE_REASON, LedgerParameterType.STAMP_DUTY, + LedgerParameterType.EVENT_REFERENCE, LedgerParameterType.PAYMENT_DATE, + LedgerParameterType.SETTLEMENT_DATE, LedgerParameterType.CORPORATE_ACTION_LEG); + } + + private static EnumSet taxOptionalParameters() + { + return SETS.parameterTypes(LedgerParameterType.TAX_REASON, + LedgerParameterType.TAXABLE_DISTRIBUTION, LedgerParameterType.WITHHOLDING_TAX, + LedgerParameterType.TRANSACTION_TAX, LedgerParameterType.STAMP_DUTY, + LedgerParameterType.RECLAIMABLE_TAX, LedgerParameterType.EVENT_REFERENCE, + LedgerParameterType.PAYMENT_DATE, LedgerParameterType.SETTLEMENT_DATE, + LedgerParameterType.CORPORATE_ACTION_LEG); + } + + private static EnumSet forexOptionalParameters() + { + return SETS.parameterTypes(LedgerParameterType.REFERENCE_PRICE, + LedgerParameterType.VALUATION_PRICE, LedgerParameterType.EVENT_REFERENCE); + } + + private static Set cashCompensationPostingGroupRules() + { + return SETS.postingGroupRules(LedgerPostingGroupRule.of(CASH_COMPENSATION_GROUP, + LedgerRequirement.OPTIONAL, + SETS.postingTypes(LedgerPostingType.CASH_COMPENSATION, LedgerPostingType.FEE, + LedgerPostingType.TAX), + SETS.projectionRoles(LedgerProjectionRole.CASH_COMPENSATION), true)); + } + + private static EnumSet downstreamResults() + { + return EnumSet.allOf(LedgerDownstreamResult.class); + } + + private static final class SetBuilder + { + private EnumSet postingTypes(LedgerPostingType first, LedgerPostingType... rest) + { + return EnumSet.of(first, rest); + } + + private EnumSet parameterTypes(LedgerParameterType... values) + { + var set = EnumSet.noneOf(LedgerParameterType.class); + + for (var value : values) + set.add(value); + + return set; + } + + private EnumSet projectionRoles(LedgerProjectionRole first, LedgerProjectionRole... rest) + { + return EnumSet.of(first, rest); + } + + private EnumSet primaryMovements(LedgerPrimaryMovement first, + LedgerPrimaryMovement... 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 componentRequirements(LedgerComponentRequirement first, + LedgerComponentRequirement... 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..9cc0bbe623 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEntryType.java @@ -0,0 +1,108 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.HashSet; +import java.util.Objects; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; + +/** + * Defines stable Ledger type codes used by persistence and validation. This is Ledger configuration metadata; normal + * transaction-editing code should use higher-level write paths. + */ +@SuppressWarnings("nls") +public enum LedgerEntryType +{ + DEPOSIT("DEPOSIT", Shape.LEGACY_FIXED), + REMOVAL("REMOVAL", Shape.LEGACY_FIXED), + INTEREST("INTEREST", Shape.LEGACY_FIXED), + INTEREST_CHARGE("INTEREST_CHARGE", Shape.LEGACY_FIXED), + FEES("FEES", Shape.LEGACY_FIXED), + FEES_REFUND("FEES_REFUND", Shape.LEGACY_FIXED), + TAXES("TAXES", Shape.LEGACY_FIXED), + TAX_REFUND("TAX_REFUND", Shape.LEGACY_FIXED), + DIVIDENDS("DIVIDENDS", Shape.LEGACY_FIXED), + BUY("BUY", Shape.LEGACY_FIXED), + SELL("SELL", Shape.LEGACY_FIXED), + CASH_TRANSFER("CASH_TRANSFER", Shape.LEGACY_FIXED), + SECURITY_TRANSFER("SECURITY_TRANSFER", Shape.LEGACY_FIXED), + DELIVERY_INBOUND("DELIVERY_INBOUND", Shape.LEGACY_FIXED), + DELIVERY_OUTBOUND("DELIVERY_OUTBOUND", Shape.LEGACY_FIXED), + CORPORATE_ACTION("CORPORATE_ACTION", Shape.LEDGER_NATIVE_TARGETED); + + private enum Shape + { + LEGACY_FIXED, + LEDGER_NATIVE_TARGETED + } + + private final String code; + + private final Shape shape; + + static + { + var codes = new HashSet(); + + for (LedgerEntryType type : values()) + { + if (type.code.isBlank()) + throw new IllegalStateException( + LedgerDiagnosticCode.LEDGER_CORE_017.message("Blank LedgerEntryType code")); + + if (!codes.add(type.code)) + throw new IllegalStateException( + LedgerDiagnosticCode.LEDGER_CORE_017.message("Duplicate LedgerEntryType code: " + + type.code)); + } + } + + private LedgerEntryType(String code, Shape shape) + { + this.code = Objects.requireNonNull(code); + this.shape = Objects.requireNonNull(shape); + } + + public String getCode() + { + return code; + } + + public static LedgerEntryType fromCode(String code) + { + if (code == null || code.isBlank()) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_017.message("Missing LedgerEntryType code")); + + for (LedgerEntryType type : values()) + if (type.code.equals(code)) + return type; + + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_017.message("Unknown LedgerEntryType code: " + code)); + } + + public boolean isLegacyFixedShape() + { + return shape == Shape.LEGACY_FIXED; + } + + public boolean isLedgerNativeTargeted() + { + return shape == Shape.LEDGER_NATIVE_TARGETED; + } + + public boolean requiresTargetedDerivedDescriptors() + { + return isLedgerNativeTargeted(); + } + + public boolean supportsDerivedDescriptors() + { + return shape == Shape.LEGACY_FIXED || shape == Shape.LEDGER_NATIVE_TARGETED; + } + + public boolean usesSignedTargetedProjectionFacts() + { + return shape == Shape.LEDGER_NATIVE_TARGETED; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEventParameterDefinition.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEventParameterDefinition.java new file mode 100644 index 0000000000..d148948d14 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerEventParameterDefinition.java @@ -0,0 +1,45 @@ +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.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, + LedgerParameterType.SETTLEMENT_DATE, LedgerParameterType.ELECTION_DEADLINE)); + + private LedgerEventParameterDefinition() + { + } + + public static Set getParameterTypes() + { + return PARAMETER_TYPES; + } + + public static boolean supportsParameterType(LedgerParameterType parameterType) + { + return PARAMETER_TYPES.contains(parameterType); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegCardinality.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegCardinality.java new file mode 100644 index 0000000000..d1ab046952 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegCardinality.java @@ -0,0 +1,14 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +/** + * Defines how often a configured Ledger leg may appear inside one native entry. + * This is Java-only configuration metadata. Persisted files store concrete + * postings and semantic projection facts, not leg cardinality rules. + */ +public enum LedgerLegCardinality +{ + EXACTLY_ONE, + AT_LEAST_ONE, + OPTIONAL, + REPEATABLE +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegDefinition.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegDefinition.java new file mode 100644 index 0000000000..3bd6d0d276 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegDefinition.java @@ -0,0 +1,199 @@ +package name.abuchen.portfolio.model.ledger.configuration; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import name.abuchen.portfolio.model.LedgerDiagnosticCode; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; + +/** + * Describes one functional leg or component inside a native Ledger entry. + * This is Java-only configuration metadata. It connects a business leg role to + * a generic posting type, parameter rules, optional projection expectations, + * and optional grouping metadata. + */ +public final class LedgerLegDefinition +{ + private final LedgerLegRole role; + private final LedgerPostingType postingType; + private final LedgerLegCardinality cardinality; + private final Set requiredParameterTypes; + private final Set optionalParameterTypes; + private final LedgerProjectionRole projectionRole; + private final boolean primaryPostingExpected; + private final boolean postingGroupExpected; + private final Set groupNames; + private final LedgerReportingClass reportingClass; + private final LedgerPerformanceTreatment performanceTreatment; + + private LedgerLegDefinition(Builder builder) + { + this.role = Objects.requireNonNull(builder.role); + this.postingType = Objects.requireNonNull(builder.postingType); + this.cardinality = Objects.requireNonNull(builder.cardinality); + this.requiredParameterTypes = copyParameterTypes(builder.requiredParameterTypes); + this.optionalParameterTypes = copyParameterTypes(builder.optionalParameterTypes); + this.projectionRole = builder.projectionRole; + this.primaryPostingExpected = builder.primaryPostingExpected; + this.postingGroupExpected = builder.postingGroupExpected; + this.groupNames = copyGroupNames(builder.groupNames); + this.reportingClass = Objects.requireNonNull(builder.reportingClass); + this.performanceTreatment = Objects.requireNonNull(builder.performanceTreatment); + } + + public static Builder of(LedgerLegRole role, LedgerPostingType postingType, LedgerLegCardinality cardinality) + { + return new Builder(role, postingType, cardinality); + } + + public LedgerLegRole getRole() + { + return role; + } + + public LedgerPostingType getPostingType() + { + return postingType; + } + + public LedgerLegCardinality getCardinality() + { + return cardinality; + } + + public Set getRequiredParameterTypes() + { + return requiredParameterTypes; + } + + public Set getOptionalParameterTypes() + { + return optionalParameterTypes; + } + + public Optional getProjectionRole() + { + return Optional.ofNullable(projectionRole); + } + + public boolean isPrimaryPostingExpected() + { + return primaryPostingExpected; + } + + public boolean isPostingGroupExpected() + { + return postingGroupExpected; + } + + public Set getGroupNames() + { + return groupNames; + } + + public LedgerReportingClass getReportingClass() + { + return reportingClass; + } + + public LedgerPerformanceTreatment getPerformanceTreatment() + { + return performanceTreatment; + } + + private static Set copyParameterTypes(Set values) + { + var copy = EnumSet.noneOf(LedgerParameterType.class); + + for (var value : values) + copy.add(Objects.requireNonNull(value)); + + return Collections.unmodifiableSet(copy); + } + + private static Set copyGroupNames(Set values) + { + var copy = new LinkedHashSet(); + + for (var value : values) + { + if (value == null || value.isBlank()) + throw new IllegalArgumentException( + LedgerDiagnosticCode.LEDGER_CORE_018.message("Ledger leg group name is required")); //$NON-NLS-1$ + + copy.add(value); + } + + return Collections.unmodifiableSet(copy); + } + + public static final class Builder + { + private final LedgerLegRole role; + private final LedgerPostingType postingType; + private final LedgerLegCardinality cardinality; + private final Set requiredParameterTypes = EnumSet.noneOf(LedgerParameterType.class); + private final Set optionalParameterTypes = EnumSet.noneOf(LedgerParameterType.class); + private LedgerProjectionRole projectionRole; + private boolean primaryPostingExpected; + private boolean postingGroupExpected; + private final Set groupNames = new LinkedHashSet(); + private LedgerReportingClass reportingClass = LedgerReportingClass.UNDEFINED; + private LedgerPerformanceTreatment performanceTreatment = LedgerPerformanceTreatment.UNDEFINED; + + private Builder(LedgerLegRole role, LedgerPostingType postingType, LedgerLegCardinality cardinality) + { + this.role = Objects.requireNonNull(role); + this.postingType = Objects.requireNonNull(postingType); + this.cardinality = Objects.requireNonNull(cardinality); + } + + public Builder requiredParameters(Set values) + { + requiredParameterTypes.addAll(values); + return this; + } + + public Builder optionalParameters(Set values) + { + optionalParameterTypes.addAll(values); + return this; + } + + public Builder projection(LedgerProjectionRole role, boolean primaryPostingExpected, + boolean postingGroupExpected) + { + this.projectionRole = Objects.requireNonNull(role); + this.primaryPostingExpected = primaryPostingExpected; + this.postingGroupExpected = postingGroupExpected; + return this; + } + + public Builder group(String name) + { + groupNames.add(name); + return this; + } + + public Builder reportingClass(LedgerReportingClass reportingClass) + { + this.reportingClass = Objects.requireNonNull(reportingClass); + return this; + } + + public Builder performanceTreatment(LedgerPerformanceTreatment performanceTreatment) + { + this.performanceTreatment = Objects.requireNonNull(performanceTreatment); + return this; + } + + public LedgerLegDefinition build() + { + return new LedgerLegDefinition(this); + } + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegRole.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegRole.java new file mode 100644 index 0000000000..4abc10098e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerLegRole.java @@ -0,0 +1,25 @@ +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, + SECURITY_CONTEXT_LEG, + RECEIVED_SECURITY_LEG, + DISTRIBUTED_RIGHT_LEG, + DISTRIBUTED_SECURITY_LEG, + SOURCE_BOND_LEG, + CASH_LEG, + CASH_COMPENSATION_LEG, + ACCRUED_INTEREST_LEG, + PRINCIPAL_REDEMPTION_LEG, + FEE_LEG, + TAX_LEG, + FOREX_CONTEXT_LEG +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java new file mode 100644 index 0000000000..4830605aa8 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerNativeEntryDefinitionValidator.java @@ -0,0 +1,1010 @@ +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; +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.CorporateActionBasisAllocation; +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.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. + * 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, + 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, + 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, + LEG_LOCAL_KEY_REQUIRED, + LEG_LOCAL_KEY_DUPLICATE + } + + 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(entry); + 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); + validateComponentRequirements(entry, definition.get(), issues); + validateBasisTreatment(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)) + { + 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$ + } + } + } + + 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); + + if (!group.getPrimaryMovements().isEmpty()) + return entry.getPostings().stream() + .anyMatch(posting -> group.getPrimaryMovements().stream() + .anyMatch(movement -> movement.matches(posting))); + + return false; + } + + 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); + + validateCardinality(entry, leg, match.postings(), issues); + validateLegParameters(entry, leg, match.postings(), issues); + } + } + + 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 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) + { + 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) + { + var projectionRole = leg.getProjectionRole(); + + if (projectionRole.isPresent()) + 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, List issues) + { + 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(); + + 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$ + entry) + .withDetail("legRole", leg.getRole()) //$NON-NLS-1$ + .withDetail("projectionRole", projectionRole)); //$NON-NLS-1$ + + for (var ref : refs) + { + var posting = ref.getPrimaryPosting(); + if (posting != null) + { + if (postingMatchesLeg(entry.getType(), posting, leg)) + matchingPostings.add(posting); + 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$ + + leg.getRole()), + entry) + .withPosting(posting) + .withDetail("legRole", leg.getRole())); //$NON-NLS-1$ + } + + if (leg.isPostingGroupExpected()) + { + 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).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 derived descriptors: " + leg.getRole()), //$NON-NLS-1$ + entry) + .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); + } + + 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) + { + 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$ + else + validateRepeatedPrimaryKeys(entry, leg, postings, issues); + 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: + validateRepeatedPrimaryKeys(entry, leg, postings, issues); + break; + default: + throw new IllegalStateException(LedgerDiagnosticCode.LEDGER_STRUCT_052 + .message("Unhandled LedgerLegCardinality: " + leg.getCardinality())); //$NON-NLS-1$ + } + } + + 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) + { + 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.CORPORATE_ACTION) + { + 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 (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) + 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) + 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 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(); + } + + 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 + { + postings = List.copyOf(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; + + 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 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..305c99fbf5 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerParameterCodeDomain.java @@ -0,0 +1,62 @@ +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, + CORPORATE_ACTION_BASIS_STATUS, + CORPORATE_ACTION_BASIS_METHOD, + 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 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()); + 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..1a816219f0 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/LedgerParameterType.java @@ -0,0 +1,223 @@ +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), + 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), + 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/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; + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerParameterRule.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerParameterRule.java new file mode 100644 index 0000000000..0480e5e37e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerParameterRule.java @@ -0,0 +1,75 @@ +package name.abuchen.portfolio.model.ledger.configuration.rule; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.configuration.LedgerParameterType; + +/** + * Describes a validation rule for Ledger entry configuration. + * This is configuration metadata used by Ledger infrastructure. Normal transaction-editing + * code should rely on creators, editors, converters, or validators that consume these rules. + * + *

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

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

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

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

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

+ */ +public final class LedgerPostingRule +{ + private final LedgerPostingType postingType; + private final LedgerRequirement requirement; + private final Set requiredParameterTypes; + private final Set optionalParameterTypes; + private final Set repeatableParameterTypes; + + private LedgerPostingRule(LedgerPostingType postingType, LedgerRequirement requirement, + Set requiredParameterTypes, Set optionalParameterTypes, + Set repeatableParameterTypes) + { + this.postingType = Objects.requireNonNull(postingType); + this.requirement = Objects.requireNonNull(requirement); + this.requiredParameterTypes = copyOf(requiredParameterTypes); + this.optionalParameterTypes = copyOf(optionalParameterTypes); + this.repeatableParameterTypes = copyOf(repeatableParameterTypes); + } + + public static LedgerPostingRule required(LedgerPostingType postingType, + Set requiredParameterTypes, Set optionalParameterTypes) + { + return of(postingType, LedgerRequirement.REQUIRED, requiredParameterTypes, optionalParameterTypes, + EnumSet.noneOf(LedgerParameterType.class)); + } + + public static LedgerPostingRule optional(LedgerPostingType postingType, + Set requiredParameterTypes, Set optionalParameterTypes) + { + return of(postingType, LedgerRequirement.OPTIONAL, requiredParameterTypes, optionalParameterTypes, + EnumSet.noneOf(LedgerParameterType.class)); + } + + private static LedgerPostingRule of(LedgerPostingType postingType, LedgerRequirement requirement, + Set requiredParameterTypes, Set optionalParameterTypes, + Set repeatableParameterTypes) + { + return new LedgerPostingRule(postingType, requirement, requiredParameterTypes, optionalParameterTypes, + repeatableParameterTypes); + } + + public LedgerPostingType getPostingType() + { + return postingType; + } + + public LedgerRequirement getRequirement() + { + return requirement; + } + + public boolean isRequired() + { + return requirement == LedgerRequirement.REQUIRED; + } + + public boolean isOptional() + { + return requirement == LedgerRequirement.OPTIONAL; + } + + public Set getRequiredParameterTypes() + { + return requiredParameterTypes; + } + + public Set getOptionalParameterTypes() + { + return optionalParameterTypes; + } + + public Set getRepeatableParameterTypes() + { + return repeatableParameterTypes; + } + + private static Set copyOf(Set values) + { + Objects.requireNonNull(values); + + var copy = EnumSet.noneOf(LedgerParameterType.class); + + for (var value : values) + copy.add(Objects.requireNonNull(value)); + + return Collections.unmodifiableSet(copy); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/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/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..9cde811eba --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/configuration/rule/LedgerRequirementGroup.java @@ -0,0 +1,144 @@ +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 final Set primaryMovements; + + private LedgerRequirementGroup(String name, LedgerRequirement requirement, Set postingTypes, + 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() && this.primaryMovements.isEmpty()) + throw new IllegalArgumentException(LedgerDiagnosticCode.LEDGER_CORE_025 + .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), + EnumSet.noneOf(LedgerPrimaryMovement.class)); + } + + public static LedgerRequirementGroup parameterTypes(String name, LedgerRequirement requirement, + Set 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() + { + 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; + } + + public Set getPrimaryMovements() + { + return primaryMovements; + } + + 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); + } + + private static 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); + } +} 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 new file mode 100644 index 0000000000..ee6e11b459 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/legacy/LegacyTransactionToLedgerMigrator.java @@ -0,0 +1,1169 @@ +package name.abuchen.portfolio.model.ledger.legacy; + +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; +import java.util.List; +import java.util.Map; +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.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.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. + * 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(); + } + + 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) + { + for (var account : client.getAccounts()) + { + for (var transaction : List.copyOf(account.getTransactions())) + { + if (transaction instanceof LedgerBackedTransaction) + continue; + + plan.markInspected(); + + 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; + + plan.markInspected(); + + 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.markPrimary(posting, MigrationGraphBuilder.semanticRole(postingType), + LedgerPostingDirection.NEUTRAL, LedgerProjectionRole.ACCOUNT); + MigrationGraphBuilder.addPosting(entry, posting); + MigrationGraphBuilder.addUnitPostings(entry, transaction); + + 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.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); + MigrationGraphBuilder.addUnitPostings(entry, portfolioTransaction); + + 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.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); + + 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.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); + + 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.markPrimary(posting, LedgerPostingSemanticRole.SECURITY, + entryType == LedgerEntryType.DELIVERY_INBOUND ? LedgerPostingDirection.INBOUND + : LedgerPostingDirection.OUTBOUND, + role); + MigrationGraphBuilder.addPosting(entry, posting); + MigrationGraphBuilder.addUnitPostings(entry, transaction); + + 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.getprojectionIdsToRemove(), 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.getprojectionIdsToRemove(), plan.getLegacyTransactionsToRemove())) + continue; + + var entry = plan.getMigratedEntry(transaction); + + if (entry == null) + continue; + + 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()); + MigrationGraphBuilder.setUpdatedAt(entry, transaction); + investmentPlan.getTransactions().remove(index); + } + } + } + + 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(migratedUpdatedAt(transaction)); + + 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 addEntry(Ledger ledger, LedgerEntry entry) + { + ledger.addEntry(entry); + } + + private static void setUpdatedAt(LedgerEntry entry, Transaction transaction) + { + 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) + { + entry.addPosting(posting); + } + + 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()); + } + + 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$ + 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 Map migratedEntriesByTransaction = new IdentityHashMap<>(); + private final 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; + + private MigrationPlan(Client client) + { + this.client = client; + } + + private void markInspected() + { + inspectedTransactionCount++; + } + + private void addEntry(LedgerEntry entry, Transaction... transactions) + { + entries.add(entry); + + for (var transaction : transactions) + { + markMigrated(transaction); + migratedEntriesByTransaction.put(transaction, entry); + } + + plannedEntryUUIDs.add(entry.getUUID()); + + MigrationGraphBuilder.setUpdatedAt(entry, transactions[0]); + } + + private boolean handleAlreadyMigratedCompleteGroup(String family, LedgerEntry expectedEntry, + Transaction... transactions) + { + if (plannedEntryUUIDs.contains(expectedEntry.getUUID())) + { + addDiagnostic(family, "DUPLICATE_CONFLICT", "plannedEntryConflict", transactions); //$NON-NLS-1$ //$NON-NLS-2$ + return true; + } + + var matchingEntries = existingEntriesMatching(expectedEntry); + + if (matchingEntries.isEmpty()) + return false; + + if (matchingEntries.size() == 1 && 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$ + "existingEntryConflict mismatch=" + duplicateMismatch(matchingEntries), //$NON-NLS-1$ + transactions); + return true; + } + + private void markMigrated(Transaction transaction) + { + legacyTransactionsToRemove.add(transaction); + projectionIdsToRemove.add(transaction.getUUID()); + migratedTransactionCount++; + } + + private void markAlreadyMigrated(Transaction transaction) + { + legacyTransactionsToRemove.add(transaction); + projectionIdsToRemove.add(transaction.getUUID()); + } + + private List existingEntriesMatching(LedgerEntry expectedEntry) + { + var result = new ArrayList(); + var expectedDescriptors = LedgerProjectionSupport.descriptors(expectedEntry); + + for (var entry : client.getLedger().getEntries()) + { + if (entry.getUUID().equals(expectedEntry.getUUID()) || entry.getType() == expectedEntry.getType() + || hasOwnerOverlap(entry, expectedDescriptors)) + result.add(entry); + } + + return result; + } + + private boolean hasOwnerOverlap(LedgerEntry entry, + List expectedDescriptors) + { + 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 false; + } + + private boolean isStructurallyValidExistingEntry(LedgerEntry entry) + { + var ledger = new Ledger(); + + MigrationGraphBuilder.addEntry(ledger, entry); + + return LedgerStructuralValidator.validate(ledger).isOK(); + } + + private SemanticMismatch duplicateMismatch(List matchingEntries) + { + if (matchingEntries.size() != 1) + 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 (!postingTypeCounts(existingEntry).equals(postingTypeCounts(expectedEntry))) + return SemanticMismatch.POSTING_TYPE_SHAPE; + + if (!sameUnitPostingFacts(existingEntry, expectedEntry)) + return SemanticMismatch.UNIT_POSTINGS; + + List existingDescriptors; + + try + { + existingDescriptors = LedgerProjectionSupport.descriptors(existingEntry); + } + catch (IllegalArgumentException e) + { + 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; + + if (existingProjection.getRole() != expectedProjection.getRole()) + return SemanticMismatch.PROJECTION_ROLE; + + if (existingProjection.getAccount() != expectedProjection.getAccount() + || existingProjection.getPortfolio() != expectedProjection.getPortfolio()) + return SemanticMismatch.PROJECTION_OWNER; + + var expectedPosting = expectedProjection.getPrimaryPosting(); + var existingPosting = existingProjection.getPrimaryPosting(); + + 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 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 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 LedgerEntry getMigratedEntry(Transaction transaction) + { + return migratedEntriesByTransaction.get(transaction); + } + + private Set getprojectionIdsToRemove() + { + return projectionIdsToRemove; + } + + private List getDiagnostics() + { + return diagnostics; + } + + private int getMigratedTransactionCount() + { + return applied ? migratedTransactionCount : 0; + } + + private int getInspectedTransactionCount() + { + return inspectedTransactionCount; + } + + 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 int inspectedLegacyTransactionCount; + private final int preservedLegacyTransactionCount; + private final int failedCount; + private final int mixedStateCount; + private final 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; + } + + public boolean hasDiagnostics() + { + return !diagnostics.isEmpty(); + } + } +} 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..0dab750acf --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionBuilder.java @@ -0,0 +1,491 @@ +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 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); + } + + 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 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(); + 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 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.setCurrency(client.getBaseCurrency()); + 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)); + 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..f6f6a1049c --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerCorporateActionEditSupport.java @@ -0,0 +1,101 @@ +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.LedgerEntryType; +import name.abuchen.portfolio.model.ledger.configuration.LedgerLegRole; +import name.abuchen.portfolio.model.ledger.configuration.LedgerNativeEntryDefinitionValidator; + +/** + * 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()); + }); + } + + 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) + { + Objects.requireNonNull(entry); + Objects.requireNonNull(role); + Objects.requireNonNull(localKey); + + if (entry.getType() != LedgerEntryType.CORPORATE_ACTION) + return List.of(); + + return entry.getPostings().stream() // + .filter(posting -> LedgerCorporateActionLegRoles.matches(posting, role)) // + .filter(posting -> localKey.equals(posting.getLocalKey())) // + .filter(posting -> groupKey == null || groupKey.equals(posting.getGroupKey())) // + .toList(); + } +} 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); + } +} 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..86b4d91bc3 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryAssembler.java @@ -0,0 +1,581 @@ +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.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.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 semantic projection facts. + *

+ */ +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 static LedgerCorporateActionBuilder corporateAction(Client client) + { + return new LedgerCorporateActionBuilder(client); + } + + public LedgerCorporateActionBuilder corporateAction() + { + return new LedgerCorporateActionBuilder(client); + } + + 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.CORPORATE_ACTION); + } + + 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 cashCompensations = new ArrayList<>(); + private final List fees = new ArrayList<>(); + private final List taxes = new ArrayList<>(); + private NativeEntryMetadata metadata; + private NativeCorporateActionEvent event; + + 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) + { + cashCompensations.add(Objects.requireNonNull(compensation)); + return this; + } + + public EntryBuilder cashCompensationMovement(NativeCashCompensation compensation) + { + cashCompensations.add(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); + + var compensationPostings = new ArrayList(); + for (var compensation : cashCompensations) + compensationPostings.add(new CashCompensationPosting(compensation, + addCashCompensation(entry, compensation))); + + for (var fee : fees) + addFee(entry, fee); + + for (var tax : taxes) + addTax(entry, tax); + + for (var compensationPosting : compensationPostings) + addCashCompensationProjection(entry, compensationPosting.compensation(), + compensationPosting.posting()); + + 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); + } + + 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()); + 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(), + 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()); + 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, + 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()); + markUnit(posting, LedgerPostingSemanticRole.FEE, LedgerPostingUnitRole.FEE, + semanticGroupKey(fee.getGroupKey(), LedgerProjectionRole.CASH_COMPENSATION.name())); + posting.setLocalKey(fee.getLocalKey()); + 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()); + markUnit(posting, LedgerPostingSemanticRole.TAX, LedgerPostingUnitRole.TAX, + semanticGroupKey(tax.getGroupKey(), LedgerProjectionRole.CASH_COMPENSATION.name())); + posting.setLocalKey(tax.getLocalKey()); + 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 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 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) + { + 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) + { + 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$ + + // 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) + { + 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); + } + } + + private record CashCompensationPosting(NativeCashCompensation compensation, LedgerPosting posting) + { + } + } +} 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..e2602aecd4 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/LedgerNativeEntryBuildResult.java @@ -0,0 +1,38 @@ +package name.abuchen.portfolio.model.ledger.nativeentry; + +import java.util.Objects; + +import name.abuchen.portfolio.model.ledger.LedgerEntry; +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} becomes + * 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 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..e046c00182 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeCashCompensation.java @@ -0,0 +1,158 @@ +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 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); + } + + public static Builder builder() + { + return new Builder(); + } + + Account getAccount() + { + return account; + } + + Money getAmount() + { + return amount; + } + + String getGroupKey() + { + return groupKey; + } + + String getLocalKey() + { + return localKey; + } + + List getParameters() + { + return parameters; + } + + 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() + { + } + + public Builder account(Account account) + { + this.account = account; + return this; + } + + public Builder amount(Money amount) + { + this.amount = 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); + } + + 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..984ba915c3 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeFee.java @@ -0,0 +1,140 @@ +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 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); + } + + 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; + } + + String getGroupKey() + { + return groupKey; + } + + String getLocalKey() + { + return localKey; + } + + List getParameters() + { + return parameters; + } + + 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() + { + } + + public Builder account(Account account) + { + this.account = account; + return this; + } + + public Builder amount(Money amount) + { + this.amount = 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); + } + + 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..829b727940 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeSecurityLeg.java @@ -0,0 +1,227 @@ +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 semantic projection facts. + *

+ */ +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 String groupKey; + private final String localKey; + 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.groupKey = builder.groupKey; + this.localKey = builder.localKey; + 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); + } + + 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(), + 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; + } + + String getGroupKey() + { + return groupKey; + } + + String getLocalKey() + { + return localKey; + } + + 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 String groupKey; + private String localKey; + 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; + } + + 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; + 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..0b6ff9811e --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/nativeentry/NativeTax.java @@ -0,0 +1,151 @@ +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 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); + } + + 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; + } + + String getGroupKey() + { + return groupKey; + } + + String getLocalKey() + { + return localKey; + } + + List getParameters() + { + return parameters; + } + + 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() + { + } + + public Builder account(Account account) + { + this.account = account; + return this; + } + + public Builder amount(Money amount) + { + this.amount = 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); + } + + 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; + } +} 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..f2731c6231 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptor.java @@ -0,0 +1,124 @@ +package name.abuchen.portfolio.model.ledger.projection; + +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; +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 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); + this.viewKind = Objects.requireNonNull(viewKind); + this.account = account; + 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); + } + + public LedgerEntry getEntry() + { + return entry; + } + + public String getRuntimeProjectionId() + { + var projectionId = entry.getUUID() + ":" + role; //$NON-NLS-1$ + + if (semanticInstanceKey == null) + return projectionId; + + return projectionId + ":" + semanticInstanceKey; //$NON-NLS-1$ + } + + 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 Optional getSemanticInstanceKey() + { + return Optional.ofNullable(semanticInstanceKey); + } + + public boolean hasSemanticInstanceKey() + { + return semanticInstanceKey != null; + } + + public String getPrimarySelector() + { + return primarySelector; + } + + 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 new file mode 100644 index 0000000000..c9fea3799a --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/DerivedProjectionDescriptorService.java @@ -0,0 +1,745 @@ +package name.abuchen.portfolio.model.ledger.projection; + +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; +import java.util.stream.Collectors; + +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.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; + +/** + * Derives runtime projection descriptors from Ledger entry type and posting + * semantics. The descriptors are runtime-only compatibility view metadata; + * they are not persisted Ledger facts. + */ +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().or(legacyAccountPrimary(entry.getType())), diagnostics) + .ifPresent(descriptors::add); + case BUY, SELL -> { + account(entry, LedgerProjectionRole.ACCOUNT, + primary().and(semantic(LedgerPostingSemanticRole.CASH)) + .or(legacyPrimary(LedgerPostingType.CASH)), diagnostics) + .ifPresent(descriptors::add); + portfolio(entry, LedgerProjectionRole.PORTFOLIO, + 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)) + .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)) + .or(legacyPrimary(LedgerPostingType.SECURITY)), + 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 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$ + } + + 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).isPresent()) + { + spinOff(entry, descriptors, diagnostics); + return; + } + + if (kind.filter(this::isSecurityInCorporateAction).isPresent()) + { + securityInCorporateAction(entry, descriptors, diagnostics); + return; + } + + if (kind.filter(this::isFixedIncomeRedemptionCorporateAction).isPresent()) + { + fixedIncomeRedemptionCorporateAction(entry, descriptors, diagnostics); + return; + } + + if (kind.filter(this::isSecurityReorganizationCorporateAction).isPresent()) + { + securityReorganizationCorporateAction(entry, descriptors, diagnostics); + return; + } + + if (kind.filter(this::isOpenMovementCorporateAction).isPresent()) + { + openMovementCorporateAction(entry, descriptors, diagnostics); + return; + } + + if (kind.filter(k -> 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 boolean isSecurityReorganizationCorporateAction(CorporateActionKind kind) + { + return switch (kind) + { + case CONVERSION, EXCHANGE -> true; + default -> false; + }; + } + + private boolean isOpenMovementCorporateAction(CorporateActionKind kind) + { + return switch (kind) + { + case DEFAULTED_INTEREST, RESTRUCTURING, DEFAULT -> true; + default -> false; + }; + } + + 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, + 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, + 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); + } + + private void cashOrientedCorporateAction(LedgerEntry entry, List descriptors, + List diagnostics) + { + 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 void securityReorganizationCorporateAction(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 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) + .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 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 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) + { + return descriptor(entry, role, DerivedProjectionViewKind.ACCOUNT, selector, false, 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 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) + { + 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) + 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 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) + { + if (unitPosting(primary)) + return List.of(); + + return entry.getPostings().stream() // + .filter(posting -> posting != primary) // + .filter(this::unitPosting) // + .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 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$ + : "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 postingType(LedgerPostingType type) + { + return posting -> posting.getType() == type; + } + + 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()); + } + + private boolean isBlank(String value) + { + return value == null || value.isBlank(); + } + + 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; + 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, + MISSING_SEMANTIC_INSTANCE_KEY, + DUPLICATE_SEMANTIC_INSTANCE_KEY + } + + 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()); + } + + 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; + } + + 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$ //$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/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 +} 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..a203bcc42b --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedAccountTransaction.java @@ -0,0 +1,315 @@ +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.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; +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; +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 DerivedProjectionDescriptor descriptor; + private final LedgerPosting primaryPosting; + private CrossEntry crossEntry; + + LedgerBackedAccountTransaction(DerivedProjectionDescriptor descriptor) + { + this.descriptor = descriptor; + this.entry = descriptor.getEntry(); + this.primaryPosting = descriptor.getPrimaryPosting(); + } + + @Override + public LedgerEntry getLedgerEntry() + { + return entry; + } + + @Override + public DerivedProjectionDescriptor getLedgerProjectionDescriptor() + { + return descriptor; + } + + void setLedgerCrossEntry(CrossEntry crossEntry) + { + this.crossEntry = crossEntry; + } + + @Override + public String getUUID() + { + return descriptor.getRuntimeProjectionId(); + } + + @Override + public Type getType() + { + if (entry.getType().isLedgerNativeTargeted()) + return LedgerProjectionSupport.targetedAccountType(descriptor); + + 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() + { + 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 + 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(descriptor); + } + + @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 (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 " + 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 new file mode 100644 index 0000000000..6cef75629a --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedCrossEntry.java @@ -0,0 +1,104 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import java.util.List; + +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; + +/** + * 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; + + if (candidate instanceof LedgerBackedAccountTransaction) + return new Projection(candidate, ledgerBacked.getLedgerProjectionDescriptor().getAccount()); + + if (candidate instanceof LedgerBackedPortfolioTransaction) + return new Projection(candidate, ledgerBacked.getLedgerProjectionDescriptor().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..960ad27993 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedPortfolioTransaction.java @@ -0,0 +1,284 @@ +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.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; +import name.abuchen.portfolio.model.ledger.LedgerEntryMetadataPatchHelper; +import name.abuchen.portfolio.model.ledger.LedgerPosting; +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 DerivedProjectionDescriptor descriptor; + private final LedgerPosting primaryPosting; + private CrossEntry crossEntry; + + LedgerBackedPortfolioTransaction(DerivedProjectionDescriptor descriptor) + { + this.descriptor = descriptor; + this.entry = descriptor.getEntry(); + this.primaryPosting = descriptor.getPrimaryPosting(); + } + + @Override + public LedgerEntry getLedgerEntry() + { + return entry; + } + + @Override + public DerivedProjectionDescriptor getLedgerProjectionDescriptor() + { + return descriptor; + } + + void setLedgerCrossEntry(CrossEntry crossEntry) + { + this.crossEntry = crossEntry; + } + + @Override + public String getUUID() + { + return descriptor.getRuntimeProjectionId(); + } + + @Override + public Type getType() + { + if (entry.getType().isLedgerNativeTargeted()) + return LedgerProjectionSupport.targetedPortfolioType(descriptor.getRole()); + + 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(descriptor); + } + + @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 (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 " + 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 new file mode 100644 index 0000000000..fde467da7d --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerBackedTransaction.java @@ -0,0 +1,26 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import name.abuchen.portfolio.model.ledger.LedgerEntry; +import name.abuchen.portfolio.model.ledger.LedgerProjectionRole; + +/** + * 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(); + + 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 new file mode 100644 index 0000000000..fc24f255f7 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionFactory.java @@ -0,0 +1,197 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +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; +import name.abuchen.portfolio.model.ledger.LedgerEntry; +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 +{ + private final DerivedProjectionDescriptorService descriptorService; + + LedgerProjectionFactory() + { + this(new DerivedProjectionDescriptorService()); + } + + LedgerProjectionFactory(DerivedProjectionDescriptorService descriptorService) + { + this.descriptorService = Objects.requireNonNull(descriptorService); + } + + 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 and instance: " //$NON-NLS-1$ + + role + "/" + semanticInstanceKey)); //$NON-NLS-1$ + } + + List createProjections(LedgerEntry entry) + { + Objects.requireNonNull(entry); + + var transactions = new ArrayList(); + + for (var descriptor : createDescriptors(entry)) + transactions.add(create(descriptor)); + + attachCrossEntry(entry, transactions); + + return List.copyOf(transactions); + } + + private List createDescriptors(LedgerEntry entry) + { + if (entry.getType() != null && !entry.getType().supportsDerivedDescriptors()) + return List.of(); + + var result = descriptorService.derive(entry); + + if (!result.isOK()) + throw new IllegalArgumentException(result.formatDiagnostics()); + + return result.getDescriptors(); + } + + private Transaction create(DerivedProjectionDescriptor descriptor) + { + if (descriptor.getViewKind() == DerivedProjectionViewKind.ACCOUNT) + return new LedgerBackedAccountTransaction(descriptor); + + if (descriptor.getViewKind() == DerivedProjectionViewKind.PORTFOLIO) + return new LedgerBackedPortfolioTransaction(descriptor); + + throw new UnsupportedOperationException(LedgerDiagnosticCode.LEDGER_PROJ_071 + .message("Unsupported ledger projection role " + descriptor.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.getLedgerProjectionDescriptor().getAccount(), + (AccountTransaction) sourceTransaction, targetTransaction.getLedgerProjectionDescriptor().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.getLedgerProjectionDescriptor().getPortfolio(), + (PortfolioTransaction) sourceTransaction, + targetTransaction.getLedgerProjectionDescriptor().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.getLedgerProjectionDescriptor().getPortfolio(), + (PortfolioTransaction) portfolioTransaction, accountTransaction.getLedgerProjectionDescriptor() + .getAccount(), + (AccountTransaction) accountTransaction); + + accountTransaction.setLedgerCrossEntry(crossEntry); + portfolioTransaction.setLedgerCrossEntry(crossEntry); + } + + private Transaction transaction(LedgerEntry entry, List transactions, LedgerProjectionRole role) + { + return transactions.stream() // + .filter(LedgerBackedTransaction.class::isInstance) // + .map(LedgerBackedTransaction.class::cast) // + .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 new file mode 100644 index 0000000000..b5dc8286ba --- /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.getLedgerProjectionDescriptor().getAccount(); + + if (account.getTransactions().stream().noneMatch(existing -> isSameLedgerProjection(existing, transaction))) + account.getTransactions().add(transaction); + } + + private void addPortfolioProjection(LedgerBackedPortfolioTransaction transaction) + { + var portfolio = transaction.getLedgerProjectionDescriptor().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.getLedgerProjectionDescriptor().getRuntimeProjectionId()); + } + + private boolean isSameLedgerProjection(PortfolioTransaction existing, LedgerBackedTransaction transaction) + { + return existing instanceof LedgerBackedTransaction && existing.getUUID().equals( + 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 new file mode 100644 index 0000000000..e06e86e3e7 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionService.java @@ -0,0 +1,52 @@ +package name.abuchen.portfolio.model.ledger.projection; + +import java.util.List; + +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.LedgerProjectionRole; +import name.abuchen.portfolio.model.ledger.LedgerStructuralValidator; + +/** + * 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, LedgerProjectionRole role) + { + return new LedgerProjectionFactory().createProjection(entry, role); + } + + 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 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..5dda42d34c --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerProjectionSupport.java @@ -0,0 +1,227 @@ +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.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.model.ledger.configuration.LedgerPostingType; +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, LedgerProjectionRole role) + { + return descriptor(entry, role).getPrimaryPosting(); + } + + public static DerivedProjectionDescriptor descriptor(LedgerEntry entry, LedgerProjectionRole role) + { + 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) // + .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) + { + Objects.requireNonNull(entry); + + var result = new DerivedProjectionDescriptorService().derive(entry); + + if (!result.isOK()) + throw new IllegalArgumentException(result.formatDiagnostics()); + + return result.getDescriptors(); + } + + static Optional primaryPostingForex(DerivedProjectionDescriptor descriptor) + { + return postingForex(descriptor.getPrimaryPosting()); + } + + 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(DerivedProjectionDescriptor descriptor) + { + return descriptor.getUnitPostings().stream().map(LedgerProjectionSupport::unit); + } + + static AccountTransaction.Type targetedAccountType(DerivedProjectionDescriptor descriptor) + { + 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 " + descriptor.getRole())); //$NON-NLS-1$ + }; + } + + 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) + 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, 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$ + }; + } + + 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) + { + 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 " + role)); //$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(LedgerProjectionRole role) + { + return switch (role) + { + case ACCOUNT, SOURCE_ACCOUNT, TARGET_ACCOUNT, CASH_COMPENSATION -> true; + default -> false; + }; + } + + static boolean isPortfolioProjection(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 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..bef0b362d1 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/ledger/projection/LedgerRuntimeProjectionRestorer.java @@ -0,0 +1,427 @@ +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 LedgerProjectionFactory factory; + 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.factory = new LedgerProjectionFactory(); + 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 : factory.createProjections(entry)) + { + if (projection instanceof LedgerBackedAccountTransaction accountTransaction) + addProjection(projections, new ProjectionKey("account", //$NON-NLS-1$ + accountTransaction.getLedgerProjectionDescriptor().getAccount(), + projection.getUUID())); + else if (projection instanceof LedgerBackedPortfolioTransaction portfolioTransaction) + addProjection(projections, new ProjectionKey("portfolio", //$NON-NLS-1$ + portfolioTransaction.getLedgerProjectionDescriptor().getPortfolio(), + projection.getUUID())); + } + } + + 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.getLedgerProjectionDescriptor().getRuntimeProjectionId())); + } + } + + 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.getLedgerProjectionDescriptor().getRuntimeProjectionId())); + } + } + + 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(); + } +} 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)