getSecurityList() {
return securityList;
}
public boolean isInvestmentAccount() {
boolean result = false;
for (final E transaction : getTransactions()) {
if (transaction.isInvestmentTransaction()) {
result = true;
break;
}
}
return result;
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ImportFilter.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2021 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat;
import jgnash.engine.Engine;
import jgnash.engine.EngineFactory;
import jgnash.util.EncodeDecode;
import jgnash.util.FileUtils;
import jgnash.util.NotNull;
import jgnash.resource.util.OS;
import javax.script.Invocable;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import static jgnash.util.FileUtils.SEPARATOR;
/**
* Transaction Import filter.
*
* This class is responsible for calling the supplied javascript file.
*
* @author Craig Cavanaugh
*/
public class ImportFilter {
private final static String ENABLED_FILTERS = "enabledFilters";
private final static String IMPORT_SCRIPT_DIRECTORY_NAME = "importScripts";
private static final String JS_REGEX_PATTERN = ".*.js";
private static final Logger logger = Logger.getLogger(ImportFilter.class.getName());
private static final String[] KNOWN_SCRIPTS = {"/jgnash/convert/scripts/tidy.js"};
private final ScriptEngine scriptEngine;
private final String script;
ImportFilter(final String script) {
scriptEngine = new ScriptEngineManager().getEngineByName("nashorn");
this.script = script;
evalScript();
}
public static List getImportFilters() {
final List importFilterList = new ArrayList<>();
// known filters first
for (String knownScript : KNOWN_SCRIPTS) {
importFilterList.add(new ImportFilter(knownScript));
}
for (final Path path : FileUtils.getDirectoryListing(getUserImportScriptDirectory(), JS_REGEX_PATTERN)) {
importFilterList.add(new ImportFilter(path.toString()));
}
final String activeDatabase = EngineFactory.getActiveDatabase();
if (activeDatabase != null && !activeDatabase.startsWith(EngineFactory.REMOTE_PREFIX)) {
for (final Path path : FileUtils.getDirectoryListing(getBaseFileImportScriptDirectory(Paths.get(activeDatabase)), JS_REGEX_PATTERN)) {
importFilterList.add(new ImportFilter(path.toString()));
}
}
return importFilterList;
}
public static List getEnabledImportFilters() {
List filterList = new ArrayList<>();
final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT);
Objects.requireNonNull(engine);
for (final String string : EncodeDecode.decodeStringCollection(engine.getPreference(ENABLED_FILTERS))) {
filterList.add(new ImportFilter(string));
}
return filterList;
}
public static void saveEnabledImportFilters(final List filters) {
final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT);
Objects.requireNonNull(engine);
if (filters != null && filters.size() > 0) {
final List scripts = filters.stream().map(ImportFilter::getScript).collect(Collectors.toList());
engine.setPreference(ENABLED_FILTERS, EncodeDecode.encodeStringCollection(scripts));
} else {
engine.setPreference(ENABLED_FILTERS, null);
}
}
private static Path getUserImportScriptDirectory() {
String scriptDirectory = System.getProperty("user.home");
// decode to correctly handle spaces, etc. in the returned path
try {
scriptDirectory = URLDecoder.decode(scriptDirectory, StandardCharsets.UTF_8.name());
} catch (final UnsupportedEncodingException ex) {
logger.log(Level.SEVERE, ex.getLocalizedMessage(), ex);
}
if (OS.isSystemWindows()) {
scriptDirectory += SEPARATOR + "AppData" + SEPARATOR + "Local" + SEPARATOR
+ "jgnash" + SEPARATOR + IMPORT_SCRIPT_DIRECTORY_NAME;
} else { // unix, osx
scriptDirectory += SEPARATOR + ".jgnash" + SEPARATOR + IMPORT_SCRIPT_DIRECTORY_NAME;
}
logger.log(Level.INFO, "Import Script path: {0}", scriptDirectory);
return Paths.get(scriptDirectory);
}
private static Path getBaseFileImportScriptDirectory(@NotNull final Path baseFile) {
if (baseFile.getParent() != null) {
return Paths.get(baseFile.getParent() + SEPARATOR + IMPORT_SCRIPT_DIRECTORY_NAME);
}
return null;
}
public String getScript() {
return script;
}
private void evalScript() {
try (final Reader reader = getReader()) {
scriptEngine.eval(reader);
} catch (final ScriptException | IOException e) {
logger.log(Level.SEVERE, e.getLocalizedMessage(), e);
}
}
public String processMemo(final String memo) {
try {
final Invocable invocable = (Invocable) scriptEngine;
final Object result = invocable.invokeFunction("processMemo", memo);
return result.toString();
} catch (final ScriptException | NoSuchMethodException e) {
logger.log(Level.SEVERE, e.getLocalizedMessage(), e);
}
return memo;
}
public String processPayee(final String payee) {
try {
final Invocable invocable = (Invocable) scriptEngine;
final Object result = invocable.invokeFunction("processPayee", payee);
return result.toString();
} catch (final ScriptException | NoSuchMethodException e) {
logger.log(Level.SEVERE, e.getLocalizedMessage(), e);
}
return payee;
}
public String getDescription() {
try {
final Invocable invocable = (Invocable) scriptEngine;
final Object result = invocable.invokeFunction("getDescription", Locale.getDefault());
return result.toString();
} catch (final ScriptException | NoSuchMethodException e) {
logger.log(Level.SEVERE, e.getLocalizedMessage(), e);
}
return "";
}
public void acceptTransaction(final ImportTransaction importTransaction) {
try {
final Invocable invocable = (Invocable) scriptEngine;
invocable.invokeFunction("acceptTransaction", importTransaction);
} catch (final ScriptException | NoSuchMethodException e) {
logger.log(Level.SEVERE, e.getLocalizedMessage(), e);
}
}
private Reader getReader() throws IOException {
if (Files.exists(Paths.get(script))) {
return Files.newBufferedReader(Paths.get(script));
}
return new InputStreamReader(
Objects.requireNonNull(ImportFilter.class.getResourceAsStream(script)), StandardCharsets.UTF_8);
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ImportSecurity.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Optional;
import jgnash.engine.SecurityNode;
/**
* Security Import Object
*
* @author Craig Cavanaugh
*/
public class ImportSecurity {
private String ticker;
private String securityName;
private BigDecimal unitPrice;
private LocalDate localDate;
private String id;
public String idType;
private String currency;
private BigDecimal currencyRate;
private SecurityNode securityNode;
@Override
public String toString() {
StringBuilder b = new StringBuilder();
b.append("ticker: ").append(getTicker()).append('\n');
b.append("securityName: ").append(securityName).append('\n');
b.append("unitPrice: ").append(unitPrice).append('\n');
b.append("localDate: ").append(localDate).append('\n');
if (id != null) {
b.append("id: ").append(id).append('\n');
}
if (idType != null) {
b.append("idType: ").append(idType).append('\n');
}
getCurrency().ifPresent(currency -> b.append("currency: ").append(currency).append('\n'));
getCurrencyRate().ifPresent(rate -> b.append("currencyRate: ").append(rate).append('\n'));
return b.toString();
}
public Optional getId() {
return Optional.ofNullable(id);
}
public void setId(String id) {
this.id = id;
}
Optional getSecurityName() {
return Optional.ofNullable(securityName);
}
public void setSecurityName(final String securityName) {
this.securityName = securityName;
}
Optional getUnitPrice() {
return Optional.ofNullable(unitPrice);
}
public void setUnitPrice(final BigDecimal unitPrice) {
this.unitPrice = unitPrice;
}
public Optional getLocalDate() {
return Optional.ofNullable(localDate);
}
public void setLocalDate(final LocalDate localDate) {
this.localDate = localDate;
}
public void setCurrencyRate(final BigDecimal unitPrice) {
this.currencyRate = unitPrice;
}
public Optional getCurrencyRate() {
return Optional.ofNullable(currencyRate);
}
public void setCurrency(final String currency) {
this.currency = currency;
}
public Optional getCurrency() {
return Optional.ofNullable(currency);
}
/**
* Reference to the security node linked to this imported security node
*/
public SecurityNode getSecurityNode() {
return securityNode;
}
public void setSecurityNode(SecurityNode securityNode) {
this.securityNode = securityNode;
}
public String getTicker() {
return ticker;
}
public void setTicker(final String ticker) {
this.ticker = ticker;
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ImportState.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat;
/**
* @author Craig Cavanaugh
*/
public enum ImportState {
NEW,
EQUAL,
IGNORE,
NOT_EQUAL
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ImportTransaction.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Objects;
import java.util.UUID;
import jgnash.engine.Account;
import jgnash.engine.TransactionType;
import jgnash.util.NotNull;
import jgnash.util.Nullable;
/**
* Common interface for importing transactions from OFX, QIF, and mt940
*
* @author Craig Cavanaugh
* @author Arnout Engelen
* @author Nicolas Bouillon
*/
public class ImportTransaction implements Comparable {
private final String uuid = UUID.randomUUID().toString();
/**
* The destination account
*/
private Account account;
/**
* Account for dividends and gains/losses from an investment transaction
*/
private Account gainsAccount;
/**
* Account for investment expenses
*/
private Account feesAccount;
private BigDecimal amount = BigDecimal.ZERO;
private String checkNumber = ""; // check number (?)
@NotNull
private LocalDate datePosted = LocalDate.now();
@Nullable
private LocalDate dateUser = null;
private String memo = ""; // memo
@NotNull
private String payee = "";
// OFX
private String payeeId;
private ImportState state = ImportState.NEW;
// OFX, Financial Institution transaction ID
private String FITID;
// OFX
private String securityId;
// OFX
private String securityType;
private BigDecimal units = BigDecimal.ZERO;
private BigDecimal unitPrice = BigDecimal.ZERO;
private BigDecimal commission = BigDecimal.ZERO;
private BigDecimal fees = BigDecimal.ZERO;
// OFX, Type of income for investment transaction
private String incomeType;
private boolean taxExempt = false;
// OFX
private TransactionType transactionType = TransactionType.SINGLENTRY; // single entry by default
// OFX
private String transactionTypeDescription;
// OFX
private String SIC;
// OFX
private String refNum;
// OFX
private String subAccount;
private String currency;
// OFX, transfer account id
private String accountTo;
/**
* @return returns the destination account
*/
public Account getAccount() {
return account;
}
public void setAccount(final Account account) {
this.account = account;
}
/**
* Depending on the implementation a unique ID may be provided that can be used to detect
* duplication of prior imported transactions.
*
* @return transaction id
*/
public String getFITID() {
return FITID;
}
public void setFITID(String FITID) {
this.FITID = FITID;
}
/**
* Deposits get positive 'amounts', withdrawals negative
*
* @return transaction amount
*/
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
@NotNull
public LocalDate getDatePosted() {
return datePosted;
}
public void setDatePosted(@NotNull LocalDate datePosted) {
this.datePosted = datePosted;
}
/**
* Date user initiated the transaction, optional, may be null
*
* @return date transaction was initiated
*/
@Nullable
public LocalDate getDateUser() {
return dateUser;
}
public void setDateUser(@Nullable LocalDate dateUser) {
this.dateUser = dateUser;
}
public String getCheckNumber() {
return checkNumber;
}
public void setCheckNumber(final String checkNumber) {
this.checkNumber = checkNumber;
}
public String getMemo() {
return memo;
}
public void setMemo(String memo) {
this.memo = memo;
}
public ImportState getState() {
return state;
}
public void setState(ImportState state) {
this.state = state;
}
@NotNull
public String getPayee() {
return payee;
}
public void setPayee(@NotNull String payee) {
Objects.requireNonNull(payee);
this.payee = payee;
}
public String getSecurityId() {
return securityId;
}
public void setSecurityId(String securityId) {
this.securityId = securityId;
}
@Override
public int compareTo(@NotNull final ImportTransaction importTransaction) {
if (importTransaction == this) {
return 0;
}
int result = getDatePosted().compareTo(importTransaction.getDatePosted());
if (result != 0) {
return result;
}
result = payee.compareTo(importTransaction.payee);
if (result != 0) {
return result;
}
return Integer.compare(hashCode(), importTransaction.hashCode());
}
@Override
public boolean equals(final Object that) {
if (this == that) {
return true;
}
if (that == null || getClass() != that.getClass()) {
return false;
}
return Objects.equals(uuid, ((ImportTransaction) that).uuid);
}
@Override
public int hashCode() {
return Objects.hash(uuid);
}
/**
* Investment transaction units
*/
public BigDecimal getUnits() {
return units;
}
public void setUnits(final BigDecimal units) {
this.units = units;
}
/**
* Investment transaction unit price
*/
public BigDecimal getUnitPrice() {
return unitPrice;
}
public void setUnitPrice(final BigDecimal unitPrice) {
this.unitPrice = unitPrice;
}
/**
* Investment transaction commission
*/
public BigDecimal getCommission() {
return commission;
}
public void setCommission(final BigDecimal commission) {
this.commission = commission;
}
public boolean isTaxExempt() {
return taxExempt;
}
public void setTaxExempt(boolean taxExempt) {
this.taxExempt = taxExempt;
}
public String getSecurityType() {
return securityType;
}
public void setSecurityType(final String securityType) {
this.securityType = securityType;
}
public boolean isInvestmentTransaction() {
return getSecurityId() != null;
}
public String getIncomeType() {
return incomeType;
}
public void setIncomeType(String incomeType) {
this.incomeType = incomeType;
}
@NotNull public BigDecimal getFees() {
return fees;
}
public void setFees(@NotNull final BigDecimal fees) {
this.fees = fees;
}
/**
* The parser may establish a transaction type when imported.
*
* @return {@code TransactionType}
*/
@NotNull
public TransactionType getTransactionType() {
return transactionType;
}
public void setTransactionType(@NotNull final TransactionType transactionType) {
this.transactionType = transactionType;
}
/**
* OFX defines descriptive transaction types
*/
public String getTransactionTypeDescription() {
return transactionTypeDescription;
}
public void setTransactionTypeDescription(final String transactionTypeDescription) {
this.transactionTypeDescription = transactionTypeDescription;
}
/**
* Standard Industry Code
*
* Could be use used for automatic expense and income assignment. Typically a 4 digit numeric, but OFX allows 6
*/
public String getSIC() {
return SIC;
}
public void setSIC(String SIC) {
this.SIC = SIC;
}
/**
* Reference number that uniquely identifies the transaction. May be used in
* addition to or instead of a {@link #getCheckNumber()}
*/
public String getRefNum() {
return refNum;
}
public void setRefNum(String refNum) {
this.refNum = refNum;
}
/**
* Some OFX based systems will assign an ID to a Payee.
*
* The ID would correspond to a Payee List identified by (not implemented)
*/
public String getPayeeId() {
return payeeId;
}
public void setPayeeId(String payeeId) {
this.payeeId = payeeId;
}
/**
* The sub-account for cash transfer, typically CASH, but could be MARGIN, SHORT, or OTHER
*
* , , ,
*/
public String getSubAccount() {
return subAccount;
}
public void setSubAccount(String subAccount) {
this.subAccount = subAccount;
}
public String getCurrency() {
return currency;
}
public void setCurrency(String currency) {
this.currency = currency;
}
/**
* Account for gains or losses from an investment transaction
*/
public Account getGainsAccount() {
return gainsAccount;
}
public void setGainsAccount(final Account gainsAccount) {
this.gainsAccount = gainsAccount;
}
/**
* Account for investment expenses
*/
public Account getFeesAccount() {
return feesAccount;
}
public void setFeesAccount(Account feesAccount) {
this.feesAccount = feesAccount;
}
@NotNull
public String getToolTip() {
if (isInvestmentTransaction()) {
return units.toString() + " @ " + unitPrice.toString();
}
return "";
}
@Override
public String toString() {
return getTransactionTypeDescription() + ", " +
getTransactionType() + ", " +
getDatePosted() + ", " +
getAmount() + ", " +
getFITID() + ", " +
getSIC() + ", " +
getPayee() + ", " +
getMemo() + ", " +
getCheckNumber() + ", " +
getRefNum() + ", " +
getPayeeId() + ", " +
getCurrency();
}
public String getAccountTo() {
return accountTo;
}
public void setAccountTo(String accountTo) {
this.accountTo = accountTo;
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ImportUtils.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat;
import java.util.Objects;
import java.util.Optional;
import jgnash.engine.Account;
import jgnash.engine.AccountType;
import jgnash.engine.CurrencyNode;
import jgnash.engine.Engine;
import jgnash.engine.EngineFactory;
import jgnash.engine.SecurityNode;
/**
* Various utility methods used when importing transactions
*
* @author Craig Cavanaugh
*/
public class ImportUtils {
private ImportUtils() {
}
public static Account getRootExpenseAccount() {
final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT);
Objects.requireNonNull(engine);
return searchForRootType(engine.getRootAccount(), AccountType.EXPENSE);
}
public static Account getRootIncomeAccount() {
final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT);
Objects.requireNonNull(engine);
return searchForRootType(engine.getRootAccount(), AccountType.INCOME);
}
/**
* Matches an ImportTransaction to an Account based on an AccountTo tag if it exists
* @param importTransaction Import transaction to test
* @return Account if found, null otherwise
*/
public static Account matchAccount(final ImportTransaction importTransaction) {
final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT);
Objects.requireNonNull(engine);
final String number = importTransaction.getAccountTo();
Account account = null;
if (number != null) {
for (final Account a : engine.getAccountList()) {
if (a.getAccountNumber().equals(number)) {
account = a;
break;
}
}
}
return account;
}
private static Account searchForRootType(final Account account, final AccountType accountType) {
Account result = null;
// search immediate top level accounts
for (Account a : account.getChildren()) {
if (a.getAccountType().equals(accountType)) {
return a;
}
}
// recursive search
for (Account a : account.getChildren()) {
result = searchForRootType(a, accountType);
if (result != null) {
break;
}
}
return result;
}
static Optional matchSecurity(final ImportSecurity security) {
final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT);
Objects.requireNonNull(engine);
for (final SecurityNode securityNode : engine.getSecurities()) {
if (securityNode.getSymbol().equals(security.getTicker())) {
return Optional.of(securityNode);
}
}
return Optional.empty();
}
static SecurityNode createSecurityNode(final ImportSecurity security, final CurrencyNode currencyNode) {
final SecurityNode securityNode = new SecurityNode(currencyNode);
securityNode.setSymbol(security.getTicker());
securityNode.setScale(currencyNode.getScale());
security.getSecurityName().ifPresent(securityNode::setDescription);
security.getId().ifPresent(securityNode::setISIN);
return securityNode;
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ofx/OfxBank.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat.ofx;
import java.math.BigDecimal;
import java.time.LocalDate;
import jgnash.convert.importat.ImportBank;
import jgnash.convert.importat.ImportSecurity;
import jgnash.convert.importat.ImportTransaction;
import jgnash.util.Nullable;
/**
* OFX Bank Object
*
* @author Craig Cavanaugh
* @author Nicolas Bouillon
*/
public class OfxBank extends ImportBank {
public String currency;
String bankId;
/**
* Branch identifier. May be required for some non-US banks
*/
String branchId;
public String accountId;
String accountType;
LocalDate dateStart;
LocalDate dateEnd;
BigDecimal ledgerBalance;
LocalDate ledgerBalanceDate;
BigDecimal availBalance;
LocalDate availBalanceDate;
int statusCode;
String statusSeverity;
@Nullable
String statusMessage;
@Override
public String toString() {
StringBuilder b = new StringBuilder();
b.append("statusCode: ").append(statusCode).append('\n');
b.append("statusSeverity: ").append(statusSeverity).append('\n');
b.append("statusMessage: ").append(statusMessage).append('\n');
b.append("currency: ").append(currency).append('\n');
b.append("bankId: ").append(bankId).append('\n');
b.append("branchId: ").append(branchId).append('\n');
b.append("accountId: ").append(accountId).append('\n');
b.append("accountType: ").append(accountType).append('\n');
b.append("dateStart: ").append(dateStart).append('\n');
b.append("dateEnd: ").append(dateEnd).append('\n');
b.append("ledgerBalance: ").append(ledgerBalance).append('\n');
b.append("ledgerBalanceDate: ").append(ledgerBalanceDate).append('\n');
if (availBalance != null) {
b.append("availBalance: ").append(availBalance).append('\n');
}
if (availBalanceDate != null) {
b.append("availBalanceDate: ").append(availBalanceDate).append('\n');
}
for (final ImportTransaction t : getTransactions()) {
b.append(t).append('\n');
}
for (final ImportSecurity importSecurity : securityList) {
b.append(importSecurity.toString()).append('\n');
}
return b.toString();
}
/* USD
074914229
10076164
CHECKING
*/
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ofx/OfxImport.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat.ofx;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import jgnash.convert.common.OfxTags;
import jgnash.convert.importat.ImportSecurity;
import jgnash.convert.importat.ImportState;
import jgnash.convert.importat.ImportTransaction;
import jgnash.engine.Account;
import jgnash.engine.AccountGroup;
import jgnash.engine.CurrencyNode;
import jgnash.engine.Engine;
import jgnash.engine.EngineFactory;
import jgnash.engine.InvestmentTransaction;
import jgnash.engine.SecurityNode;
import jgnash.engine.Transaction;
import jgnash.engine.TransactionEntry;
import jgnash.engine.TransactionFactory;
import jgnash.engine.TransactionTag;
/**
* OfxImport utility methods
*
* @author Craig Cavanaugh
*/
public class OfxImport {
/**
* Private constructor, utility class
*/
private OfxImport() {
}
public static void importTransactions(final OfxBank ofxBank, final Account baseAccount) {
Objects.requireNonNull(ofxBank.getTransactions());
Objects.requireNonNull(baseAccount);
final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT);
Objects.requireNonNull(engine);
for (final ImportTransaction tran : ofxBank.getTransactions()) {
// do not import matched transactions
if (tran.getState() == ImportState.NEW || tran.getState() == ImportState.NOT_EQUAL) {
Transaction transaction = null;
if (tran.isInvestmentTransaction()) {
if (baseAccount.getAccountType().getAccountGroup() == AccountGroup.INVEST) {
transaction = importInvestmentTransaction(ofxBank, tran, baseAccount);
if (transaction != null) {
// check and add the security node to the account if not present
if (!baseAccount.containsSecurity(((InvestmentTransaction) transaction).getSecurityNode())) {
engine.addAccountSecurity(((InvestmentTransaction) transaction).getInvestmentAccount(),
((InvestmentTransaction) transaction).getSecurityNode());
}
}
} else { // Signal an error
System.out.println("Base account was not an investment account type");
}
} else {
if (baseAccount.equals(tran.getAccount())) { // single entry oTran
transaction = TransactionFactory.generateSingleEntryTransaction(baseAccount, tran.getAmount(),
tran.getDatePosted(), tran.getMemo(), tran.getPayee(), tran.getCheckNumber());
} else { // double entry
if (tran.getAmount().signum() >= 0) {
transaction = TransactionFactory.generateDoubleEntryTransaction(baseAccount, tran.getAccount(),
tran.getAmount().abs(), tran.getDatePosted(), tran.getMemo(), tran.getPayee(),
tran.getCheckNumber());
} else {
transaction = TransactionFactory.generateDoubleEntryTransaction(tran.getAccount(), baseAccount,
tran.getAmount().abs(), tran.getDatePosted(), tran.getMemo(), tran.getPayee(),
tran.getCheckNumber());
}
}
}
// add the new transaction
if (transaction != null) {
transaction.setFitid(tran.getFITID());
engine.addTransaction(transaction);
}
}
}
}
private static InvestmentTransaction importInvestmentTransaction(final OfxBank ofxBank, final ImportTransaction ofxTransaction,
final Account investmentAccount) {
final SecurityNode securityNode = matchSecurity(ofxBank, ofxTransaction.getSecurityId());
final String memo = ofxTransaction.getMemo();
final LocalDate datePosted = ofxTransaction.getDatePosted();
final BigDecimal units = ofxTransaction.getUnits();
final BigDecimal unitPrice = ofxTransaction.getUnitPrice();
final Account incomeAccount = ofxTransaction.getGainsAccount();
final Account fessAccount = ofxTransaction.getFeesAccount();
Account cashAccount = ofxTransaction.getAccount();
// force use of cash balance
if (OfxTags.CASH.equals(ofxTransaction.getSubAccount())) {
cashAccount = investmentAccount;
}
final List fees = new ArrayList<>();
final List gains = new ArrayList<>();
if (ofxTransaction.getCommission().compareTo(BigDecimal.ZERO) != 0) {
final TransactionEntry transactionEntry = new TransactionEntry(fessAccount, ofxTransaction.getCommission().negate());
transactionEntry.setTransactionTag(TransactionTag.INVESTMENT_FEE);
fees.add(transactionEntry);
}
if (ofxTransaction.getFees().compareTo(BigDecimal.ZERO) != 0) {
final TransactionEntry transactionEntry = new TransactionEntry(fessAccount, ofxTransaction.getFees().negate());
transactionEntry.setTransactionTag(TransactionTag.INVESTMENT_FEE);
fees.add(transactionEntry);
}
InvestmentTransaction transaction = null;
if (securityNode != null) {
switch (ofxTransaction.getTransactionType()) {
case DIVIDEND:
final BigDecimal dividend = ofxTransaction.getAmount();
transaction = TransactionFactory.generateDividendXTransaction(incomeAccount, investmentAccount, cashAccount,
securityNode, dividend, dividend, dividend, datePosted, memo);
break;
case REINVESTDIV:
// Create a gains entry of an account other than the investment account has been selected
if (incomeAccount != investmentAccount) {
final TransactionEntry gainsEntry = TransactionFactory.createTransactionEntry(incomeAccount,
investmentAccount, ofxTransaction.getAmount().negate(), memo, TransactionTag.GAIN_LOSS);
gains.add(gainsEntry);
}
transaction = TransactionFactory.generateReinvestDividendXTransaction(investmentAccount, securityNode,
unitPrice, units, datePosted, memo, fees, gains);
break;
case BUYSHARE:
transaction = TransactionFactory.generateBuyXTransaction(cashAccount, investmentAccount, securityNode,
unitPrice, units, BigDecimal.ONE, datePosted, memo, fees);
break;
case SELLSHARE:
transaction = TransactionFactory.generateSellXTransaction(cashAccount, investmentAccount, securityNode,
unitPrice, units, BigDecimal.ONE, datePosted, memo, fees, gains);
break;
default:
}
}
return transaction;
}
private static SecurityNode matchSecurity(final OfxBank ofxBank, final String securityId) {
SecurityNode securityNode = null;
for (final ImportSecurity importSecurity : ofxBank.getSecurityList()) {
if (importSecurity.getId().isPresent()) {
if (importSecurity.getId().get().equals(securityId)) {
securityNode = importSecurity.getSecurityNode();
break;
}
}
}
return securityNode;
}
public static Account matchAccount(final OfxBank bank) {
final Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT);
Objects.requireNonNull(engine);
final String number = bank.accountId;
final CurrencyNode node = engine.getCurrency(bank.currency);
Account account = null;
if (node != null) {
for (Account a : engine.getAccountList()) {
if (a.getAccountNumber() != null && a.getAccountNumber().equals(number) && a.getCurrencyNode().equals(node)) {
account = a;
break;
}
}
} else if (number != null) {
for (Account a : engine.getAccountList()) {
if (a.getAccountNumber().equals(number)) {
account = a;
break;
}
}
}
return account;
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ofx/OfxV1ToV2.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat.ofx;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import jgnash.util.FileMagic;
import static jgnash.util.LogUtil.logSevere;
import static jgnash.convert.importat.ofx.Sanitize.sanitize;
/**
* Utility class to convert OFX version 1 (SGML) to OFX version 2 (XML)
*
* @author Craig Cavanaugh
*/
class OfxV1ToV2 {
private static final int READ_AHEAD_LIMIT = 2048;
/*
public static void main(final String[] args) {
if (args.length == 2) {
Path input = Paths.get(args[0]);
Path output = Paths.get(args[1]);
if (Files.exists(input)) {
try {
Files.write(output, convertToXML(input).getBytes());
} catch (final IOException e) {
e.printStackTrace();
}
}
}
}*/
static String convertToXML(final Path path) {
String encoding = FileMagic.getOfxV1Encoding(path);
Logger.getLogger(OfxV1ToV2.class.getName()).log(Level.INFO, "OFX Version 1 file encoding was {0}", encoding);
return convertSgmlToXML(readFile(path, encoding));
}
static String convertToXML(final InputStream stream) {
return convertSgmlToXML(readFile(stream, System.getProperty("file.encoding")));
}
private static String convertSgmlToXML(final String sgml) {
StringBuilder xml = new StringBuilder(sgml);
int readPos = 0;
int tagEnd = 0;
while (readPos < xml.length() && readPos != -1) {
String tag;
readPos = xml.indexOf("<", tagEnd);
if (readPos != -1) {
tagEnd = xml.indexOf(">", readPos);
if (tagEnd != -1) {
tag = xml.substring(readPos + 1, tagEnd);
if (!tag.startsWith("/")) {
if (xml.indexOf("" + tag + ">", tagEnd) == -1) {
readPos = xml.indexOf("<", tagEnd);
xml.insert(readPos, "" + tag + ">");
}
}
} else {
readPos = -1;
}
}
}
return sanitize(xml.toString());
}
private static String concat(final Collection strings) {
StringBuilder b = new StringBuilder();
for (String s : strings) {
b.append(s.trim());
}
return b.toString();
}
/**
* Munch through the header one character at a time. Do not assume clean
* formatting or EOL characters.
*
* @param reader {@code BufferedReader}
* @throws IOException thrown if IO error occurs
*/
private static void consumeHeader(final BufferedReader reader) throws IOException {
Logger logger = Logger.getLogger(OfxV1ToV2.class.getName());
while (true) {
reader.mark(READ_AHEAD_LIMIT);
int character = reader.read();
if (character >= 0) {
if (character == '<') {
reader.reset();
logger.info("readHeader() Complete");
break;
}
} else {
break;
}
}
}
/**
* Reads a file, strips the header and reads the SGML content into one large
* string
*
* @param stream input stream
* @param characterSet assumed character set for the file being converted
* @return a String with the SGML content and header removed
*/
private static String readFile(final InputStream stream, final String characterSet) {
if (stream == null) {
logSevere(OfxV1ToV2.class, "InputStream was null");
return null;
}
List strings = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, characterSet))) {
// consume the Ofx1 header
consumeHeader(reader);
String line = reader.readLine();
while (line != null) {
line = line.trim();
if (!line.isEmpty()) {
strings.add(line);
}
line = reader.readLine();
}
} catch (final IOException e) {
logSevere(OfxV1ToV2.class, e);
}
return concat(strings);
}
private static String readFile(final Path path, final String characterSet) {
try (final InputStream stream = new BufferedInputStream(Files.newInputStream(path))) {
return readFile(stream, characterSet);
} catch (final IOException e) {
logSevere(OfxV1ToV2.class, e);
return "";
}
}
private OfxV1ToV2() {
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ofx/OfxV2Parser.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat.ofx;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.text.ParseException;
import java.time.LocalDate;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.logging.FileHandler;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.logging.SimpleFormatter;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.xml.namespace.QName;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import jgnash.convert.common.OfxTags;
import jgnash.convert.importat.ImportSecurity;
import jgnash.convert.importat.ImportTransaction;
import jgnash.engine.TransactionType;
import jgnash.resource.util.ResourceUtils;
import jgnash.util.FileMagic;
import jgnash.util.NotNull;
import static jgnash.convert.importat.ofx.Sanitize.sanitize;
/**
* StAX based parser for 2.x OFX (XML) files.
*
* This parser will intentionally absorb higher level elements and drop through to simplify and reduce code.
*
* @author Craig Cavanaugh
*/
public class OfxV2Parser implements OfxTags {
private static final Logger logger = Logger.getLogger("OfxV2Parser");
private static final String EXTRA_SPACE_REGEX = "\\s+";
private static final String ENCODING = StandardCharsets.UTF_8.name();
private OfxBank bank;
/**
* Default language is assumed to be English unless the import file defines it
*/
private String language = "ENG";
private int statusCode;
private String statusSeverity;
/**
* Status message from sign-on process
*/
private String statusMessage;
private final Pattern extraSpaceRegex = Pattern.compile(EXTRA_SPACE_REGEX);
/**
* Support class
*/
private static class AccountInfo {
String bankId;
String accountId;
String accountType;
String branchId;
}
static void enableDetailedLogFile() {
try {
final Handler fh = new FileHandler("%t/jgnash-ofx.log", false);
fh.setFormatter(new SimpleFormatter());
logger.addHandler(fh);
logger.setLevel(Level.ALL);
} catch (final IOException ioe) {
logger.severe(ResourceUtils.getString("Message.Error.LogFileHandler"));
}
}
public static OfxBank parse(@NotNull final Path file) throws Exception {
final OfxV2Parser parser = new OfxV2Parser();
if (FileMagic.isOfxV1(file)) {
logger.info("Parsing OFX Version 1 file");
parser.parse(OfxV1ToV2.convertToXML(file), FileMagic.getOfxV1Encoding(file));
} else if (FileMagic.isOfxV2(file)) {
logger.info("Parsing OFX Version 2 file");
parser.parseFile(file);
} else {
logger.info("Unknown OFX Version");
}
if (parser.getBank() == null) {
throw new Exception("Bank import failed");
}
return postProcess(parser.getBank());
}
/**
* Post processes the OFX transactions for import. Income transactions with a reinvestment transaction will be
* stripped out. jGnash has a reinvested dividend transaction that reduces overall transaction count.
*
* @param ofxBank OfxBank to process
* @return OfxBank with post processed transactions
*/
private static OfxBank postProcess(final OfxBank ofxBank) {
// Clone the original list
final List importTransactions = ofxBank.getTransactions();
// Create a list of Reinvested dividends
final List reinvestedDividends = importTransactions.stream()
.filter(importTransaction -> importTransaction.getTransactionType() == TransactionType.REINVESTDIV)
.collect(Collectors.toList());
// Search through the list and remove matching income transactions
for (final ImportTransaction reinvestDividend : reinvestedDividends) {
final Iterator iterator = importTransactions.iterator();
while (iterator.hasNext()) {
final ImportTransaction otherTran = iterator.next();
// if this was OFX income and the securities match and the amount match, remove the transaction
if (reinvestDividend != otherTran && OfxTags.INCOME.equals(otherTran.getTransactionTypeDescription())) {
if (otherTran.getAmount().compareTo(reinvestDividend.getAmount().abs()) == 0) {
if (otherTran.getSecurityId().equals(reinvestDividend.getSecurityId())) {
iterator.remove(); // remove it
// reverse sign
reinvestDividend.setAmount(reinvestDividend.getAmount().abs());
}
}
}
}
}
return ofxBank;
}
/**
* Parse a date. Time zone and seconds are ignored
*
* YYYYMMDDHHMMSS.XXX [gmt offset:tz name]
*
* @param date String form of the date
* @return parsed date
*/
private static LocalDate parseDate(final String date) {
int year = Integer.parseInt(date.substring(0, 4)); // year
int month = Integer.parseInt(date.substring(4, 6)); // month
int day = Integer.parseInt(date.substring(6, 8)); // day
return LocalDate.of(year, month, day);
}
private static BigDecimal parseAmount(final String amount) {
/* Must trim the amount for a clean parse
* Some banks leave extra spaces before the value
*/
try {
return new BigDecimal(amount.trim());
} catch (final NumberFormatException e) {
if (amount.contains(",")) { // OFX file in not valid and uses commas for decimal separators
// Use the French locale as it uses commas for decimal separators
DecimalFormat df = (DecimalFormat) NumberFormat.getInstance(Locale.FRENCH);
df.setParseBigDecimal(true); // force return value of BigDecimal
try {
return (BigDecimal) df.parseObject(amount.trim());
} catch (final ParseException pe) {
logger.log(Level.INFO, "Parse amount was: {0}", amount);
logger.log(Level.SEVERE, e.getLocalizedMessage(), pe);
}
}
return BigDecimal.ZERO; // give up at this point
}
}
private static boolean parseBoolean(final String bool) {
return !bool.isEmpty() && bool.startsWith("T");
}
/**
* Parses an InputStream and assumes UTF-8 encoding
*
* Illegal characters are corrected automatically if found
*
* @param stream InputStream to parse
*/
public void parse(final InputStream stream) {
final StringBuilder stringBuilder = new StringBuilder();
try (final BufferedReader reader = new BufferedReader(new InputStreamReader(stream, ENCODING))) {
String line;
while ((line = reader.readLine()) != null) {
stringBuilder.append(line);
}
parse(sanitize(stringBuilder.toString()), ENCODING);
} catch (final IOException e) {
logger.log(Level.SEVERE, e.toString(), e);
}
}
/**
* Parses an InputStream using a specified encoding
*
* @param stream InputStream to parse
* @param encoding encoding to use
*/
private void parse(final InputStream stream, final String encoding) {
logger.entering(OfxV2Parser.class.getName(), "parse");
bank = new OfxBank();
final XMLInputFactory inputFactory = XMLInputFactory.newInstance();
inputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
inputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
try (final InputStream input = new BufferedInputStream(stream)) {
XMLStreamReader reader = inputFactory.createXMLStreamReader(input, encoding);
readOfx(reader);
} catch (IOException | XMLStreamException e) {
logger.log(Level.SEVERE, e.toString(), e);
}
logger.exiting(OfxV2Parser.class.getName(), "parse");
}
private void parseFile(final Path path) {
try (final InputStream stream = new BufferedInputStream(Files.newInputStream(path))) {
parse(stream);
} catch (final IOException e) {
logger.log(Level.SEVERE, e.toString(), e);
}
}
public void parse(final String string, final String encoding) throws UnsupportedEncodingException {
parse(new ByteArrayInputStream(string.getBytes(encoding)), encoding);
}
private void readOfx(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "readOfx");
while (reader.hasNext()) {
final int event = reader.next();
if (event == XMLStreamConstants.START_ELEMENT) {
switch (reader.getLocalName()) {
case OFX: // consume the OFX header here
break;
case SIGNONMSGSRSV1:
parseSignOnMessageSet(reader);
break;
case BANKMSGSRSV1:
parseBankMessageSet(reader);
break;
case CREDITCARDMSGSRSV1:
parseCreditCardMessageSet(reader);
break;
case INVSTMTMSGSRSV1:
parseInvestmentAccountMessageSet(reader);
break;
case SECLISTMSGSRSV1:
parseSecuritesMessageSet(reader);
break;
default:
logger.log(Level.WARNING, "Unknown message set {0}", reader.getLocalName());
break;
}
}
}
logger.exiting(OfxV2Parser.class.getName(), "readOfx");
}
private void parseInvestmentAccountMessageSet(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseInvestmentAccountMessageSet");
final QName parsingElement = reader.getName();
parse:
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case STATUS:
parseStatementStatus(reader);
break;
case CURDEF:
bank.currency = reader.getElementText();
break;
case INVACCTFROM:
parseAccountInfo(bank, parseAccountInfo(reader));
break;
case INVTRANLIST:
parseInvestmentTransactionList(reader);
break;
case INVSTMTRS: // consume the statement download
break;
case INVPOSLIST: // consume the securities position list; TODO: Use for reconcile
case INV401KBAL: // consume 401k balance aggregate
case INV401K: // consume 401k account info aggregate
case INVBAL: // consume the investment account balance; TODO: Use for reconcile
case INVOOLIST: // consume open orders
consumeElement(reader);
break;
case INVSTMTTRNRS:
case TRNUID:
case DTASOF: // statement date
break;
default:
logger.log(Level.WARNING, "Unknown INVSTMTMSGSRSV1 element: {0}", reader.getLocalName());
break;
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the investment account message set aggregate");
break parse;
}
default:
}
}
logger.exiting(OfxV2Parser.class.getName(), "parseInvestmentAccountMessageSet");
}
/**
* Parses a BANKMSGSRSV1 element
*
* @param reader shared XMLStreamReader
* @throws XMLStreamException XML parsing error has occurred
*/
private void parseBankMessageSet(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseBankMessageSet");
final QName parsingElement = reader.getName();
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case STATUS:
parseStatementStatus(reader);
break;
case CURDEF:
bank.currency = reader.getElementText();
break;
case LEDGERBAL:
parseLedgerBalance(reader);
break;
case AVAILBAL:
parseAvailableBalance(reader);
break;
case BANKACCTFROM:
parseAccountInfo(bank, parseAccountInfo(reader));
break;
case BANKTRANLIST:
parseBankTransactionList(reader);
break;
case STMTTRNRS: // consume it
case TRNUID:
case STMTRS:
break;
default:
logger.log(Level.WARNING, "Unknown BANKMSGSRSV1 element: {0}", reader.getLocalName());
break;
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the bank message set aggregate");
break;
}
default:
}
}
logger.exiting(OfxV2Parser.class.getName(), "parseBankMessageSet");
}
/**
* Parses a CREDITCARDMSGSRSV1 element
*
* @param reader shared XMLStreamReader
* @throws XMLStreamException XML parsing error has occurred
*/
private void parseCreditCardMessageSet(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseCreditCardMessageSet");
final QName parsingElement = reader.getName();
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case STATUS:
parseStatementStatus(reader);
break;
case CURDEF:
bank.currency = reader.getElementText();
break;
case LEDGERBAL:
parseLedgerBalance(reader);
break;
case AVAILBAL:
parseAvailableBalance(reader);
break;
case CCACCTFROM:
parseAccountInfo(bank, parseAccountInfo(reader));
break;
case BANKTRANLIST:
parseBankTransactionList(reader);
break;
default:
logger.log(Level.WARNING, "Unknown CREDITCARDMSGSRSV1 element: {0}", reader.getLocalName());
break;
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the credit card message set aggregate");
break;
}
default:
}
}
logger.exiting(OfxV2Parser.class.getName(), "parseCreditCardMessageSet");
}
/**
* Parses a SECLISTMSGSRSV1 element
*
* @param reader shared XMLStreamReader
* @throws XMLStreamException XML parsing error has occurred
*/
private void parseSecuritesMessageSet(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseSecuritesMessageSet");
final QName parsingElement = reader.getName();
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
if (SECLIST.equals(reader.getLocalName())) {
parseSecuritiesList(reader);
} else {
logger.log(Level.WARNING, "Unknown SECLISTMSGSRSV1 element: {0}", reader.getLocalName());
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the sercurites set");
break;
}
default:
}
}
logger.exiting(OfxV2Parser.class.getName(), "parseSecuritesMessageSet");
}
private void parseSecuritiesList(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseSecuritiesList");
final QName parsingElement = reader.getName();
parse:
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case ASSETCLASS:
case OPTTYPE:
case STRIKEPRICE:
case DTEXPIRE:
case SHPERCTRCT:
case YIELD:
break; // just consume it, not used
case SECID: // underlying stock for an Option. Not used, so consume it here
case UNIQUEID:
case UNIQUEIDTYPE:
break;
case SECINFO:
parseSecurity(reader);
break;
case MFINFO: // just consume it
case OPTINFO:
case STOCKINFO:
break;
default:
logger.log(Level.WARNING, "Unknown SECLIST element: {0}", reader.getLocalName());
break;
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the securities list");
break parse;
}
default:
}
}
logger.exiting(OfxV2Parser.class.getName(), "parseSecuritiesList");
}
private void parseSecurity(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseSecurity");
final QName parsingElement = reader.getName();
final ImportSecurity importSecurity = new ImportSecurity();
parse:
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case FIID:
case SECID: // consume it:
break;
case UNIQUEIDTYPE:
importSecurity.idType = reader.getElementText().trim();
break;
case UNIQUEID:
importSecurity.setId(reader.getElementText().trim());
break;
case SECNAME:
importSecurity.setSecurityName(reader.getElementText().trim());
break;
case TICKER:
importSecurity.setTicker(reader.getElementText().trim());
break;
case UNITPRICE:
importSecurity.setUnitPrice(parseAmount(reader.getElementText()));
break;
case DTASOF:
importSecurity.setLocalDate(parseDate(reader.getElementText()));
break;
case CURRENCY: // consume the currency aggregate for unit price and handle here
case ORIGCURRENCY:
break;
case RATING: // consume, not used
break;
case CURSYM:
importSecurity.setCurrency(reader.getElementText().trim());
break;
case CURRATE:
importSecurity.setCurrencyRate(parseAmount(reader.getElementText()));
break;
default:
logger.log(Level.WARNING, "Unknown SECINFO element: {0}", reader.getLocalName());
break;
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the security info");
break parse;
}
default:
}
}
bank.addSecurity(importSecurity);
logger.exiting(OfxV2Parser.class.getName(), "parseSecurity");
}
/**
* Parses a BANKTRANLIST element
*
* @param reader shared XMLStreamReader
* @throws XMLStreamException XML parsing error has occurred
*/
private void parseBankTransactionList(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseBankTransactionList");
final QName parsingElement = reader.getName();
parse:
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case DTSTART:
bank.dateStart = parseDate(reader.getElementText());
break;
case DTEND:
bank.dateEnd = parseDate(reader.getElementText());
break;
case STMTTRN:
parseBankTransaction(reader);
break;
default:
logger.log(Level.WARNING, "Unknown BANKTRANLIST element: {0}", reader.getLocalName());
break;
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the bank transaction list");
break parse;
}
default:
}
}
logger.exiting(OfxV2Parser.class.getName(), "parseBankTransactionList");
}
private void parseInvestmentTransactionList(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseInvestmentTransactionList");
final QName parsingElement = reader.getName();
parse:
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case DTSTART:
bank.dateStart = parseDate(reader.getElementText());
break;
case DTEND:
bank.dateEnd = parseDate(reader.getElementText());
break;
case BUYSTOCK:
case BUYMF:
case BUYOTHER:
case INCOME:
case REINVEST:
case SELLSTOCK:
parseInvestmentTransaction(reader);
break;
case INVBANKTRAN:
parseBankTransaction(reader);
break;
default:
logger.log(Level.WARNING, "Unknown INVTRANLIST element: {0}", reader.getLocalName());
break;
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the investment transaction list");
break parse;
}
default:
}
}
logger.exiting(OfxV2Parser.class.getName(), "parseInvestmentTransactionList");
}
private void parseInvestmentTransaction(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseInvestmentTransaction");
final QName parsingElement = reader.getName();
final ImportTransaction tran = new ImportTransaction();
// set the descriptive transaction type text as well
tran.setTransactionTypeDescription(parsingElement.toString());
// extract the investment transaction type from the element name
switch (parsingElement.toString()) {
case BUYMF:
case BUYOTHER:
case BUYSTOCK:
tran.setTransactionType(TransactionType.BUYSHARE);
break;
case SELLMF:
case SELLOTHER:
case SELLSTOCK:
tran.setTransactionType(TransactionType.SELLSHARE);
break;
case INCOME: // dividend
tran.setTransactionType(TransactionType.DIVIDEND);
break;
case REINVEST:
tran.setTransactionType(TransactionType.REINVESTDIV);
break;
default:
logger.log(Level.WARNING, "Unknown investment transaction type: {0}", parsingElement.toString());
break;
}
parse:
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case DTSETTLE:
tran.setDatePosted(parseDate(reader.getElementText()));
break;
case DTTRADE:
tran.setDateUser(parseDate(reader.getElementText()));
break;
case TOTAL: // total of the investment transaction
tran.setAmount(parseAmount(reader.getElementText()));
break;
case FITID:
tran.setFITID(reader.getElementText());
break;
case UNIQUEID: // the security for the transaction
tran.setSecurityId(reader.getElementText());
break;
case UNIQUEIDTYPE: // the type of security for the transaction
tran.setSecurityType(reader.getElementText());
break;
case UNITS:
tran.setUnits(parseAmount(reader.getElementText()));
break;
case UNITPRICE:
tran.setUnitPrice(parseAmount(reader.getElementText()));
break;
case FEES: // investment fees
tran.setFees(parseAmount(reader.getElementText()));
break;
case COMMISSION: // investment commission
tran.setCommission(parseAmount(reader.getElementText()));
break;
case INCOMETYPE:
tran.setIncomeType(reader.getElementText());
break;
case SUBACCTSEC:
case SUBACCTFROM:
case SUBACCTTO:
case SUBACCTFUND:
tran.setSubAccount(reader.getElementText());
break;
case CHECKNUM:
tran.setCheckNumber(reader.getElementText());
break;
case NAME:
case PAYEE: // either PAYEE or NAME will be used
tran.setPayee(removeExtraWhiteSpace(reader.getElementText()));
break;
case MEMO:
tran.setMemo(removeExtraWhiteSpace(reader.getElementText()));
break;
case CATEGORY: // Chase bank mucking up the OFX standard
break;
case SIC:
tran.setSIC(reader.getElementText());
break;
case REFNUM:
tran.setRefNum(reader.getElementText());
break;
case PAYEEID:
tran.setPayeeId(removeExtraWhiteSpace(reader.getElementText()));
break;
case CURRENCY: // currency used for the transaction
//tran.currency = reader.getElementText();
consumeElement(reader);
break;
case ORIGCURRENCY:
tran.setCurrency(reader.getElementText());
break;
case TAXEXEMPT:
tran.setTaxExempt(parseBoolean(reader.getElementText()));
break;
case BUYTYPE:
case INVBUY: // consume
case INVTRAN:
case SECID:
break;
case LOANID: // consume 401k loan information
case LOANPRINCIPAL:
case LOANINTEREST:
break;
case INV401KSOURCE: // consume 401k information
case DTPAYROLL:
case PRIORYEARCONTRIB:
break;
default:
logger.log(Level.WARNING, "Unknown investment transaction element: {0}",
reader.getLocalName());
break;
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
//logger.fine("Found the end of the investment transaction");
break parse;
}
default:
}
}
bank.addTransaction(tran);
logger.exiting(OfxV2Parser.class.getName(), "parseInvestmentTransaction");
}
private String removeExtraWhiteSpace(final String string) {
return extraSpaceRegex.matcher(string).replaceAll(" ").trim();
}
/**
* Parses a STMTTRN element
*
* @param reader shared XMLStreamReader
* @throws XMLStreamException XML parsing error has occurred
*/
private void parseBankTransaction(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseBankTransaction");
final QName parsingElement = reader.getName();
final ImportTransaction tran = new ImportTransaction();
parse:
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case TRNTYPE:
tran.setTransactionTypeDescription(reader.getElementText());
break;
case DTPOSTED:
tran.setDatePosted(parseDate(reader.getElementText()));
break;
case DTUSER:
tran.setDateUser(parseDate(reader.getElementText()));
break;
case TRNAMT:
tran.setAmount(parseAmount(reader.getElementText()));
break;
case FITID:
tran.setFITID(reader.getElementText());
break;
case CHECKNUM:
tran.setCheckNumber(reader.getElementText());
break;
case NAME:
case PAYEE: // either PAYEE or NAME will be used
tran.setPayee(removeExtraWhiteSpace(reader.getElementText()));
break;
case MEMO:
tran.setMemo(removeExtraWhiteSpace(reader.getElementText()));
break;
case CATEGORY: // Chase bank mucking up the OFX standard
break;
case SIC:
tran.setSIC(reader.getElementText());
break;
case REFNUM:
tran.setRefNum(reader.getElementText());
break;
case PAYEEID:
tran.setPayeeId(removeExtraWhiteSpace(reader.getElementText()));
break;
case CURRENCY:
case ORIGCURRENCY:
tran.setCurrency(reader.getElementText());
break;
case SUBACCTFUND: // transfer into / out off an investment account
tran.setSubAccount(reader.getElementText());
break;
case STMTTRN: // consume, occurs with an investment account transfer
break;
case BANKACCTTO:
case CCACCTTO:
case INVACCTTO:
parseAccountInfo(tran, parseAccountInfo(reader));
break;
default:
logger.log(Level.WARNING, "Unknown STMTTRN element: {0}", reader.getLocalName());
break;
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the bank transaction");
break parse;
}
default:
}
}
bank.addTransaction(tran);
logger.exiting(OfxV2Parser.class.getName(), "parseBankTransaction");
}
private static void parseAccountInfo(final ImportTransaction importTransaction, final AccountInfo accountInfo) {
importTransaction.setAccountTo(accountInfo.accountId);
}
/**
* Parses a BANKACCTFROM element
*
* @param reader shared XMLStreamReader
* @throws XMLStreamException XML parsing error has occurred
*/
private static AccountInfo parseAccountInfo(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseAccountInfo");
final QName parsingElement = reader.getName();
final AccountInfo accountInfo = new AccountInfo();
parse:
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case BANKID:
case BROKERID: // normally a URL per the OFX specification
accountInfo.bankId = reader.getElementText();
break;
case ACCTID:
accountInfo.accountId = reader.getElementText();
break;
case ACCTTYPE:
accountInfo.accountType = reader.getElementText();
break;
case BRANCHID:
accountInfo.branchId = reader.getElementText();
break;
default:
logger.log(Level.WARNING, "Unknown BANKACCTFROM element: {0}", reader.getLocalName());
break;
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the bank and account info aggregate");
break parse;
}
default:
}
}
logger.exiting(OfxV2Parser.class.getName(), "parseAccountInfo");
return accountInfo;
}
private static void parseAccountInfo(final OfxBank ofxBank, final AccountInfo accountInfo) {
ofxBank.bankId = accountInfo.bankId;
ofxBank.branchId = accountInfo.branchId;
ofxBank.accountId = accountInfo.accountId;
ofxBank.accountType = accountInfo.accountType;
}
/**
* Parses a LEDGERBAL element
*
* @param reader shared XMLStreamReader
* @throws XMLStreamException XML parsing error has occurred
*/
private void parseLedgerBalance(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseLedgerBalance");
final QName parsingElement = reader.getName();
parse:
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case BALAMT:
bank.ledgerBalance = parseAmount(reader.getElementText());
break;
case DTASOF:
bank.ledgerBalanceDate = parseDate(reader.getElementText());
break;
default:
logger.log(Level.WARNING, "Unknown ledger balance information {0}", reader.getLocalName());
break;
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the ledger balance aggregate");
break parse;
}
default:
}
}
logger.exiting(OfxV2Parser.class.getName(), "parseLedgerBalance");
}
/**
* Parses a AVAILBAL element
*
* @param reader shared XMLStreamReader
* @throws XMLStreamException XML parsing error has occurred
*/
private void parseAvailableBalance(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseAvailableBalance");
final QName parsingElement = reader.getName();
parse:
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case BALAMT:
bank.availBalance = parseAmount(reader.getElementText());
break;
case DTASOF:
bank.availBalanceDate = parseDate(reader.getElementText());
break;
default:
logger.log(Level.WARNING, "Unknown AVAILBAL element {0}", reader.getLocalName());
break;
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the available balance aggregate");
break parse;
}
default:
}
}
logger.exiting(OfxV2Parser.class.getName(), "parseAvailableBalance");
}
/**
* Parses a SIGNONMSGSRSV1 element
*
* @param reader shared XMLStreamReader
* @throws XMLStreamException XML parsing error has occurred
*/
private void parseSignOnMessageSet(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseSignOnMessageSet");
final QName parsingElement = reader.getName();
parse:
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case LANGUAGE:
language = reader.getElementText();
break;
case STATUS:
parseSignOnStatus(reader);
break;
case FI:
case FID:
case ORG:
case INTUBID:
case INTUUSERID:
default:
break;
}
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the sign-on message set aggregate");
break parse;
}
default:
}
}
logger.exiting(OfxV2Parser.class.getName(), "parseSignOnMessageSet");
}
/**
* Parses a STATUS element from the SignOn element
*
* @param reader shared XMLStreamReader
* @throws XMLStreamException XML parsing error has occurred
*/
private void parseSignOnStatus(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseSignOnStatus");
final QName parsingElement = reader.getName();
parse:
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case CODE:
try {
statusCode = Integer.parseInt(reader.getElementText());
} catch (final NumberFormatException ex) {
logger.log(Level.SEVERE, ex.getLocalizedMessage(), ex);
}
break;
case SEVERITY:
statusSeverity = reader.getElementText();
break;
case MESSAGE: // consume it, not used
statusMessage = reader.getElementText();
break;
default:
logger.log(Level.WARNING, "Unknown STATUS element {0}", reader.getLocalName());
break;
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the statusCode response");
break parse;
}
default:
}
}
logger.exiting(OfxV2Parser.class.getName(), "parseSignOnStatus");
}
/**
* Parses a STATUS element from the statement element
*
* @param reader shared XMLStreamReader
* @throws XMLStreamException XML parsing error has occurred
*/
private void parseStatementStatus(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "parseSignOnStatus");
final QName parsingElement = reader.getName();
parse:
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
switch (reader.getLocalName()) {
case CODE:
try {
bank.statusCode = Integer.parseInt(reader.getElementText());
} catch (final NumberFormatException ex) {
logger.log(Level.SEVERE, ex.getLocalizedMessage(), ex);
}
break;
case SEVERITY:
bank.statusSeverity = reader.getElementText();
break;
case MESSAGE:
bank.statusMessage = reader.getElementText();
break;
default:
logger.log(Level.WARNING, "Unknown STATUS element {0}", reader.getLocalName());
break;
}
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.fine("Found the end of the statement status response");
break parse;
}
default:
}
}
logger.exiting(OfxV2Parser.class.getName(), "parseStatementStatus");
}
/**
* Consumes an element that will not be used
*
* @param reader shared XMLStreamReader
* @throws XMLStreamException XML parsing error has occurred
*/
private static void consumeElement(final XMLStreamReader reader) throws XMLStreamException {
logger.entering(OfxV2Parser.class.getName(), "consumeElement");
final QName parsingElement = reader.getName();
parse:
while (reader.hasNext()) {
final int event = reader.next();
switch (event) {
case XMLStreamConstants.START_ELEMENT:
reader.getLocalName();
break;
case XMLStreamConstants.END_ELEMENT:
if (reader.getName().equals(parsingElement)) {
logger.log(Level.FINEST, "Found the end of consumed element {0}", reader.getName());
break parse;
}
default:
}
}
logger.exiting(OfxV2Parser.class.getName(), "consumeElement");
}
public OfxBank getBank() {
logger.log(Level.INFO, "OFX Status was: {0}", statusCode);
logger.log(Level.INFO, "Status Level was: {0}", statusSeverity);
logger.log(Level.INFO, "File language was: {0}", language);
return bank;
}
int getStatusCode() {
return statusCode;
}
String getStatusSeverity() {
return statusSeverity;
}
public String getLanguage() {
return language;
}
String getStatusMessage() {
return statusMessage;
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/ofx/Sanitize.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat.ofx;
/**
* Utility class for handling OFX files with invalid characters
*
* @author Craig Cavanaugh
*/
class Sanitize {
private Sanitize() {
// utility class
}
/**
* Replaces illegal XML characters with escaped characters
*
* @param xml String to process
* @return valid string
*/
static String sanitize(final String xml) {
String ugly = xml;
// remove all XML declarations as they are not needed
ugly = ugly.replaceAll("<\\?xml.*\\?>", "");
ugly = ugly.replaceAll("&(?!(?:amp);)", "&");
ugly = ugly.replaceAll("\"", """);
ugly = ugly.replaceAll("'", "'");
return ugly;
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifAccount.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat.qif;
import java.util.List;
import java.util.Objects;
import jgnash.convert.importat.DateFormat;
import jgnash.convert.importat.ImportBank;
/**
* @author Craig Cavanaugh
*/
public class QifAccount extends ImportBank {
public String name;
public String type;
public String description = "";
private DateFormat dateFormat = null;
@Override
public List getTransactions() {
if (dateFormat == null) {
setDateFormat(QifTransaction.determineDateFormat(super.getTransactions()));
}
reparseDates(getDateFormat()); // reparse the dates before returning
return super.getTransactions();
}
public QifTransaction get(final int index) {
return super.getTransactions().get(index);
}
@Override
public String toString() {
return "Name: " + name + '\n' + "Type: " + type + '\n' + "Description: " + description + '\n';
}
public void reparseDates(final DateFormat dateFormat) {
Objects.requireNonNull(dateFormat);
setDateFormat(dateFormat);
for (final QifTransaction transaction: super.getTransactions()) {
transaction.setDatePosted(QifTransaction.parseDate(transaction.oDate, dateFormat));
}
}
public DateFormat getDateFormat() {
if (dateFormat == null) {
return DateFormat.US;
}
return dateFormat;
}
public void setDateFormat(final DateFormat dateFormat) {
Objects.requireNonNull(dateFormat);
this.dateFormat = dateFormat;
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifCategory.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat.qif;
/**
* @author Craig Cavanaugh
*/
class QifCategory {
public String name;
public String description;
public String type;
public QifCategory() {
type = "E";
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifImport.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat.qif;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import jgnash.convert.importat.DateFormat;
import jgnash.convert.importat.ImportUtils;
import jgnash.engine.Account;
import jgnash.engine.AccountType;
import jgnash.engine.CurrencyNode;
import jgnash.engine.Engine;
import jgnash.engine.EngineFactory;
import jgnash.engine.ReconcileManager;
import jgnash.engine.ReconciledState;
import jgnash.engine.Transaction;
import jgnash.engine.TransactionEntry;
import jgnash.engine.TransactionFactory;
/**
* QifImport takes a couple of simple steps to prevent importing a duplicate account. Other than that, duplicate
* transactions will be imported. At this time, it would require a lot of extra and normally unused data to be stored
* with each transaction and account to prevent duplication. This import utility is ideal for importing an existing data
* set, but not for importing monthly bank statements. A more specialized import utility may be useful/required for more
* advanced in
*
* @author Craig Cavanaugh
*/
public class QifImport {
/**
* Default for a QIF import
*/
private static final String FITID = "qif";
private QifParser parser;
private final Engine engine;
private final HashMap expenseMap = new HashMap<>();
private final HashMap incomeMap = new HashMap<>();
private final HashMap accountMap = new HashMap<>();
private boolean partialImport = false;
private static final Logger logger = Logger.getLogger("qifimport");
public QifImport() {
engine = EngineFactory.getEngine(EngineFactory.DEFAULT);
}
public QifParser getParser() {
return parser;
}
public void doFullParse(final File file, final DateFormat dateFormat) throws IOException {
if (file != null) {
parser = new QifParser(dateFormat);
parser.parseFullFile(file);
logger.info("*** Parsing Complete ***");
}
}
public void doFullImport() {
if (parser != null) {
importCategories();
importAccounts();
logger.info("*** Importing Complete ***");
}
}
public boolean doPartialParse(final File file) {
if (file != null) {
partialImport = true;
parser = new QifParser(DateFormat.US);
return parser.parsePartialFile(file);
}
return false;
}
public void dumpStats() {
if (parser != null) {
parser.dumpStats();
}
}
private void importCategories() {
logger.info("*** Importing Categories ***");
loadCategoryMap(engine.getExpenseAccountList(), expenseMap);
loadCategoryMap(engine.getIncomeAccountList(), incomeMap);
reduceCategories();
addCategories();
}
private void importAccounts() {
loadAccountMap();
addAccounts();
}
private static void loadCategoryMap(final List list, final Map map) {
if (list != null) { // protect against a failed load on a new account
for (Account aList : list) {
loadCategoryMap(aList, map);
}
}
}
private static void loadCategoryMap(final Account acc, final Map map) {
String pathName = acc.getPathName();
int index = pathName.indexOf(':');
if (index != -1) {
map.put(pathName.substring(index + 1), acc);
}
}
/**
* Returns a list of all accounts excluding the rootAccount and IncomeAccounts and ExpenseAccounts
*
* @return List of bank accounts
*/
private List getBankAccountList() {
final List list = engine.getAccountList();
return list.stream().filter(a -> a.getAccountType() == AccountType.BANK
|| a.getAccountType() == AccountType.CASH).collect(Collectors.toList());
}
private void loadAccountMap() {
List list = getBankAccountList();
list.forEach(this::loadAccountMap);
}
private void loadAccountMap(final Account acc) {
String pathName = acc.getPathName();
int index = pathName.indexOf(':');
if (index != -1) {
accountMap.put(pathName.substring(index + 1), acc);
}
}
/**
* All of the accounts must be added before the transactions are added because the category (account) must exist
* first
*/
private void addAccounts() {
logger.info("*** Importing Accounts ***");
List list = parser.accountList;
// add all of the accounts first
for (QifAccount qAcc : list) {
Account acc;
if (!accountMap.containsKey(qAcc.name)) { // add the account if it does not exist
acc = generateAccount(qAcc);
if (acc != null) {
engine.addAccount(engine.getRootAccount(), acc);
loadAccountMap(acc);
}
}
}
logger.info("*** Importing Transactions ***");
// go back and add the transactions;
for (QifAccount qAcc : list) {
Account acc = accountMap.get(qAcc.name);
// try and match the closest
if (acc == null) {
acc = engine.getAccountByName(qAcc.name);
}
// TODO Correct import of investment transactions
if (acc != null && acc.getAccountType() != AccountType.INVEST) {
addTransactions(qAcc, acc);
} else {
if (acc != null) {
logger.severe("Investment transactions not fully supported");
} else {
logger.log(Level.SEVERE, "Lost the account: {0}", qAcc.name);
}
}
}
}
private void addTransactions(final QifAccount qAcc, final Account acc) {
if (qAcc.getTransactions().isEmpty()) {
return;
}
List list = qAcc.getTransactions();
for (QifTransaction aList : list) {
Transaction tran;
/*if (acc.getAccountType().equals(AccountType.INVEST)) {
//tran = generateInvestmentTransaction(aList, acc);
tran = generateTransaction(aList, acc);
} else {
tran = generateTransaction(aList, acc);
}*/
tran = generateTransaction(aList, acc);
if (tran != null) {
if (partialImport) {
tran.setFitid(FITID); // importing a bank statement, flag as imported
}
engine.addTransaction(tran);
} else {
logger.warning("Null Transaction!");
}
}
}
private void addCategories() {
List list = parser.categories;
Map map;
for (QifCategory cat : list) {
Account acc = generateAccount(cat);
if (acc.getAccountType() == AccountType.EXPENSE) {
map = expenseMap;
} else {
map = incomeMap;
}
Account parent = findBestParent(cat, map);
engine.addAccount(parent, acc);
loadCategoryMap(acc, map);
}
}
/**
* Returns the Account of the best possible parent account for the supplied QifCategory
*
* @param cat imported QifCategory to match
* @param map cached account map
* @return best Account match
*/
private Account findBestParent(final QifCategory cat, final Map map) {
int i = cat.name.lastIndexOf(':');
if (i != -1) {
String pathName = cat.name.substring(0, i);
while (true) {
if (map.containsKey(pathName)) {
return map.get(pathName);
}
int j = pathName.lastIndexOf(':');
if (j != -1) {
pathName = pathName.substring(0, j);
} else {
break;
}
}
}
Account parent;
if (cat.type.equals("E")) {
parent = ImportUtils.getRootExpenseAccount();
} else {
parent = ImportUtils.getRootIncomeAccount();
}
if (parent != null) {
return parent;
}
return engine.getRootAccount();
}
/**
* Returns the best matching account
*
* @param category QIF category
* @return Best matching account
*/
private Account findBestAccount(final String category) {
Account acc = null;
// nulls can happen and don't search on an empty category
if (category != null && !category.isEmpty()) {
String name = category;
if (isAccount(name)) { // account
name = category.substring(1, category.length() - 1);
logger.log(Level.FINEST, "Looking for bank account: {0}", name);
acc = accountMap.get(name);
}
if (acc == null) { // income or expense account
// strip any category tags
name = QifUtils.stripCategoryTags(name);
acc = expenseMap.get(name);
if (acc == null) {
acc = incomeMap.get(name);
}
}
if (acc == null) {
logger.log(Level.WARNING, "No account match for: {0}", name);
}
}
return acc;
}
/**
* Determines if the supplied String represents a QIF account
*
* @param category category string to validate
* @return true if this is suppose to be an account
*/
private static boolean isAccount(final String category) {
return category.startsWith("[") && category.endsWith("]");
}
/*
* Removes duplicate categories from a supplied list
*/
private void reduceCategories() {
QifCategory cat;
String path;
List list = parser.categories;
Iterator i = list.iterator();
while (i.hasNext()) {
cat = i.next();
path = cat.name;
if (cat.type.equals("E") && expenseMap.containsKey(path)) {
i.remove();
} else if (cat.type.equals("I") && incomeMap.containsKey(path)) {
i.remove();
}
}
}
/*
* Creates and returns an Account of the correct type given a QifCategory
*/
private Account generateAccount(final QifCategory cat) {
Account account;
CurrencyNode defaultCurrency = engine.getDefaultCurrency();
if (cat.type.equals("E")) {
account = new Account(AccountType.EXPENSE, defaultCurrency);
// account.setTaxRelated(cat.taxRelated);
// account.setTaxSchedule(cat.taxSchedule);
} else {
account = new Account(AccountType.INCOME, defaultCurrency);
// account.setTaxRelated(cat.taxRelated);
// account.setTaxSchedule(cat.taxSchedule);
}
// trim off the leading parent account
int index = cat.name.lastIndexOf(':');
if (index != -1) {
account.setName(cat.name.substring(index + 1));
} else {
account.setName(cat.name);
}
account.setDescription(cat.description);
return account;
}
private Account generateAccount(final QifAccount acc) {
Account account;
CurrencyNode defaultCurrency = engine.getDefaultCurrency();
switch (acc.type) {
case "Bank":
account = new Account(AccountType.BANK, defaultCurrency);
break;
case "CCard":
account = new Account(AccountType.CREDIT, defaultCurrency);
break;
case "Cash":
account = new Account(AccountType.CASH, defaultCurrency);
break;
case "Invst":
case "Port":
account = new Account(AccountType.INVEST, defaultCurrency);
break;
case "Oth A":
account = new Account(AccountType.ASSET, defaultCurrency);
break;
case "Oth L":
account = new Account(AccountType.LIABILITY, defaultCurrency);
break;
default:
logger.log(Level.SEVERE, "Could not generate an account for:\n{0}", acc.toString());
return null;
}
account.setName(acc.name);
account.setDescription(acc.description);
return account;
}
/**
* Generates a transaction
*
* Notes: If a QifTransaction does not specify an account, then assume it is a single
* entry transaction for the supplied Account. The transaction most likely came from a online banking source.
*
* @param qTran Qif transaction to generate Transaction for
* @param acc base Account
* @return new Transaction
*/
private Transaction generateTransaction(final QifTransaction qTran, final Account acc) {
Objects.requireNonNull(acc);
boolean reconciled = "x".equalsIgnoreCase(qTran.status);
Transaction tran;
Account cAcc;
if (qTran.getAccount() != null) {
cAcc = qTran.getAccount();
} else {
cAcc = findBestAccount(qTran.category);
}
if (qTran.hasSplits()) {
tran = new Transaction();
// create a double entry transaction with splits
List splits = qTran.splits;
for (QifSplitTransaction splitTransaction : splits) {
TransactionEntry split = generateSplitTransaction(splitTransaction, acc);
Objects.requireNonNull(split); // should not be null, throw an exception
tran.addTransactionEntry(split);
}
ReconcileManager.reconcileTransaction(acc, tran, reconciled ? ReconciledState.RECONCILED : ReconciledState.NOT_RECONCILED);
} else if (acc == cAcc && !qTran.hasSplits() || cAcc == null) {
// create single entry transaction without splits
tran = TransactionFactory.generateSingleEntryTransaction(acc, qTran.getAmount(), qTran.getDatePosted(), qTran.getMemo(),
qTran.getPayee(), qTran.getCheckNumber());
ReconcileManager.reconcileTransaction(acc, tran, reconciled ? ReconciledState.RECONCILED : ReconciledState.NOT_RECONCILED);
} else if (!qTran.hasSplits()) { // && cAcc != null
// create a double entry transaction without splits
if (qTran.getAmount().signum() == -1) {
tran = TransactionFactory.generateDoubleEntryTransaction(cAcc, acc, qTran.getAmount(), qTran.getDatePosted(),
qTran.getMemo(), qTran.getPayee(), qTran.getCheckNumber());
} else {
tran = TransactionFactory.generateDoubleEntryTransaction(acc, cAcc, qTran.getAmount(), qTran.getDatePosted(),
qTran.getMemo(), qTran.getPayee(), qTran.getCheckNumber());
}
ReconcileManager.reconcileTransaction(cAcc, tran, reconciled ? ReconciledState.RECONCILED : ReconciledState.NOT_RECONCILED);
ReconcileManager.reconcileTransaction(acc, tran, reconciled ? ReconciledState.RECONCILED : ReconciledState.NOT_RECONCILED);
if (isAccount(qTran.category)) {
removeMirrorTransaction(qTran, acc); // remove the mirror transaction
}
} else {
// could not find the account this transaction belongs to
logger.log(Level.WARNING, "Could not create following transaction:" + "\n{0}", qTran.toString());
return null;
}
tran.setDate(qTran.getDatePosted());
tran.setPayee(qTran.getPayee());
tran.setNumber(qTran.getCheckNumber());
return tran;
}
private Account unassignedExpense = null;
private Account unassignedIncome = null;
/**
* Generates a Transaction given a QifSplitTransaction
*
* @param qTran split qif transaction to convert
* @param acc base Account
* @return generated TransactionEntry
*/
private TransactionEntry generateSplitTransaction(final QifSplitTransaction qTran, final Account acc) {
TransactionEntry tran = new TransactionEntry();
Account account = findBestAccount(qTran.category);
/* Verify that the splits category is not assigned to the parent account. This is
* allowed within Quicken, but violates double entry and is not allowed in jGnash.
* Wipe the resulting account and default to the unassigned accounts to maintain
* integrity.
*/
if (account == acc) {
logger.warning("Detected an invalid split transactions entry, correcting problem");
account = null;
}
/* If a valid account is found at this point, then it should have a duplicate
* entry in another account that needs to be removed
*/
if (account != null && isAccount(qTran.category)) {
removeMirrorSplitTransaction(qTran);
}
if (account == null) { // unassigned split transaction.... fix it with a default
if (qTran.amount.signum() == -1) { // need an expense account
if (unassignedExpense == null) {
unassignedExpense = new Account(AccountType.EXPENSE, engine.getDefaultCurrency());
unassignedExpense.setName("** QIF Import - Unassigned Expense Account");
unassignedExpense.setDescription("Fix transactions and delete this account");
engine.addAccount(engine.getRootAccount(), unassignedExpense);
logger.info("Created an account for unassigned expense account");
}
account = unassignedExpense;
} else {
if (unassignedIncome == null) {
unassignedIncome = new Account(AccountType.INCOME, engine.getDefaultCurrency());
unassignedIncome.setName("** QIF Import - Unassigned Income Account");
unassignedIncome.setDescription("Fix transactions and delete this account");
engine.addAccount(engine.getRootAccount(), unassignedIncome);
logger.info("Created an account for unassigned income account");
}
account = unassignedIncome;
}
}
if (qTran.amount.signum() == -1) {
tran.setDebitAccount(acc);
tran.setCreditAccount(account);
} else {
tran.setDebitAccount(account);
tran.setCreditAccount(acc);
}
tran.setAmount(qTran.amount.abs());
tran.setMemo(qTran.memo);
return tran;
}
/* Cannot check against check number and payee because Quicken allows for the
* different payees at each side of the transaction and does not include the
* check number on both sides.
*
* The date, amount, and account/category is checked to determine if the match is valid
*/
private void removeMirrorTransaction(final QifTransaction qTran, final Account acc) {
String name = qTran.category.substring(1, qTran.category.length() - 1);
List list = parser.accountList;
for (QifAccount qAcc : list) {
if (qAcc.name.equals(name)) {
List items = qAcc.getTransactions();
Iterator i = items.iterator();
QifTransaction tran;
while (i.hasNext()) {
tran = i.next();
if (tran.getAmount().compareTo(qTran.getAmount().negate()) == 0 && tran.getDatePosted().equals(qTran.getDatePosted()) && tran.category.contains(acc.getName())) {
i.remove();
logger.finest("Removed mirror transaction");
return;
}
}
}
}
}
private void removeMirrorSplitTransaction(final QifSplitTransaction qTran) {
String name = qTran.category.substring(1, qTran.category.length() - 1);
logger.log(Level.FINE, "Category name is: {0}", name);
List list = parser.accountList;
for (QifAccount qAcc : list) {
if (qAcc.name.equals(name)) {
List items = qAcc.getTransactions();
Iterator i = items.iterator();
QifTransaction tran;
while (i.hasNext()) {
tran = i.next();
if (tran != null) {
if (tran.getAmount().compareTo(qTran.amount.negate()) == 0 && tran.getMemo().equals(qTran.memo)) {
i.remove();
logger.finest("Removed mirror split transaction");
return;
}
} else { // should not occur anymore
logger.log(Level.SEVERE, "There was a null QifTransaction in QifAccount: \n{0}", qAcc.toString());
}
}
}
}
// could be a split into a bank account... look a level higher
// TODO add check against the base account... should point at each other..
// qTran's category same as tran category?
for (QifAccount qAcc : list) {
if (qAcc.name.equals(name)) {
List items = qAcc.getTransactions();
Iterator i = items.iterator();
QifTransaction tran;
while (i.hasNext()) {
tran = i.next();
// is the match an account and the opposite value and does not have any splits?
// is this a valid method?
if (tran.getAmount().compareTo(qTran.amount.negate()) == 0 && isAccount(tran.category) && !tran.hasSplits()) {
logger.log(Level.FINE, "Found a match:\n{0}", tran.toString());
i.remove();
return;
}
}
}
}
logger.log(Level.WARNING, "Did not find matching mirror:" + "\n{0}", qTran.toString());
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifParser.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat.qif;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import jgnash.convert.importat.DateFormat;
import jgnash.util.FileMagic;
import jgnash.util.NotNull;
/**
* The QIF format seems to be very broken. Various applications and services
* export it differently, some have even extended an already broken format to
* add even more confusion. To make matters worse, the format has changed over
* time with no indication of what "version" the QIF file is.
*
* This parses through the QIF file using brute force, no fancy parser or
* tricks. The QIF file is broken enough that it's easier to find problems with
* the parser when it's easy to step through the code.
*
* The !Option:AutoSwitch and !Clear:AutoSwitch headers do not appear to be used
* correctly, even by the "creator" of the QIF file format. There seems to be
* confusion as to it's purpose. The best thing to do is ignore AutoSwitch
* completely and make an educated guess about the data.
*
* I'm not very happy with this code, but I'm not sure there is a clean solution
* to parsing QIF files
*
* @author Craig Cavanaugh
*/
public final class QifParser {
private DateFormat dateFormat = DateFormat.US;
final ArrayList categories = new ArrayList<>();
private final ArrayList classes = new ArrayList<>();
public final ArrayList accountList = new ArrayList<>();
private final ArrayList securities = new ArrayList<>();
private static final Logger logger = Logger.getLogger(QifParser.class.getName());
QifParser(final DateFormat dateFormat) {
setDateFormat(dateFormat);
}
public QifAccount getBank() {
return accountList.get(0);
}
/**
* Tests if the source string starts with the prefix string. Case is
* ignored.
*
* @param source the source String.
* @param prefix the prefix String.
* @return true, if the source starts with the prefix string.
*/
private static boolean startsWith(final String source, final String prefix) {
return prefix.length() <= source.length() && source.regionMatches(true, 0, prefix, 0, prefix.length());
}
private void setDateFormat(@NotNull final DateFormat dateFormat) {
Objects.requireNonNull(dateFormat);
this.dateFormat = dateFormat;
}
void parseFullFile(final File file) throws IOException {
parseFullFile(file.getAbsolutePath());
}
boolean parsePartialFile(final File file) {
return parsePartialFile(file.getAbsolutePath());
}
private void parseFullFile(final String fileName) throws IOException {
boolean accountFound = true;
final Charset charset = FileMagic.detectCharset(fileName);
try (final QifReader in = new QifReader(Files.newBufferedReader(Paths.get(fileName), charset))) {
String line = in.readLine();
while (line != null) {
if (startsWith(line, "!Type:Class")) {
parseClassList(in);
} else if (startsWith(line, "!Type:Cat")) {
parseCategoryList(in);
} else if (startsWith(line, "!Account")) {
parseAccount(in);
} else if (startsWith(line, "!Type:Memorized")) {
parseMemorizedTransactions(in);
} else if (startsWith(line, "!Type:Security")) {
parseSecurity(in);
} else if (startsWith(line, "!Type:Prices")) {
parsePrice(in);
} else if (startsWith(line, "!Type:Bank")) { // QIF from an online bank statement... assumes the account is known
accountFound = false;
break;
} else if (startsWith(line, "!Type:CCard")) { // QIF from an online credit card statement
accountFound = false;
break;
} else if (startsWith(line, "!Type:Oth")) { // QIF from an online credit card statement
accountFound = false;
break;
} else if (startsWith(line, "!Type:Cash")) { // Partial QIF export
accountFound = false;
break;
} else if (startsWith(line, "!Option:AutoSwitch")) {
logger.info("Consuming !Option:AutoSwitch");
} else if (startsWith(line, "!Clear:AutoSwitch")) {
logger.info("Consuming !Clear:AutoSwitch");
} else {
System.out.println("Error: " + line);
}
line = in.readLine();
}
} catch (final FileNotFoundException e) {
logger.log(Level.WARNING, "Could not find file: {0}", fileName);
} catch (final IOException e) {
logger.log(Level.SEVERE, null, e);
}
if (!accountFound) {
throw new IOException("The account was not found");
}
// re-parse the dates
for (final QifAccount account : accountList) {
account.reparseDates(QifTransaction.determineDateFormat(account.getTransactions()));
}
}
private boolean parsePartialFile(final String fileName) {
final Charset charset = FileMagic.detectCharset(fileName);
try (QifReader in = new QifReader(Files.newBufferedReader(Paths.get(fileName), charset))) {
String peek = in.peekLine();
if (startsWith(peek, "!Type:")) {
final QifAccount acc = new QifAccount(); // "unknown" holding account
if (parseAccountTransactions(in, acc)) {
accountList.add(acc);
logger.finest("*** Added account ***");
// re-parse the dates
acc.reparseDates(QifTransaction.determineDateFormat(acc.getTransactions()));
return true; // only look for transactions for one account
}
System.err.println("parseAccountTransactions: error");
}
} catch (final FileNotFoundException fne) {
logger.log(Level.WARNING, "Could not find file: {0}", fileName);
} catch (final IOException ioe) {
logger.log(Level.SEVERE, null, ioe);
}
return false;
}
private void parseAccount(final QifReader in) throws IOException {
logger.entering(this.getClass().getName(), "parseAccount", in);
String line;
QifAccount acc = new QifAccount();
boolean result = false;
line = in.readLine();
while (line != null) {
if (line.startsWith("N")) {
acc.name = line.substring(1);
} else if (line.startsWith("T")) {
acc.type = line.substring(1);
} else if (line.startsWith("D")) {
acc.description = line.substring(1);
} else if (line.startsWith("L")) {
logger.finest("Ignoring credit limit");
} else if (line.startsWith("/")) {
logger.finest("statement balance date");
} else if (line.startsWith("$")) {
logger.finest("Ignoring statement balance");
} else if (line.startsWith("X")) {
// must be GnuCashToQIF... not sure what it is??? ignore it.
logger.warning("Ignoring 'X' attribute");
} else if (line.startsWith("^")) {
String peek = in.peekLine();
if (peek == null) { // end of the file in empty account list
accountList.add(acc);
result = true;
break;
}
if (startsWith(peek, "!Account")) {
// must be in an account list, no transaction data here
accountList.add(acc);
acc = new QifAccount();
in.readLine(); // eat the line since we only peeked at it
} else if (startsWith(peek, "!Type:Memor")) {
accountList.add(acc);
result = true;
break;
} else if (startsWith(peek, "!Type:Invst")) { // investment transactions follow
logger.fine("Found investment transactions");
/* Search for a duplicate account generated by the account list and
* use it if possible. Qif out will output a list of empty accounts
* and then follow up with the same account and it's transactions
*/
QifAccount dup = searchForDuplicate(acc);
if (dup != null) {
acc = dup; // trade for the duplicate already existing in the list
}
if (parseInvestmentAccountTransactions(in, acc)) {
if (dup == null) {
accountList.add(acc); // only add if not a duplicate
}
logger.finest("Added Qif Account");
result = true;
break; // exit here, the outer loop will catch the next account if it exists
}
acc = new QifAccount();
} else if (startsWith(peek, "!Type:Prices")) { // security prices, jump out
logger.fine("Found commodity price; jump out");
result = true;
break;
} else if (startsWith(peek, "!Type:")) {
// must be transactions that follow
logger.fine("Found bank transactions");
/* Search for a duplicate account generated by the account list and
* use it if possible. Qif out will output a list of empty accounts
* and then follow up with the same account and it's transactions
*/
QifAccount dup = searchForDuplicate(acc);
if (dup != null) {
acc = dup; // trade for the duplicate already existing in the list
}
if (parseAccountTransactions(in, acc)) {
if (dup == null) {
accountList.add(acc); // only add if not a duplicate
}
logger.finest("Added Qif Account");
result = true;
break; // exit here, the outer loop will catch the next account if it exists
}
acc = new QifAccount();
} else if (startsWith(peek, "!Clear:Auto")) {
in.readLine(); // the broken AutoSwitch.... eat the line
accountList.add(acc);
result = true;
break;
} else if (startsWith(peek, "!")) {
// something weird, assume in empty account list
accountList.add(acc);
result = true;
break;
} else {
// must be in an account list using AutoSwitch
accountList.add(acc);
acc = new QifAccount();
}
} else {
break;
}
line = in.readLine();
}
logger.exiting(this.getClass().getName(), "parseAccount", result);
}
private QifAccount searchForDuplicate(final QifAccount acc) {
String name = acc.name;
String type = acc.type;
String description = acc.description; // assume non-null description
Objects.requireNonNull(description);
if (name == null || type == null) {
logger.log(Level.SEVERE, "Invalid account: \n{0}", acc.toString());
return null;
}
/* Investment account types are not consistent in Quicken export.... buggy software.
* A Type of "Invst" is used as a generic when listing the transactions in the
* investment account.
* TODO "Port" is only one type.... need to discover other types
*/
if (type.equals("Invst") || type.equals("Port")) {
for (QifAccount a : accountList) {
if (a.name.equals(name) && a.description.equals(description)) {
logger.fine("Matched a duplicate account");
return a;
}
}
} else {
for (QifAccount a : accountList) {
if (a.name.equals(name) && a.type.equals(type) && a.description.equals(description)) {
logger.fine("Matched a duplicate account");
return a;
}
}
}
return null;
}
// TODO strip out investment account transaction checks
private static boolean parseAccountTransactions(final QifReader in, final QifAccount acc) {
String line;
QifTransaction tran = new QifTransaction();
try {
line = in.readLine();
while (line != null) {
if (startsWith(line, "!Type:")) {
if (startsWith(line, "!Type:Invst")) {
tran.setTransactionTypeDescription(line.substring(1));
tran.setTransactionTypeDescription(line.substring(1));
} else if (startsWith(line, "!Type:Memor")) {
in.reset();
return true;
} else {
tran.setTransactionTypeDescription(line.substring(1));
}
} else if (line.startsWith("D")) {
/* Preserve the original unparsed date so that it may be
* reevaluated at a later time. */
tran.oDate = line.substring(1);
//tran.datePosted = QifTransaction.parseDate(tran.oDate, dateFormat);
} else if (line.startsWith("U")) {
logger.finest("Ignoring U");
} else if (line.startsWith("T")) {
tran.setAmount(QifUtils.parseMoney(line.substring(1)));
} else if (line.startsWith("C")) {
tran.status = line.substring(1);
} else if (line.startsWith("P")) {
tran.setPayee(line.substring(1));
} else if (line.startsWith("L")) {
tran.category = line.substring(1);
} else if (line.startsWith("N")) {
tran.setCheckNumber(line.substring(1));
} else if (line.startsWith("M")) {
tran.setMemo(line.substring(1));
} else if (line.startsWith("A")) {
logger.log(Level.INFO, "Ignored address line: {0}", line.substring(1));
} else if (line.startsWith("I")) {
tran.price = line.substring(1);
} else if (line.startsWith("^")) {
acc.addTransaction(tran);
logger.finest("*** Added a Transaction ***");
tran = new QifTransaction();
} else if (startsWith(line, "!Account")) {
in.reset();
return true;
} else if (startsWith(line, "!Type:Prices")) { // fund prices... jump out
in.reset();
return true;
} else if (line.charAt(0) == 'S' || line.charAt(0) == 'E' || line.charAt(0) == '$'
|| line.charAt(0) == '%') {
// doing a split transaction
in.reset();
QifSplitTransaction split = parseSplitTransaction(in);
if (split != null) {
tran.addSplit(split);
logger.finest("*** Added a Split Transaction ***");
}
} else {
logger.log(Level.SEVERE, "Unknown field: {0}", line);
}
in.mark();
line = in.readLine();
}
} catch (IOException e) {
return false;
}
return true;
}
private boolean parseInvestmentAccountTransactions(final QifReader in, final QifAccount acc) {
boolean result = true;
logger.entering(this.getClass().getName(), "parseInvestmentAccountTransactions", acc);
String line;
QifTransaction tran = new QifTransaction();
try {
line = in.readLine();
while (line != null) {
if (startsWith(line, "!Type:Invst")) { // TODO Bogus check?
tran.setTransactionTypeDescription(line.substring(1));
} else if (startsWith(line, "!Type:Memor")) {
in.reset();
result = true;
break;
} else if (startsWith(line, "!Type:Prices")) { // fund prices... jump out
logger.fine("Found commodity prices; jumping out");
in.reset();
result = true;
break;
} else if (line.startsWith("D")) {
/* Preserve the original unparsed date so that it may be
* reevaluated at a later time. */
tran.oDate = line.substring(1);
tran.setDatePosted(QifTransaction.parseDate(tran.oDate, dateFormat));
} else if (line.startsWith("U")) {
//tran.U = line.substring(1);
logger.finest("Ignoring U");
} else if (line.startsWith("T")) {
tran.setAmount(QifUtils.parseMoney(line.substring(1)));
} else if (line.startsWith("C")) {
tran.status = line.substring(1);
} else if (line.startsWith("P")) {
tran.setPayee(line.substring(1));
} else if (line.startsWith("L")) {
tran.category = line.substring(1);
} else if (line.startsWith("N")) { // trans type for inv accounts
tran.setCheckNumber(line.substring(1));
} else if (line.startsWith("M")) {
tran.setMemo(line.substring(1));
} else if (line.startsWith("A")) {
logger.log(Level.INFO, "Ignored address line: {0}", line.substring(1));
} else if (line.startsWith("Y")) {
tran.security = line.substring(1);
} else if (line.startsWith("I")) {
tran.price = line.substring(1);
} else if (line.startsWith("Q")) {
tran.quantity = line.substring(1);
} else if (line.charAt(0) == '$') { // must check before split trans checks... Does Quicken allow for split investment transactions?
tran.amountTrans = line.substring(1);
} else if (line.startsWith("^")) {
acc.addTransaction(tran);
logger.finest("*** Added an investment transaction ***");
tran = new QifTransaction();
} else if (startsWith(line, "!Account")) {
in.reset();
result = true;
break;
} else if (line.charAt(0) == 'S' || line.charAt(0) == 'E' || line.charAt(0) == '$'
|| line.charAt(0) == '%') {
// doing a split transaction
in.reset();
QifSplitTransaction split = parseSplitTransaction(in);
if (split != null) {
tran.addSplit(split);
logger.fine("*** Added a Split Transaction ***");
}
} else {
logger.log(Level.SEVERE, "Unknown field: {0}", line);
}
in.mark();
line = in.readLine();
}
} catch (IOException e) {
result = false;
}
logger.exiting(this.getClass().getName(), "parseInvestmentAccountTransactions", result);
return result;
}
private static QifSplitTransaction parseSplitTransaction(final QifReader in) {
boolean category = false;
boolean memo = false;
boolean amount = false;
boolean percentage = false;
String line;
QifSplitTransaction split = new QifSplitTransaction();
try {
line = in.readLine();
while (line != null) {
if (line.startsWith("S")) {
if (category) {
in.reset();
return split;
}
category = true;
split.category = line.substring(1);
} else if (line.startsWith("E")) {
if (memo) {
in.reset();
return split;
}
memo = true;
split.memo = line.substring(1);
} else if (line.startsWith("$")) {
if (amount) {
in.reset();
return split;
}
amount = true;
split.amount = QifUtils.parseMoney(line.substring(1));
} else if (line.startsWith("%")) {
if (percentage) {
in.reset();
return split;
}
percentage = true;
// split.percentage = line.substring(1);
} else if (line.startsWith("^")) {
in.reset();
return split;
} else {
in.reset();
return null;
}
in.mark();
line = in.readLine();
}
return null;
} catch (IOException e) {
return null;
}
}
/**
* Just eats the memorized transaction data. Will not try to convert to jGnash entities
*
* @param in {@code QifReader}
*/
private static void parseMemorizedTransactions(final QifReader in) throws IOException {
logger.finest("*** Start: parseMemorizedTransactions ***");
String line = in.readLine();
while (line != null) {
if (line.startsWith("K")) {
// munch until all of them are gone
line = in.readLine();
if (line != null && line.charAt(0) == '^') {
String peek = in.peekLine();
if (peek == null) {
return;
} else if (!peek.startsWith("K")) {
break;
}
}
} else {
in.reset();
return;
}
in.mark();
line = in.readLine();
}
}
private void parseCategoryList(final QifReader in) throws IOException {
boolean result = false;
QifCategory cat = new QifCategory();
String line = in.readLine();
while (line != null) {
if (line.startsWith("N")) {
cat.name = line.substring(1);
} else if (line.startsWith("D")) {
cat.description = line.substring(1);
} else if (line.startsWith("T")) {
logger.finest("Ignoring tax related flag");
} else if (line.startsWith("I")) {
cat.type = "I";
} else if (line.startsWith("E")) {
cat.type = "E";
} else if (line.startsWith("B")) {
logger.finest("Ignoring budget amount");
} else if (line.startsWith("R")) {
logger.finest("Ignoring tax schedule");
} else if (line.startsWith("^")) { // a complete category item
categories.add(cat); // add it to the list
cat = new QifCategory(); // start a new one
in.mark(); // next line might be end of list
} else if (line.startsWith("!")) { // done with category list
in.reset(); // give the line back
result = true; // a good return
break;
} else {
System.out.println("Error: " + line);
result = false;
}
line = in.readLine();
}
logger.exiting(this.getClass().getName(), "parseCategoryList", result);
}
private void parseClassList(final QifReader in) throws IOException {
QifClassItem classItem = new QifClassItem();
String line = in.readLine();
while (line != null) {
if (line.startsWith("N")) {
classItem.name = line.substring(1);
} else if (line.startsWith("D")) {
classItem.description = line.substring(1);
} else if (line.startsWith("^")) { // end of a class item
classes.add(classItem); // add it to the list
classItem = new QifClassItem(); // start a new one
in.mark(); // next line might be end of the list
} else if (line.startsWith("!")) { // done with the class list
in.reset(); // give the line back
return;
} else {
throw new IOException("Error: " + line);
}
line = in.readLine();
}
}
/**
* So far, I haven't see a security as part of a list, but it is supported
* just in case there is another "variation" of the format
*
* @param in {@code QifReader}
*/
private void parseSecurity(final QifReader in) throws IOException {
boolean result = false;
QifSecurity sec = new QifSecurity();
String line = in.readLine();
while (line != null) {
if (line.startsWith("N")) {
sec.name = line.substring(1);
} else if (line.startsWith("D")) {
sec.description = line.substring(1);
} else if (line.startsWith("T")) {
sec.type = line.substring(1);
} else if (line.startsWith("S")) {
sec.symbol = line.substring(1);
} else if (line.startsWith("^")) {
securities.add(sec);
sec = new QifSecurity();
in.mark();
} else if (line.startsWith("!")) { // end of securities
in.reset();
result = true;
break;
} else {
System.out.println("Error: " + line);
break;
}
line = in.readLine();
}
logger.exiting(this.getClass().getName(), "parseSecurity", result);
}
/**
* Price data in QIF file is not very informative.... ignore it for now
*
* @param in {@code QifReader}
*/
private void parsePrice(final QifReader in) throws IOException {
logger.entering(this.getClass().getName(), "parsePrice");
boolean result = false;
String line = in.readLine();
while (line != null) {
if (line.startsWith("^")) {
result = true;
break;
}
line = in.readLine();
}
logger.exiting(this.getClass().getName(), "parsePrice", result);
}
void dumpStats() {
System.out.println("Num Classes :" + classes.size());
System.out.println("Num Categories :" + categories.size());
System.out.println("Num Securities :" + securities.size());
System.out.println("Num Accounts :" + accountList.size());
int count = accountList.size();
for (int i = 0; i < count; i++) {
QifAccount acc = accountList.get(i);
System.out.println("Account " + (i + 1) + " " + acc.name);
int size = acc.getTransactions().size();
System.out.println(" Num Transactions :" + size);
for (int j = 0; j < size; j++) {
QifTransaction tran = acc.getTransactions().get(j);
System.out.println(" Transaction " + (j + 1) + " " + tran.getPayee());
System.out.println(" Num Splits :" + tran.splits.size());
for (int k = 0; k < tran.splits.size(); k++) {
System.out.println(" Split " + (k + 1) + " " + tran.splits.get(k).memo);
}
}
}
}
static class QifSecurity {
String name;
String description;
String symbol;
String type;
}
static class QifClassItem {
String name;
String description;
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifReader.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat.qif;
import java.io.IOException;
import java.io.LineNumberReader;
import java.io.Reader;
/**
* An extended LineNumberReader to help ease the pain of parsing
* a QIF file
*
* @author Craig Cavanaugh
*/
class QifReader extends LineNumberReader {
private static final boolean debug = false;
QifReader(Reader in) {
super(in, 8192);
}
void mark() throws IOException {
super.mark(256);
}
@Override
public void reset() throws IOException {
super.reset();
if (debug) {
System.out.println("Reset");
}
}
/**
* Takes a peek at the next line and eats and empty line if found
*
* @return next readable line, null if at the end of the file
* @throws IOException IO exception
*/
String peekLine() throws IOException {
String peek;
while (true) {
mark();
peek = readLine();
if (peek != null) {
peek = peek.trim();
reset();
if (peek.isEmpty()) {
readLine(); // eat the empty line
if (debug) {
System.out.println("*EMPTY LINE*");
}
} else {
return peek.trim();
}
} else {
return null;
}
}
}
@Override
public String readLine() throws IOException {
while (true) {
try {
String line = super.readLine().trim();
if (debug) {
System.out.println("Line " + getLineNumber() + ": " + line);
}
if (!line.isEmpty()) {
return line;
}
if (debug) {
System.out.println("*EMPTY LINE*");
}
} catch (NullPointerException e) {
return null;
}
}
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifSplitTransaction.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat.qif;
import java.math.BigDecimal;
/**
* Class for QIF split transaction import
*
* @author Craig Cavanaugh
*/
class QifSplitTransaction {
public String category;
public String memo = "";
public BigDecimal amount;
//public String percentage;
public QifSplitTransaction() {
amount = BigDecimal.ZERO;
}
@Override
public String toString() {
StringBuilder buf = new StringBuilder();
buf.append("Memo: ").append(memo).append('\n');
buf.append("Category: ").append(category).append('\n');
if (amount != null) {
buf.append("Amount:").append(amount).append('\n');
}
return buf.toString();
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifTransaction.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat.qif;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Objects;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import jgnash.convert.importat.DateFormat;
import jgnash.convert.importat.ImportTransaction;
/**
* Transaction object for a QIF transaction
*
* @author Craig Cavanaugh
*/
public class QifTransaction extends ImportTransaction {
private static final Pattern DATE_DELIMITER_PATTERN = Pattern.compile("[/'.-]");
/**
* Original date before conversion
*/
String oDate;
String status = null;
public String category = null;
String security;
String price;
String quantity;
String amountTrans;
public final ArrayList splits = new ArrayList<>();
void addSplit(QifSplitTransaction split) {
splits.add(split);
}
boolean hasSplits() {
return !splits.isEmpty();
}
@Override
public String toString() {
StringBuilder buf = new StringBuilder();
buf.append("Payee: ").append(getPayee()).append('\n');
buf.append("Memo: ").append(getMemo()).append('\n');
buf.append("Category: ").append(category).append('\n');
if (getAmount() != null) {
buf.append("Amount:").append(getAmount()).append('\n');
}
buf.append("Date: ").append(getDatePosted()).append('\n');
return buf.toString();
}
static DateFormat determineDateFormat(final Collection transactions) {
Objects.requireNonNull(transactions);
DateFormat dateFormat = DateFormat.US; // US date is assumed
for (final QifTransaction transaction : transactions) {
// protect against a transaction missing a date Github issue #30
if (transaction.oDate != null) {
int zero;
int one;
//int two;
final String[] chunks = QifTransaction.DATE_DELIMITER_PATTERN.split(transaction.oDate);
zero = Integer.parseInt(chunks[0].trim());
one = Integer.parseInt(chunks[1].trim());
//two = Integer.parseInt(chunks[2].trim());
if (zero > 12 && one <= 12) { // must have a EU date format
dateFormat = DateFormat.EU;
break;
}
}
}
return dateFormat;
}
/**
* Converts a string into a data object
*
* {@code
* "6/21' 1" -> 6/21/2001
* "6/21'01" -> 6/21/2001
* "9/18'2001 -> 9/18/2001
* "06/21/2001" -> "06/21/01"
* "3.26.03" -> German version of quicken format "03-26-2003"
* MSMoney format "1.1.2005"
* kmymoney2 20.1.94
* European dd/mm/yyyy
* 21/2/07 -> 02/21/2007 UK
* Quicken 2007 D15/2/07
* }``
*
* @param sDate String QIF date to parse
* @param format String identifier of format to parse
* @return Returns parsed date and current date if an error occurs
*/
static LocalDate parseDate(final String sDate, final DateFormat format) throws java.time.DateTimeException {
int month = 0;
int day = 0;
int year = 0;
final String[] chunks = DATE_DELIMITER_PATTERN.split(sDate);
try {
switch (format) {
case US:
month = Integer.parseInt(chunks[0].trim());
day = Integer.parseInt(chunks[1].trim());
year = Integer.parseInt(chunks[2].trim());
break;
case EU:
day = Integer.parseInt(chunks[0].trim());
month = Integer.parseInt(chunks[1].trim());
year = Integer.parseInt(chunks[2].trim());
break;
}
} catch (final NumberFormatException e) {
Logger.getLogger(QifUtils.class.getName()).severe(e.toString());
}
if (year < 100) {
if (year < 29) {
year += 2000;
} else {
year += 1900;
}
}
try {
return LocalDate.of(year, month, day);
} catch (Exception e) {
Logger.getLogger(QifUtils.class.getName()).severe("Invalid date format specified");
return LocalDate.now();
}
}
}
================================================
FILE: jgnash-convert/src/main/java/jgnash/convert/importat/qif/QifUtils.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.convert.importat.qif;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import jgnash.engine.MathConstants;
import jgnash.util.FileMagic;
/**
* Various helper methods for importing QIF files
*
* @author Craig Cavanaugh
* @author Navneet Karnani
*/
public class QifUtils {
private static final Pattern MONEY_PREFIX_PATTERN = Pattern.compile("\\D");
private static final Pattern CATEGORY_DELIMITER_PATTERN = Pattern.compile("/");
private QifUtils() {
}
static BigDecimal parseMoney(final String money) {
String sMoney = money;
if (sMoney != null) {
sMoney = sMoney.trim(); // to be safe
try {
return new BigDecimal(sMoney);
} catch (NumberFormatException e) {
/* there must be commas, etc in the number. Need to look for them
* and remove them first, and then try BigDecimal again. If that
* fails, then give up and use NumberFormat and scale it down
* */
String[] split = MONEY_PREFIX_PATTERN.split(sMoney);
if (split.length > 2) {
StringBuilder buf = new StringBuilder();
if (sMoney.startsWith("-")) {
buf.append('-');
}
for (int i = 0; i < split.length - 1; i++) {
buf.append(split[i]);
}
buf.append('.');
buf.append(split[split.length - 1]);
try {
return new BigDecimal(buf.toString());
} catch (final NumberFormatException e2) {
Logger l = Logger.getLogger(QifUtils.class.getName());
l.info("second parse attempt failed");
l.info(buf.toString());
l.info("falling back to rounding");
}
}
NumberFormat formatter = NumberFormat.getNumberInstance();
try {
Number num = formatter.parse(sMoney);
BigDecimal bd = BigDecimal.valueOf(num.floatValue());
if (bd.scale() > 6) {
Logger l = Logger.getLogger(QifUtils.class.getName());
l.warning("-Warning-");
l.warning("Large scale detected in QifUtils.parseMoney");
l.warning("Truncating scale to 2 places");
l.warning(bd.toString());
bd = bd.setScale(2, MathConstants.roundingMode);
l.warning(bd.toString());
}
return bd;
} catch (ParseException ignored) {
Logger.getLogger(QifUtils.class.getName())
.log(Level.SEVERE, "poorly formatted number: {0}", sMoney);
}
}
}
return BigDecimal.ZERO;
}
public static boolean isFullFile(final File file) {
boolean result = false;
final Charset charset = FileMagic.detectCharset(file.getPath());
try (final QifReader in = new QifReader(Files.newBufferedReader(file.toPath(), charset))) {
String line = in.readLine();
while (line != null) {
if (startsWith(line, "!Type:Class")) {
result = true;
break;
} else if (startsWith(line, "!Type:Cat")) {
result = true;
break;
} else if (startsWith(line, "!Account")) {
result = true;
break;
} else if (startsWith(line, "!Type:Memorized")) {
result = true;
break;
} else if (startsWith(line, "!Type:Security")) {
result = true;
break;
} else if (startsWith(line, "!Type:Prices")) {
result = true;
break;
} else if (startsWith(line, "!Type:Bank")) { // QIF from an online bank statement... assumes the account is known
break;
} else if (startsWith(line, "!Type:CCard")) { // QIF from an online credit card statement
break;
} else if (startsWith(line, "!Type:Oth")) { // QIF from an online credit card statement
break;
} else if (startsWith(line, "!Type:Cash")) { // Partial QIF export
break;
} else if (startsWith(line, "!Option:AutoSwitch")) {
Logger.getLogger(QifUtils.class.getName()).fine("!Option:AutoSwitch");
} else if (startsWith(line, "!Clear:AutoSwitch")) {
Logger.getLogger(QifUtils.class.getName()).fine("!Clear:AutoSwitch");
} else {
System.out.println("Error: " + line);
break;
}
line = in.readLine();
}
in.close();
return result;
} catch (FileNotFoundException e) {
Logger.getLogger(QifUtils.class.getName()).log(Level.SEVERE, "Could not find file: {0}",
file.getAbsolutePath());
return false;
} catch (IOException e) {
Logger.getLogger(QifUtils.class.getName()).log(Level.SEVERE, null, e);
return false;
}
}
/**
* Tests if the source string starts with the prefix string. Case is
* ignored.
*
* @param source the source String.
* @param prefix the prefix String.
* @return true, if the source starts with the prefix string.
*/
private static boolean startsWith(final String source, final String prefix) {
return prefix.length() <= source.length() && source.regionMatches(true, 0, prefix, 0, prefix.length());
}
/**
* Strip any category tags from the category name... found when parsing
* transactions
*
* @param category string to strip
* @return the stripped string
*/
static String stripCategoryTags(final String category) {
// Auto:Gas/matrix:Vacation > Auto:Gas
if (category != null && category.contains("/")) {
return CATEGORY_DELIMITER_PATTERN.split(category)[0];
}
return category;
}
}
================================================
FILE: jgnash-convert/src/main/resources/jgnash/convert/scripts/tidy.js
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
/*
This scripts normalizes the case of the payee and memo fields
*/
var importTransaction; // place holder for the passed ImportTransaction
/* This will be called first and passed the ImportTransaction */
function acceptTransaction(transaction) {
importTransaction = transaction; // do nothing with it in this script
}
function processMemo(memo) {
return capitalizeFirstLetter(memo.toLocaleLowerCase());
}
function processPayee(payee) {
return titleCase(payee.toLocaleLowerCase());
}
function getDescription(locale) {
var Locale = Packages.java.util.Locale;
switch (locale) {
case Locale.ENGLISH:
return "Tidy Memo and Payee fields";
default:
return "Tidy Memo and Payee fields";
}
}
function capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
function titleCase(str) {
return str.replace(/(^|\s)[a-z]/g,function(f){return f.toUpperCase();});
}
================================================
FILE: jgnash-core/build.gradle.kts
================================================
description = "jGnash Core"
var moduleName = "jgnash.core"
val commonsCollectionsVersion: String by project
val commonsCsvVersion: String by project
val commonsLangVersion: String by project
val commonsMathVersion: String by project
val slf4jVersion: String by project
val hibernateVersion: String by project
val hikariVersion: String by project
val h2Version: String by project
val hsqldbVersion: String by project
val xstreamVersion: String by project
val nettyVersion: String by project
plugins {
`java-library`
}
dependencies {
implementation(project(":jgnash-resources"))
// required for HikariCP, override with modular version
implementation("org.slf4j:slf4j-api:$slf4jVersion")
implementation("org.slf4j:slf4j-jdk14:$slf4jVersion")
api("org.hibernate:hibernate-entitymanager:$hibernateVersion")
implementation("org.hibernate:hibernate-hikaricp:$hibernateVersion")
implementation("com.zaxxer:HikariCP:$hikariVersion")
implementation("com.h2database:h2:$h2Version")
implementation("org.hsqldb:hsqldb:$hsqldbVersion")
implementation("com.thoughtworks.xstream:xstream:$xstreamVersion") {
exclude(module = "xmlpull")
exclude(module = "xpp3_min")
}
implementation("com.thoughtworks.xstream:xstream-hibernate:$xstreamVersion") {
exclude(module = "xmlpull")
exclude(module = "xpp3_min")
}
implementation("io.netty:netty-codec:$nettyVersion")
implementation("org.apache.commons:commons-collections4:$commonsCollectionsVersion")
implementation("org.apache.commons:commons-csv:$commonsCsvVersion")
implementation("org.apache.commons:commons-lang3:$commonsLangVersion")
implementation("org.apache.commons:commons-math3:$commonsMathVersion")
}
tasks.jar {
manifest.attributes["Automatic-Module-Name"] = moduleName
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/AbstractInvestmentTransactionEntry.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.math.BigDecimal;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.ManyToOne;
import jgnash.util.NotNull;
/**
* Investment Transaction Entry.
*
* @author Craig Cavanaugh
*/
@Entity
public abstract class AbstractInvestmentTransactionEntry extends TransactionEntry {
/**
* Security for this entry.
*/
@ManyToOne
private SecurityNode securityNode;
/**
* share price.
*/
@Column(precision = 26, scale = 8)
private BigDecimal price;
/**
* number of shares.
*/
@Column(precision = 26, scale = 8)
private BigDecimal quantity;
/**
* Creates a new instance of InvestmentTransactionEntry.
*/
protected AbstractInvestmentTransactionEntry() {
setTransactionTag(TransactionTag.INVESTMENT);
}
/**
* Calculates the total of the value of the shares, gains, fees, etc. as it
* pertains to an account.
*
* Not intended for use to calculate account balances
*
* @return total resulting total for this entry
* @see InvestmentTransaction#getTotal(jgnash.engine.Account)
*/
public BigDecimal getTotal() {
return getQuantity().multiply(getPrice());
}
public SecurityNode getSecurityNode() {
return securityNode;
}
void setSecurityNode(final SecurityNode securityNode) {
this.securityNode = securityNode;
}
public BigDecimal getPrice() {
return price;
}
void setPrice(final BigDecimal price) {
this.price = price;
}
/**
* Assigns the number of shares for this transaction. The value should be
* always be a positive value.
*
* @param quantity the quantity of securities to assign to this account
* @see #getSignedQuantity()
*/
void setQuantity(final BigDecimal quantity) {
this.quantity = quantity;
}
/**
* Returns the number of shares assigned to this transaction.
*
* @return the quantity of securities for this transaction
* @see #getSignedQuantity()
*/
public BigDecimal getQuantity() {
return quantity;
}
/**
* Returns the number of shares as it would impact the sum of the investment
* accounts shares. Useful for summing share quantities
*
* @return the quantity of securities for this transaction
*/
public abstract BigDecimal getSignedQuantity();
/**
* Returns the type of this transaction entry.
*
* @return the transaction type
*/
@NotNull public abstract TransactionType getTransactionType();
@Override
public String toString() {
return super.toString() + "Security: " + getSecurityNode().getSymbol()
+ System.lineSeparator() + "Quantity: " + getQuantity().toPlainString()
+ System.lineSeparator() + "Price: " + getPrice().toPlainString()
+ System.lineSeparator();
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/Account.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received account copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.ElementCollection;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.JoinTable;
import javax.persistence.ManyToMany;
import javax.persistence.ManyToOne;
import javax.persistence.OneToMany;
import javax.persistence.OneToOne;
import javax.persistence.OrderBy;
import javax.persistence.PostLoad;
import javax.persistence.Transient;
import jgnash.time.DateUtils;
import jgnash.util.NotNull;
import jgnash.util.Nullable;
/**
* Account object. The {@code Account} object is mutable. Changes should be made using the {@code Engine} to
* ensure correct state and persistence.
*
* @author Craig Cavanaugh
* @author Jeff Prickett prickett@users.sourceforge.net
*/
@Entity
public class Account extends StoredObject implements Comparable {
static final int MAX_ATTRIBUTE_LENGTH = 8192;
/**
* Attribute key for the last attempted reconciliation date.
*/
public static final String RECONCILE_LAST_ATTEMPT_DATE = "Reconcile.LastAttemptDate";
/**
* Attribute key for the last successful reconciliation date.
*/
public static final String RECONCILE_LAST_SUCCESS_DATE = "Reconcile.LastSuccessDate";
/**
* Attribute key for the last reconciliation statement date.
*/
public static final String RECONCILE_LAST_STATEMENT_DATE = "Reconcile.LastStatementDate";
/**
* Attribute key for the last reconciliation opening balance.
*/
public static final String RECONCILE_LAST_OPENING_BALANCE = "Reconcile.LastOpeningBalance";
/**
* Attribute key for the last reconciliation closing balance.
*/
public static final String RECONCILE_LAST_CLOSING_BALANCE = "Reconcile.LastClosingBalance";
private static final Pattern numberPattern = Pattern.compile("\\d+");
private static final Logger logger = Logger.getLogger(Account.class.getName());
/**
* String delimiter for reported account structure.
*/
private static String accountSeparator = ":";
@ManyToOne
Account parentAccount;
/**
* List of transactions for this account.
*/
@JoinTable
@OrderBy("date, number, timestamp")
@ManyToMany(cascade = {CascadeType.ALL}, fetch = FetchType.EAGER)
final Set transactions = new HashSet<>();
/**
* List of securities if this is an investment account.
*/
@JoinColumn()
@OrderBy("symbol")
@ManyToMany(cascade = {CascadeType.REFRESH, CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.EAGER)
private final Set securities = new HashSet<>();
@Enumerated(EnumType.STRING)
private AccountType accountType;
private boolean placeHolder = false;
private boolean locked = false;
private boolean visible = true;
private boolean excludedFromBudget = false;
private String name = "";
private String description = "";
@Column(columnDefinition = "VARCHAR(8192)")
private String notes = "";
/**
* CurrencyNode for this account.
*/
@ManyToOne
private CurrencyNode currencyNode;
/**
* Sorted list of child accounts.
*/
@OrderBy("name")
@OneToMany(cascade = {CascadeType.ALL}, fetch = FetchType.EAGER)
private final Set children = new HashSet<>();
/**
* Cached list of sorted transactions that is not persisted. This prevents concurrency issues when using a JPA backend
*/
@Transient
private transient List cachedSortedTransactionList;
/**
* Cached list of sorted accounts this is not persisted. This prevents concurrency issues when using a JPA backend
*/
@Transient
private transient List cachedSortedChildren;
/**
* Balance of the account.
*
* Cached balances cannot be persisted to do nature of JPA
*/
@Transient
private transient BigDecimal accountBalance;
/**
* Reconciled balance of the account.
*
* Cached balances cannot be persisted to do nature of JPA
*/
@Transient
private transient BigDecimal reconciledBalance;
/**
* User definable account number.
*/
private String accountNumber = "";
/**
* User definable bank id. Useful for OFX import
*/
private String bankId;
/**
* User definable account code. This will control sort order
*/
@Column(nullable = false, columnDefinition = "int default 0")
private int accountCode;
@OneToOne(orphanRemoval = true, cascade = {CascadeType.ALL})
private AmortizeObject amortizeObject;
/**
* User definable attributes.
*/
@ElementCollection
@Column(columnDefinition = "varchar(8192)")
private final Map attributes = new HashMap<>(); // maps from attribute name to value
private transient ReadWriteLock transactionLock;
private transient ReadWriteLock childLock;
private transient ReadWriteLock securitiesLock;
private transient ReadWriteLock attributesLock;
private transient AccountProxy proxy;
/**
* No argument public constructor for reflection purposes.
*
* Do not use to create account new instance
*/
public Account() {
transactionLock = new ReentrantReadWriteLock(true);
childLock = new ReentrantReadWriteLock(true);
securitiesLock = new ReentrantReadWriteLock(true);
attributesLock = new ReentrantReadWriteLock(true);
// CopyOnWrite is used as an alternative to defensive copies
cachedSortedChildren = new ArrayList<>();
}
public Account(@NotNull final AccountType type, @NotNull final CurrencyNode node) {
this();
Objects.requireNonNull(type);
Objects.requireNonNull(node);
setAccountType(type);
setCurrencyNode(node);
}
private static String getAccountSeparator() {
return accountSeparator;
}
static void setAccountSeparator(final String separator) {
accountSeparator = separator;
}
ReadWriteLock getTransactionLock() {
return transactionLock;
}
private AccountProxy getProxy() {
if (proxy == null) {
proxy = getAccountType().getProxy(this);
}
return proxy;
}
/**
* Clear cached account balances so they will be recalculated.
*/
void clearCachedBalances() {
accountBalance = null;
reconciledBalance = null;
}
/**
* Adds account transaction in chronological order.
*
* @param tran the {@code Transaction} to be added
* @return true the transaction was added successful false the transaction was already attached
* to this account
*/
boolean addTransaction(final Transaction tran) {
if (placeHolder) {
logger.severe("Tried to add transaction to a place holder account");
return false;
}
transactionLock.writeLock().lock();
try {
boolean result = false;
if (!contains(tran)) {
transactions.add(tran);
/* The cached list may already contain the transaction if it has not been initialized yet */
if (!getCachedSortedTransactionList().contains(tran)) {
getCachedSortedTransactionList().add(tran);
Collections.sort(getCachedSortedTransactionList());
}
clearCachedBalances();
result = true;
} else {
logger.log(Level.SEVERE, "Account: {0}({1}){2}Already have transaction ID: {3}", new Object[]{getName(),
hashCode(), System.lineSeparator(), tran.hashCode()});
}
return result;
} finally {
transactionLock.writeLock().unlock();
}
}
/**
* Removes the specified transaction from this account.
*
* @param tran the {@code Transaction} to be removed
* @return {@code true} the transaction removal was successful {@code false} the transaction could not be found
* within this account
*/
boolean removeTransaction(final Transaction tran) {
transactionLock.writeLock().lock();
try {
boolean result = false;
if (contains(tran)) {
transactions.remove(tran);
getCachedSortedTransactionList().remove(tran);
clearCachedBalances();
result = true;
} else {
Logger.getLogger(Account.class.toString()).log(Level.SEVERE, "Account: {0}({1}){2}Did not contain transaction ID: {3}", new Object[]{getName(), getUuid(), System.lineSeparator(), tran.getUuid()});
}
return result;
} finally {
transactionLock.writeLock().unlock();
}
}
/**
* Determines if the specified transaction is attach to this account.
*
* @param tran the {@code Transaction} to look for
* @return {@code true} the transaction is attached to this account {@code false} the transaction is not attached
* to this account
*/
public boolean contains(final Transaction tran) {
transactionLock.readLock().lock();
try {
return transactions.contains(tran);
} finally {
transactionLock.readLock().unlock();
}
}
/**
* Determine if the supplied account is a child of this account.
*
* @param account to check
* @return true if the supplied account is a child of this account
*/
public boolean contains(final Account account) {
childLock.readLock().lock();
try {
return cachedSortedChildren.contains(account);
} finally {
childLock.readLock().unlock();
}
}
/**
* Returns a sorted list of transactions for this account that is unmodifiable.
*
* @return List of transactions
*/
@NotNull
public List getSortedTransactionList() {
transactionLock.readLock().lock();
try {
return Collections.unmodifiableList(getCachedSortedTransactionList());
} finally {
transactionLock.readLock().unlock();
}
}
/**
* Returns the transaction at the specified index.
*
* @param index the index of the transaction to return.
* @return the transaction at the specified index.
* @throws IndexOutOfBoundsException if the index is out of bounds
*/
@NotNull
public Transaction getTransactionAt(final int index) {
transactionLock.readLock().lock();
try {
return getCachedSortedTransactionList().get(index);
} finally {
transactionLock.readLock().unlock();
}
}
/**
* Returns the number of transactions attached to this account.
*
* @return the number of transactions attached to this account.
*/
public int getTransactionCount() {
transactionLock.readLock().lock();
try {
return transactions.size();
} finally {
transactionLock.readLock().unlock();
}
}
/**
* Searches through the transactions and determines the next largest
* transaction number.
*
* @return The next check number; and empty String if numbers are not found
*/
@NotNull
public String getNextTransactionNumber() {
transactionLock.readLock().lock();
try {
int number = 0;
for (final Transaction tran : transactions) {
if (numberPattern.matcher(tran.getNumber()).matches()) {
try {
number = Math.max(number, Integer.parseInt(tran.getNumber()));
} catch (NumberFormatException e) {
logger.log(Level.INFO, "Number regex failed", e);
}
}
}
if (number == 0) {
return "";
}
return Integer.toString(number + 1);
} finally {
transactionLock.readLock().unlock();
}
}
/**
* Add account child account given it's reference.
*
* @param child The child account to add to this account.
* @return {@code true} if the account was added successfully, {@code false} otherwise.
*/
boolean addChild(final Account child) {
childLock.writeLock().lock();
try {
boolean result = false;
if (!children.contains(child) && child != this) {
if (child.setParent(this)) {
children.add(child);
result = true;
cachedSortedChildren.add(child);
Collections.sort(cachedSortedChildren);
}
}
return result;
} finally {
childLock.writeLock().unlock();
}
}
/**
* Removes account child account. The reference to the parent(this) is left so that the parent can be discovered.
*
* @param child The child account to remove.
* @return {@code true} if the specific account was account child of this account, {@code false} otherwise.
*/
boolean removeChild(final Account child) {
childLock.writeLock().lock();
try {
boolean result = false;
if (children.remove(child)) {
result = true;
cachedSortedChildren.remove(child);
}
return result;
} finally {
childLock.writeLock().unlock();
}
}
/**
* Returns a sorted list of the children. A protective copy is returned to protect against concurrency issues.
*
* @return List of children
*/
public List getChildren() {
childLock.readLock().lock();
try {
return new ArrayList<>(cachedSortedChildren);
} finally {
childLock.readLock().unlock();
}
}
/**
* Returns a sorted list of the children. A protective copy is returned to protect against concurrency issues.
*
* @param comparator {@code Comparator} to use
* @return List of children
*/
public List getChildren(final Comparator super Account> comparator) {
List accountChildren = getChildren();
accountChildren.sort(comparator);
return accountChildren;
}
/**
* Returns the index of the specified {@code Transaction} within this {@code Account}.
*
* @param tran the {@code Transaction} to look for
* @return The index of the {@code Transaction}, -1 if this
* {@code Account} does not contain the {@code Transaction}.
*/
public int indexOf(final Transaction tran) {
transactionLock.readLock().lock();
try {
return getCachedSortedTransactionList().indexOf(tran);
} finally {
transactionLock.readLock().unlock();
}
}
/**
* Returns the number of children this account has.
*
* @return the number of children this account has.
*/
public int getChildCount() {
childLock.readLock().lock();
try {
return cachedSortedChildren.size();
} finally {
childLock.readLock().unlock();
}
}
/**
* Returns the parent account.
*
* @return the parent of this account, null is this account is not account child
*/
public Account getParent() {
childLock.readLock().lock();
try {
return parentAccount;
} finally {
childLock.readLock().unlock();
}
}
/**
* Sets the parent of this {@code Account}.
*
* @param account The new parent {@code Account}
* @return {@code true} is successful
*/
public boolean setParent(final Account account) {
childLock.writeLock().lock();
try {
boolean result = false;
if (account != this) {
parentAccount = account;
result = true;
}
return result;
} finally {
childLock.writeLock().unlock();
}
}
/**
* Determines is this {@code Account} has any child{@code Account}.
*
* @return {@code true} is this {@code Account} has children, {@code false} otherwise.
*/
public boolean isParent() {
childLock.readLock().lock();
try {
return !cachedSortedChildren.isEmpty();
} finally {
childLock.readLock().unlock();
}
}
/**
* The account balance is cached to improve performance and reduce thrashing
* of the GC system. The accountBalance is reset when transactions are added
* and removed and lazily recalculated.
*
* @return the balance of this account
*/
public BigDecimal getBalance() {
transactionLock.readLock().lock();
try {
if (accountBalance != null) {
return accountBalance;
}
return accountBalance = getProxy().getBalance();
} finally {
transactionLock.readLock().unlock();
}
}
/**
* The account balance is cached to improve performance and reduce thrashing
* of the GC system. The accountBalance is rest when transactions are added
* and removed and lazily recalculated.
*
* @param node CurrencyNode to get balance against
* @return the balance of this account
*/
private BigDecimal getBalance(final CurrencyNode node) {
transactionLock.readLock().lock();
try {
return adjustForExchangeRate(getBalance(), node);
} finally {
transactionLock.readLock().unlock();
}
}
/**
* Get the account balance up to the specified index using the natural
* transaction sort order.
*
* @param index the balance of this account at the specified index.
* @return the balance of this account at the specified index.
*/
private BigDecimal getBalanceAt(final int index) {
transactionLock.readLock().lock();
try {
return getProxy().getBalanceAt(index);
} finally {
transactionLock.readLock().unlock();
}
}
/**
* Get the account balance up to the specified transaction using the natural
* transaction sort order.
*
* @param transaction reference transaction for running balance. Must be contained within the account
* @return the balance of this account at the specified transaction
*/
public BigDecimal getBalanceAt(final Transaction transaction) {
transactionLock.readLock().lock();
try {
final int index = indexOf(transaction);
if (index >= 0) {
return getBalanceAt(index);
}
return BigDecimal.ZERO;
} finally {
transactionLock.readLock().unlock();
}
}
/**
* The reconciled balance is cached to improve performance and reduce
* thrashing of the GC system. The reconciledBalance is reset when
* transactions are added and removed and lazily recalculated.
*
* @return the reconciled balance of this account
*/
public BigDecimal getReconciledBalance() {
transactionLock.readLock().lock();
try {
if (reconciledBalance != null) {
return reconciledBalance;
}
return reconciledBalance = getProxy().getReconciledBalance();
} finally {
transactionLock.readLock().unlock();
}
}
private BigDecimal getReconciledBalance(final CurrencyNode node) {
return adjustForExchangeRate(getReconciledBalance(), node);
}
private BigDecimal adjustForExchangeRate(final BigDecimal amount, final CurrencyNode node) {
if (node.equals(getCurrencyNode())) { // child has the same commodity type
return amount;
}
// the account has a different currency, use the last known exchange rate
return amount.multiply(getCurrencyNode().getExchangeRate(node));
}
/**
* Returns the date of the first unreconciled transaction.
*
* @return Date of first unreconciled transaction
*/
public LocalDate getFirstUnreconciledTransactionDate() {
transactionLock.readLock().lock();
try {
LocalDate date = null;
for (final Transaction transaction : getSortedTransactionList()) {
if (transaction.getReconciled(this) != ReconciledState.RECONCILED) {
date = transaction.getLocalDate();
break;
}
}
if (date == null) {
date = getCachedSortedTransactionList().get(getTransactionCount() - 1).getLocalDate();
}
return date;
} finally {
transactionLock.readLock().unlock();
}
}
/**
* Get the default opening balance for reconciling the account.
*
* @return Opening balance for reconciling the account
* @see AccountProxy#getOpeningBalanceForReconcile()
*/
public BigDecimal getOpeningBalanceForReconcile() {
return getProxy().getOpeningBalanceForReconcile();
}
/**
* Returns the balance of the account plus any child accounts.
*
* @return the balance of this account including the balance of any child
* accounts.
*/
public BigDecimal getTreeBalance() {
transactionLock.readLock().lock();
childLock.readLock().lock();
try {
BigDecimal balance = getBalance();
for (final Account child : cachedSortedChildren) {
balance = balance.add(child.getTreeBalance(getCurrencyNode()));
}
return balance;
} finally {
transactionLock.readLock().unlock();
childLock.readLock().unlock();
}
}
/**
* Returns the balance of the account plus any child accounts.
*
* @param endDate The inclusive end date
* @param node The commodity to convert balance to
*
* @return the balance of this account including the balance of any child
* accounts.
*/
public BigDecimal getTreeBalance(final LocalDate endDate, final CurrencyNode node) {
transactionLock.readLock().lock();
childLock.readLock().lock();
try {
BigDecimal balance = getBalance(endDate, node);
for (final Account child : cachedSortedChildren) {
balance = balance.add(child.getTreeBalance(endDate, node));
}
return balance;
} finally {
transactionLock.readLock().unlock();
childLock.readLock().unlock();
}
}
/**
* Returns the balance of the account plus any child accounts. The balance
* is adjusted to the current exchange rate of the supplied commodity if
* needed.
*
* @param node The commodity to convert balance to
* @return the balance of this account including the balance of any child
* accounts.
*/
private BigDecimal getTreeBalance(final CurrencyNode node) {
transactionLock.readLock().lock();
childLock.readLock().lock();
try {
BigDecimal balance = getBalance(node);
for (final Account child : cachedSortedChildren) {
balance = balance.add(child.getTreeBalance(node));
}
return balance;
} finally {
transactionLock.readLock().unlock();
childLock.readLock().unlock();
}
}
/**
* Returns the reconciled balance of the account plus any child accounts.
* The balance is adjusted to the current exchange rate of the supplied
* commodity if needed.
*
* @param node The commodity to convert balance to
* @return the balance of this account including the balance of any child
* accounts.
*/
private BigDecimal getReconciledTreeBalance(final CurrencyNode node) {
transactionLock.readLock().lock();
childLock.readLock().lock();
try {
BigDecimal balance = getReconciledBalance(node);
for (final Account child : cachedSortedChildren) {
balance = balance.add(child.getReconciledTreeBalance(node));
}
return balance;
} finally {
transactionLock.readLock().unlock();
childLock.readLock().unlock();
}
}
/**
* Returns the reconciled balance of the account plus any child accounts.
*
* @return the balance of this account including the balance of any child
* accounts.
*/
public BigDecimal getReconciledTreeBalance() {
transactionLock.readLock().lock();
childLock.readLock().lock();
try {
BigDecimal balance = getReconciledBalance();
for (final Account child : cachedSortedChildren) {
balance = balance.add(child.getReconciledTreeBalance(getCurrencyNode()));
}
return balance;
} finally {
transactionLock.readLock().unlock();
childLock.readLock().unlock();
}
}
/**
* Returns the balance of the transactions inclusive of the start and end
* dates.
*
* @param start The inclusive start date
* @param end The inclusive end date
* @return The ending balance
*/
public BigDecimal getBalance(final LocalDate start, final LocalDate end) {
Objects.requireNonNull(start);
Objects.requireNonNull(end);
transactionLock.readLock().lock();
try {
return getProxy().getBalance(start, end);
} finally {
transactionLock.readLock().unlock();
}
}
/**
* Returns the account balance up to and inclusive of the supplied date. The
* returned balance is converted to the specified commodity.
*
* @param startDate start date
* @param endDate end date
* @param node The commodity to convert balance to
* @return the account balance
*/
public BigDecimal getBalance(final LocalDate startDate, final LocalDate endDate, final CurrencyNode node) {
transactionLock.readLock().lock();
try {
return adjustForExchangeRate(getBalance(startDate, endDate), node);
} finally {
transactionLock.readLock().unlock();
}
}
/**
* Returns the full inclusive ancestry of this
* {@code Account}.
*
* @return {@code List} of accounts
*/
public List getAncestors() {
List list = new ArrayList<>();
list.add(this);
Account parent = getParent();
while (parent != null) {
list.add(parent);
parent = parent.getParent();
}
return list;
}
/**
* Returns the the balance of the account plus any child accounts inclusive
* of the start and end dates.
*
* @param start start date
* @param end end date
* @param node CurrencyNode to use for balance
* @return account balance
*/
public BigDecimal getTreeBalance(final LocalDate start, final LocalDate end, final CurrencyNode node) {
Objects.requireNonNull(start);
Objects.requireNonNull(end);
transactionLock.readLock().lock();
childLock.readLock().lock();
try {
BigDecimal returnValue = getBalance(start, end, node);
for (final Account child : cachedSortedChildren) {
returnValue = returnValue.add(child.getTreeBalance(start, end, node));
}
return returnValue;
} finally {
transactionLock.readLock().unlock();
childLock.readLock().unlock();
}
}
/**
* Returns the account balance up to and inclusive of the supplied localDate.
*
* @param localDate The inclusive ending localDate
* @return The ending balance
*/
public BigDecimal getBalance(final LocalDate localDate) {
transactionLock.readLock().lock();
try {
return getProxy().getBalance(localDate);
} finally {
transactionLock.readLock().unlock();
}
}
/**
* Returns the account balance up to and inclusive of the supplied date. The
* returned balance is converted to the specified commodity.
*
* @param node The commodity to convert balance to
* @param date The inclusive ending date
* @return The ending balance
*/
public BigDecimal getBalance(final LocalDate date, final CurrencyNode node) {
transactionLock.readLock().lock();
try {
return adjustForExchangeRate(getBalance(date), node);
} finally {
transactionLock.readLock().unlock();
}
}
/**
* Returns a {@code List} of {@code Transaction} that occur during the specified period.
* The specified dates are inclusive.
*
* @param startDate starting date
* @param endDate ending date
* @return a {@code List} of transactions that occurred within the specified dates
*/
public List getTransactions(final LocalDate startDate, final LocalDate endDate) {
transactionLock.readLock().lock();
try {
return transactions.parallelStream().filter(transaction
-> DateUtils.after(transaction.getLocalDate(), startDate)
&& DateUtils.before(transaction.getLocalDate(), endDate)).sorted().collect(Collectors.toList());
} finally {
transactionLock.readLock().unlock();
}
}
/**
* Returns the commodity node for this account
*
* Note: method may not be final for Hibernate.
*
* @return the commodity node for this account.
*/
public CurrencyNode getCurrencyNode() {
return currencyNode;
}
/**
* Sets the commodity node for this account.
*
* Note: method may not be final for Hibernate
*
* @param node The new commodity node for this account.
*/
void setCurrencyNode(@NotNull final CurrencyNode node) {
Objects.requireNonNull(node);
if (!node.equals(currencyNode)) {
currencyNode = node;
clearCachedBalances(); // cached balances will need to be recalculated
}
}
public boolean isLocked() {
return locked;
}
public void setLocked(final boolean locked) {
this.locked = locked;
}
public boolean isPlaceHolder() {
return placeHolder;
}
public void setPlaceHolder(final boolean placeHolder) {
this.placeHolder = placeHolder;
}
public String getDescription() {
return description;
}
public void setDescription(final String desc) {
description = desc;
}
public synchronized String getName() {
return name;
}
public synchronized void setName(final String newName) {
if (!newName.equals(name)) {
name = newName;
}
}
public synchronized String getPathName() {
final Account parent = getParent();
if (parent != null && parent.getAccountType() != AccountType.ROOT) {
return parent.getPathName() + getAccountSeparator() + getName();
}
return getName(); // this account is at the root level
}
public AccountType getAccountType() {
return accountType;
}
/**
* Sets the account type
*
* Note: method may not be final for Hibernate
*
* @param type new account type
*/
void setAccountType(final AccountType type) {
Objects.requireNonNull(type);
if (accountType != null && !accountType.isMutable()) {
throw new EngineException("Immutable account type");
}
accountType = type;
proxy = null; // proxy will need to change
}
/**
* Returns the visibility of the account.
*
* @return boolean is this account is visible, false otherwise
*/
public boolean isVisible() {
return visible;
}
/**
* Changes the visibility of the account.
*
* @param visible the new account visibility
*/
public void setVisible(final boolean visible) {
this.visible = visible;
}
/**
* Returns the notes for this account.
*
* @return the notes for this account
*/
public String getNotes() {
return notes;
}
/**
* Sets the notes for this account.
*
* @param notes the notes for this account
*/
public void setNotes(final String notes) {
this.notes = notes;
}
/**
* Compares two Account for ordering. Returned sort order is consistent with JPA order.
* The account name, and then account UUID is used1
*
* @param acc the {@code Account} to be compared.
* @return the value {@code 0} if the argument Account is equal to this Account; account
* value less than {@code 0} if this Account is before the Account argument; and
* account value greater than {@code 0} if this Account is after the Account argument.
*/
@Override
public int compareTo(@NotNull final Account acc) {
// Sort by name
int result = getName().compareToIgnoreCase(acc.getName());
if (result != 0) {
return result;
}
// Sort of uuid after everything else fails.
return getUuid().compareTo(acc.getUuid());
}
@Override
public String toString() {
return name;
}
@Override
public boolean equals(final Object other) {
return this == other || other instanceof Account && getUuid().equals(((Account) other).getUuid());
}
/**
* User definable account code. This can be used to manage sort order
*
* @return the user defined account code
*/
public int getAccountCode() {
return accountCode;
}
public void setAccountCode(final int accountCode) {
this.accountCode = accountCode;
}
/**
* Returns the account number. A non-null value is guaranteed
*
* @return the account number
*/
public String getAccountNumber() {
return accountNumber;
}
public void setAccountNumber(final String account) {
accountNumber = account;
}
/**
* Adds account commodity to the list and ensures duplicates are not added.
* The list is sorted according to numeric code
*
* @param node SecurityNode to add
* @return true if successful
*/
boolean addSecurity(final SecurityNode node) {
boolean result = false;
if (node != null && memberOf(AccountGroup.INVEST) && !containsSecurity(node)) {
securities.add(node);
result = true;
}
return result;
}
/**
* Removes a {@code SecurityNode} from the account. If the {@code SecurityNode} is in use by transactions,
* removal will be prohibited.
*
* @param node {@code SecurityNode} to remove
* @return {@code true} if successful, {@code false} if used by a transaction or not an active {@code SecurityNode}
*/
boolean removeSecurity(final SecurityNode node) {
securitiesLock.writeLock().lock();
try {
boolean result = false;
if (!getUsedSecurities().contains(node) && containsSecurity(node)) {
securities.remove(node);
result = true;
}
return result;
} finally {
securitiesLock.writeLock().unlock();
}
}
public boolean containsSecurity(final SecurityNode node) {
securitiesLock.readLock().lock();
try {
return securities.contains(node);
} finally {
securitiesLock.readLock().unlock();
}
}
/**
* Returns the market value of this account.
*
* @return market value of the account
*/
public BigDecimal getMarketValue() {
return getProxy().getMarketValue();
}
/**
* Returns a defensive copy of the security set.
*
* @return a sorted set
*/
public Set getSecurities() {
securitiesLock.readLock().lock();
try {
return new TreeSet<>(securities);
} finally {
securitiesLock.readLock().unlock();
}
}
/**
* Returns a set of used SecurityNodes.
*
* @return a set of used SecurityNodes
*/
public Set getUsedSecurities() {
transactionLock.readLock().lock();
securitiesLock.readLock().lock();
try {
return transactions.parallelStream().filter(t -> t instanceof InvestmentTransaction).map(t ->
((InvestmentTransaction) t).getSecurityNode()).collect(Collectors.toCollection(TreeSet::new));
} finally {
securitiesLock.readLock().unlock();
transactionLock.readLock().unlock();
}
}
/**
* Returns the cash balance of this account.
*
* @return Cash balance of the account
*/
public BigDecimal getCashBalance() {
Lock l = transactionLock.readLock();
l.lock();
try {
return getProxy().getCashBalance();
} finally {
l.unlock();
}
}
/**
* Returns the depth of the account relative to the {@code RootAccount}.
*
* @return depth relative to the root
*/
public int getDepth() {
int depth = 0;
Account parent = getParent();
while (parent != null) {
depth++;
parent = parent.getParent();
}
return depth;
}
/**
* Shortcut method to check account type.
*
* @param type AccountType to compare against
* @return true if supplied AccountType match
*/
public final boolean instanceOf(final AccountType type) {
return getAccountType() == type;
}
/**
* Shortcut method to check account group membership.
*
* @param group AccountGroup to compare against
* @return true if this account belongs to the supplied group
*/
public final boolean memberOf(final AccountGroup group) {
return getAccountType().getAccountGroup() == group;
}
public String getBankId() {
return bankId;
}
public void setBankId(final String bankId) {
this.bankId = bankId;
}
public boolean isExcludedFromBudget() {
return excludedFromBudget;
}
public void setExcludedFromBudget(boolean excludeFromBudget) {
this.excludedFromBudget = excludeFromBudget;
}
/**
* Amortization object for loan payments.
*
* @return {@code AmortizeObject} if not null
*/
@Nullable
public AmortizeObject getAmortizeObject() {
return amortizeObject;
}
void setAmortizeObject(final AmortizeObject amortizeObject) {
this.amortizeObject = amortizeObject;
}
/**
* Sets an attribute for the {@code Account}.
*
* @param key the attribute key
* @param value the value. If null, the attribute will be removed
*/
void setAttribute(@NotNull final String key, @Nullable final String value) {
attributesLock.writeLock().lock();
try {
if (key.isEmpty()) {
throw new EngineException("Attribute key may not be empty or null");
}
if (value == null) {
attributes.remove(key);
} else {
attributes.put(key, value);
}
} finally {
attributesLock.writeLock().unlock();
}
}
/**
* Returns an {@code Account} attribute.
*
* @param key the attribute key
* @return the attribute if found
* @see Engine#setAccountAttribute
*/
@Nullable
String getAttribute(@NotNull final String key) {
attributesLock.readLock().lock();
try {
if (key.isEmpty()) {
throw new EngineException("Attribute key may not be empty or null");
}
return attributes.get(key);
} finally {
attributesLock.readLock().unlock();
}
}
/**
* Provides access to a cached and sorted list of transactions. Direct access to the list
* is for internal use only.
*
* @return List of sorted transactions
* @see #getSortedTransactionList
*/
private List getCachedSortedTransactionList() {
// Lazy initialization
if (cachedSortedTransactionList == null) {
cachedSortedTransactionList = new ArrayList<>(transactions);
Collections.sort(cachedSortedTransactionList);
}
return cachedSortedTransactionList;
}
/**
* Required by XStream for proper initialization.
*
* @return Properly initialized Account
*/
protected Object readResolve() {
postLoad();
return this;
}
@PostLoad
private void postLoad() {
transactionLock = new ReentrantReadWriteLock(true);
childLock = new ReentrantReadWriteLock(true);
securitiesLock = new ReentrantReadWriteLock(true);
attributesLock = new ReentrantReadWriteLock(true);
cachedSortedChildren = new ArrayList<>(children);
Collections.sort(cachedSortedChildren); // JPA will be naturally sorted, but XML files will not
}
/**
* Accounts should not be cloned.
*
* @return will result in a CloneNotSupportedException
* @throws java.lang.CloneNotSupportedException will always occur
*/
@Override
public Object clone() throws CloneNotSupportedException {
super.clone();
throw new CloneNotSupportedException("Accounts may not be cloned");
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/AccountGroup.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import jgnash.resource.util.ResourceUtils;
/**
* Account Group class. Helps to categorize account types to make reporting easier and consistent.
*
* @author Craig Cavanaugh
*/
public enum AccountGroup {
ASSET(ResourceUtils.getString("AccountType.Asset")),
EQUITY(ResourceUtils.getString("AccountType.Equity")),
EXPENSE(ResourceUtils.getString("AccountType.Expense")),
INCOME(ResourceUtils.getString("AccountType.Income")),
INVEST(ResourceUtils.getString("AccountType.Investment")),
LIABILITY(ResourceUtils.getString("AccountType.Liability")),
ROOT(ResourceUtils.getString("AccountType.Root")),
SIMPLEINVEST(ResourceUtils.getString("AccountType.SimpleInvestment")); // CD's, Treasuries, Etc.
private final transient String description;
AccountGroup(final String description) {
this.description = description;
}
@Override
public String toString() {
return description;
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/AccountProxy.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.locks.Lock;
import jgnash.time.DateUtils;
/**
* Proxy class to locate account balance behaviors. Depending on account type, summation of transaction types are
* handled differently.
*
* @author Craig Cavanaugh
*/
class AccountProxy {
final Account account;
AccountProxy(final Account account) {
this.account = account;
}
/**
* Get the balance of all account transactions.
*
* @return the balance of this account
*/
public BigDecimal getBalance() {
final Lock l = account.getTransactionLock().readLock();
l.lock();
try {
BigDecimal balance = BigDecimal.ZERO;
for (Transaction transaction : account.getSortedTransactionList()) {
balance = balance.add(transaction.getAmount(account));
}
return balance;
} finally {
l.unlock();
}
}
/**
* Get the account balance up to a specified index.
*
* @param index the balance of this account at the specified index.
* @return the balance of this account at the specified index.
*/
public BigDecimal getBalanceAt(final int index) {
final Lock l = account.getTransactionLock().readLock();
l.lock();
try {
BigDecimal balance = BigDecimal.ZERO;
List transactions = account.getSortedTransactionList();
for (int i = 0; i <= index; i++) {
balance = balance.add(transactions.get(i).getAmount(account));
}
return balance;
} finally {
l.unlock();
}
}
/**
* Returns the balance of the transactions inclusive of the start and end dates.
*
* @param start The inclusive start date
* @param end The inclusive end date
* @return The ending balance
*/
public BigDecimal getBalance(final LocalDate start, final LocalDate end) {
final Lock l = account.getTransactionLock().readLock();
l.lock();
try {
BigDecimal balance = BigDecimal.ZERO;
for (final Transaction t : account.getSortedTransactionList()) {
final LocalDate d = t.getLocalDate();
if (DateUtils.after(d, start) && DateUtils.before(d, end)) {
balance = balance.add(t.getAmount(account));
}
}
return balance;
} finally {
l.unlock();
}
}
/**
* Returns the account balance up to and inclusive of the supplied date.
*
* @param date The inclusive ending date
* @return The ending balance
*/
public BigDecimal getBalance(final LocalDate date) {
final Lock l = account.getTransactionLock().readLock();
l.lock();
try {
BigDecimal balance = BigDecimal.ZERO;
if (!account.transactions.isEmpty()) {
balance = getBalance(account.getSortedTransactionList().get(0).getLocalDate(), date);
}
return balance;
} finally {
l.unlock();
}
}
/**
* Returns the cash balance of this account.
*
* @return exception thrown
*/
public BigDecimal getCashBalance() {
throw new UnsupportedOperationException();
}
/**
* Returns the market value of this account.
*
* @return exception thrown
*/
public BigDecimal getMarketValue() {
throw new UnsupportedOperationException();
}
/**
* Calculates the reconciled balance of the account.
*
* @return the reconciled balance of this account
*/
public BigDecimal getReconciledBalance() {
final Lock l = account.getTransactionLock().readLock();
l.lock();
try {
BigDecimal balance = BigDecimal.ZERO;
// Use the cached list to avoid ConcurrentModificationException with JPA
for (final Transaction t : account.getSortedTransactionList()) {
if (t.getReconciled(account) == ReconciledState.RECONCILED) {
balance = balance.add(t.getAmount(account));
}
}
return balance;
} finally {
l.unlock();
}
}
/**
* Get the default opening balance for reconciling the account.
*
* @return Opening balance for reconciling the account
*/
public BigDecimal getOpeningBalanceForReconcile() {
final Lock l = account.getTransactionLock().readLock();
l.lock();
try {
final LocalDate date = account.getFirstUnreconciledTransactionDate();
final List transactions = account.getSortedTransactionList();
BigDecimal balance = BigDecimal.ZERO;
for (int i = 0; i < transactions.size(); i++) {
if (transactions.get(i).getLocalDate().equals(date)) {
if (i > 0) {
balance = getBalanceAt(i - 1);
}
break;
}
}
return balance;
} finally {
l.unlock();
}
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/AccountTreeXMLFactory.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.converters.reflection.PureJavaReflectionProvider;
import com.thoughtworks.xstream.hibernate.converter.HibernatePersistentCollectionConverter;
import com.thoughtworks.xstream.hibernate.converter.HibernatePersistentMapConverter;
import com.thoughtworks.xstream.hibernate.converter.HibernateProxyConverter;
import com.thoughtworks.xstream.hibernate.mapper.HibernateMapper;
import com.thoughtworks.xstream.io.xml.PrettyPrintWriter;
import com.thoughtworks.xstream.io.xml.StaxDriver;
import com.thoughtworks.xstream.mapper.MapperWrapper;
import com.thoughtworks.xstream.security.ArrayTypePermission;
import com.thoughtworks.xstream.security.NoTypePermission;
import com.thoughtworks.xstream.security.PrimitiveTypePermission;
import com.thoughtworks.xstream.security.WildcardTypePermission;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import jgnash.engine.xstream.XStreamJVM9;
import jgnash.resource.util.ClassPathUtils;
import jgnash.resource.util.ResourceUtils;
/**
* Import and export a tree of accounts using XML files.
*
* @author Craig Cavanaugh
*/
public class AccountTreeXMLFactory {
private static final Charset ENCODING = StandardCharsets.UTF_8;
private static final String RESOURCE_ROOT_PATH = "/jgnash/resource/account";
private AccountTreeXMLFactory() {
}
private static XStream getStream() {
final XStreamJVM9 xstream = new XStreamJVM9(new PureJavaReflectionProvider(), new StaxDriver()) {
@Override
protected MapperWrapper wrapMapper(final MapperWrapper next) {
return new HibernateMapper(next);
}
};
// configure XStream security
xstream.addPermission(NoTypePermission.NONE);
xstream.addPermission(PrimitiveTypePermission.PRIMITIVES);
xstream.addPermission(ArrayTypePermission.ARRAYS);
xstream.addPermission(new WildcardTypePermission(new String[] {"java.**", "jgnash.engine.**"}));
xstream.ignoreUnknownElements(); // gracefully ignore fields in the file that do not have object members
xstream.setMode(XStream.ID_REFERENCES);
xstream.alias("date", LocalDate.class); // use date instead of local-date by default
xstream.alias("Account", Account.class);
xstream.alias("RootAccount", RootAccount.class);
xstream.alias("CurrencyNode", CurrencyNode.class);
xstream.alias("SecurityNode", SecurityNode.class);
xstream.useAttributeFor(Account.class, "placeHolder");
xstream.useAttributeFor(Account.class, "locked");
xstream.useAttributeFor(Account.class, "visible");
xstream.useAttributeFor(Account.class, "name");
xstream.useAttributeFor(Account.class, "description");
xstream.useAttributeFor(CommodityNode.class, "symbol");
xstream.useAttributeFor(CommodityNode.class, "scale");
xstream.useAttributeFor(CommodityNode.class, "prefix");
xstream.useAttributeFor(CommodityNode.class, "suffix");
xstream.useAttributeFor(CommodityNode.class, "description");
xstream.omitField(StoredObject.class, "uuid");
xstream.omitField(StoredObject.class, "markedForRemoval");
// Ignore fields required for JPA
xstream.omitField(StoredObject.class, "version");
xstream.omitField(Account.class, "transactions");
xstream.omitField(Account.class, "accountBalance");
xstream.omitField(Account.class, "reconciledBalance");
xstream.omitField(Account.class, "attributes");
xstream.omitField(Account.class, "propertyMap");
xstream.omitField(Account.class, "amortizeObject");
xstream.omitField(SecurityNode.class, "historyNodes");
xstream.omitField(SecurityNode.class, "securityHistoryEvents");
// Filters out the hibernate
xstream.registerConverter(new HibernateProxyConverter());
xstream.registerConverter(new HibernatePersistentCollectionConverter(xstream.getMapper()));
xstream.registerConverter(new HibernatePersistentMapConverter(xstream.getMapper()));
//xstream.registerConverter(new HibernatePersistentSortedMapConverter(xstream.getMapper()));
//xstream.registerConverter(new HibernatePersistentSortedSetConverter(xstream.getMapper()));
return xstream;
}
public static void exportAccountTree(final Engine engine, final Path file) {
RootAccount account = engine.getRootAccount();
XStream xstream = getStream();
try (final Writer writer = Files.newBufferedWriter(file, ENCODING);
final ObjectOutputStream out = xstream.createObjectOutputStream(new PrettyPrintWriter(writer))) {
out.writeObject(account);
} catch (IOException e) {
Logger.getLogger(AccountTreeXMLFactory.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e);
}
}
/**
* Load an account tree given a reader.
*
* @param reader Reader to use
* @return RootAccount if reader is valid
*/
private static RootAccount loadAccountTree(final Reader reader) {
RootAccount account = null;
XStream xstream = getStream();
try (final ObjectInputStream in = xstream.createObjectInputStream(reader)) {
final Object o = in.readObject();
if (o instanceof RootAccount) {
account = (RootAccount) o;
}
} catch (IOException | ClassNotFoundException ex) {
Logger.getLogger(AccountTreeXMLFactory.class.getName()).log(Level.SEVERE, null, ex);
}
return account;
}
/**
* Load an account tree given a reader.
*
* @param file file name to use
* @return RootAccount if file name is valid
*/
public static RootAccount loadAccountTree(final Path file) {
RootAccount account = null;
try (final Reader reader = Files.newBufferedReader(file, ENCODING)) {
account = loadAccountTree(reader);
} catch (IOException ex) {
Logger.getLogger(AccountTreeXMLFactory.class.getName()).log(Level.SEVERE, null, ex);
}
return account;
}
/**
* Load an account tree given an InputStream.
*
* @param stream InputStream to use
* @return RootAccount if stream is valid
*/
private static RootAccount loadAccountTree(final InputStream stream) {
try (Reader reader = new InputStreamReader(stream, ENCODING)) {
return loadAccountTree(reader);
} catch (IOException ex) {
Logger.getLogger(AccountTreeXMLFactory.class.getName()).log(Level.SEVERE, null, ex);
}
return null;
}
/**
* Imports an account tree into the existing account tree. Account
* currencies are forced to the engine's default
*
* @param engine current engine to merge into
* @param root root of account structure to merge
*/
public static void importAccountTree(final Engine engine, final RootAccount root) {
AccountImport accountImport = new AccountImport();
accountImport.importAccountTree(engine, root);
}
/**
* Merges an account tree into the existing account tree. Duplicate
* currencies are prevented
*
* @param engine current engine to merge into
* @param root root of account structure to merge
*/
public static void mergeAccountTree(final Engine engine, final RootAccount root) {
AccountImport accountImport = new AccountImport();
accountImport.mergeAccountTree(engine, root);
}
public static Collection getLocalizedAccountSet() {
final List files = new ArrayList<>();
for (final String string : getAccountSetList()) {
try (final InputStream stream = AccountTreeXMLFactory.class.getResourceAsStream(string)) {
final RootAccount account = AccountTreeXMLFactory.loadAccountTree(stream);
files.add(account);
} catch (final IOException e) {
Logger.getLogger(AccountTreeXMLFactory.class.getName()).log(Level.SEVERE, null, e);
}
}
return files;
}
private static List getAccountSetList() {
final String path = ClassPathUtils.getLocalizedPath(RESOURCE_ROOT_PATH);
final List set = new ArrayList<>();
if (path != null) {
try (final InputStream stream = AccountTreeXMLFactory.class.getResourceAsStream(path + "/set.txt");
final BufferedReader r = new BufferedReader(new InputStreamReader(stream, ENCODING))) {
String line = r.readLine();
while (line != null) {
set.add(path + "/" + line);
line = r.readLine();
}
} catch (final IOException ex) {
Logger.getLogger(AccountTreeXMLFactory.class.getName()).log(Level.SEVERE, null, ex);
}
}
return set;
}
private static class AccountImport {
// merge map for accounts
private final Map mergeMap = new HashMap<>();
private void importAccountTree(final Engine engine, final RootAccount root) {
forceCurrency(engine, root);
for (Account child : root.getChildren()) {
importChildren(engine, child);
}
}
private void mergeAccountTree(final Engine engine, final RootAccount root) {
fixCurrencies(engine, root);
for (final Account child : root.getChildren()) {
importChildren(engine, child);
}
}
/**
* Ensures that duplicate currencies are not created when the accounts are merged.
*
* @param engine Engine with existing currencies
* @param account account to correct
*/
private static void fixCurrencies(final Engine engine, final Account account) {
// If an existing currency matches, assign it to the account
engine.getCurrencies().stream().filter(currencyNode -> account.getCurrencyNode()
.matches(currencyNode)).forEach(account::setCurrencyNode);
// Need to persist the currency before the account if it does not exist within the database
if (!engine.getCurrencies().contains(account.getCurrencyNode())) {
engine.addCurrency(account.getCurrencyNode());
}
// match SecurityNodes to prevent duplicates
if (account.memberOf(AccountGroup.INVEST)) {
final Set nodes = account.getSecurities();
for (final SecurityNode node : nodes) {
SecurityNode sNode = engine.getSecurity(node.getSymbol());
if (sNode == null) { // no match found
try {
sNode = (SecurityNode) node.clone();
for (final CurrencyNode currencyNode : engine.getCurrencies()) {
if (sNode.getReportedCurrencyNode().matches(currencyNode)) {
sNode.setReportedCurrencyNode(currencyNode);
}
}
if (!engine.addSecurity(sNode)) {
final ResourceBundle rb = ResourceUtils.getBundle();
Logger.getLogger(AccountImport.class.getName()).log(Level.SEVERE,
rb.getString("Message.Error.SecurityAdd"), sNode.getSymbol());
}
} catch (final CloneNotSupportedException e) {
Logger.getLogger(AccountImport.class.getName()).log(Level.SEVERE,
e.getLocalizedMessage(), e);
}
}
account.removeSecurity(node);
account.addSecurity(sNode);
}
}
for (Account child : account.getChildren()) {
fixCurrencies(engine, child);
}
}
/**
* Ensures that duplicate currencies are not created when the accounts are merged.
*
* @param engine Engine with existing currencies
* @param account account to correct
*/
private static void forceCurrency(final Engine engine, final Account account) {
account.setCurrencyNode(engine.getDefaultCurrency());
// match SecurityNodes to prevent duplicates
if (account.memberOf(AccountGroup.INVEST)) {
final Set nodes = account.getSecurities();
for (final SecurityNode node : nodes) {
SecurityNode sNode = engine.getSecurity(node.getSymbol());
if (sNode == null) { // no match found
try {
sNode = (SecurityNode) node.clone();
sNode.setReportedCurrencyNode(engine.getDefaultCurrency());
if (!engine.addSecurity(sNode)) {
final ResourceBundle rb = ResourceUtils.getBundle();
Logger.getLogger(AccountImport.class.getName()).log(Level.SEVERE,
rb.getString("Message.Error.SecurityAdd"), sNode.getSymbol());
}
} catch (final CloneNotSupportedException e) {
Logger.getLogger(AccountImport.class.getName()).log(Level.SEVERE, e.toString(), e);
}
}
account.removeSecurity(node);
account.addSecurity(sNode);
}
}
for (Account child : account.getChildren()) {
forceCurrency(engine, child);
}
}
private void importChildren(final Engine engine, final Account account) {
// fix the exchange rate DAO if needed
engine.attachCurrencyNode(account.getCurrencyNode());
// match RootAccount special case
if (account.getParent() instanceof RootAccount) {
mergeMap.put(account.getParent(), engine.getRootAccount());
}
// search for a pre-existing match
Account match = AccountUtils.searchTree(engine.getRootAccount(), account.getName(),
account.getAccountType(), account.getDepth());
if (match != null && match.getParent().equals(mergeMap.get(account.getParent()))) { // found a match
mergeMap.put(account, match);
} else { // the account is unique
// place in the merge map
mergeMap.put(account, account);
Account parent = mergeMap.get(account.getParent());
engine.addAccount(parent, account);
}
for (final Account child : account.getChildren()) {
importChildren(engine, child);
}
}
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/AccountType.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.EnumSet;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import jgnash.resource.util.ResourceUtils;
/**
* Account type enumeration.
*
* @author Craig Cavanaugh
*/
public enum AccountType {
ASSET(ResourceUtils.getString("AccountType.Asset"), AccountGroup.ASSET, AccountProxy.class, true),
BANK(ResourceUtils.getString("AccountType.Bank"), AccountGroup.ASSET, AccountProxy.class, true),
CASH(ResourceUtils.getString("AccountType.Cash"), AccountGroup.ASSET, AccountProxy.class, true),
CHECKING(ResourceUtils.getString("AccountType.Checking"), AccountGroup.ASSET, AccountProxy.class, true),
CREDIT(ResourceUtils.getString("AccountType.Credit"), AccountGroup.LIABILITY, AccountProxy.class, true),
EQUITY(ResourceUtils.getString("AccountType.Equity"), AccountGroup.EQUITY, AccountProxy.class, true),
EXPENSE(ResourceUtils.getString("AccountType.Expense"), AccountGroup.EXPENSE, AccountProxy.class, true),
INCOME(ResourceUtils.getString("AccountType.Income"), AccountGroup.INCOME, AccountProxy.class, true),
INVEST(ResourceUtils.getString("AccountType.Investment"), AccountGroup.INVEST, InvestmentAccountProxy.class, false),
SIMPLEINVEST(ResourceUtils.getString("AccountType.SimpleInvestment"), AccountGroup.SIMPLEINVEST, AccountProxy.class, true),
LIABILITY(ResourceUtils.getString("AccountType.Liability"), AccountGroup.LIABILITY, AccountProxy.class, true),
MONEYMKRT(ResourceUtils.getString("AccountType.MoneyMarket"), AccountGroup.ASSET, AccountProxy.class, true),
MUTUAL(ResourceUtils.getString("AccountType.Mutual"), AccountGroup.INVEST, InvestmentAccountProxy.class, false),
ROOT(ResourceUtils.getString("AccountType.Root"), AccountGroup.ROOT, AccountProxy.class, true);
private final transient String description;
private final transient AccountGroup accountGroup;
private final transient Class extends AccountProxy> accountProxy;
private final transient boolean mutable;
AccountType(final String description, final AccountGroup accountGroup, final Class extends AccountProxy> accountProxy, final boolean mutable) {
this.description = description;
this.accountGroup = accountGroup;
this.accountProxy = accountProxy;
this.mutable = mutable;
}
/**
* Returns all AccountTypes that fit the supplied AccountGroup.
*
* @param group AccountGroup to match
* @return array of AccountTypes that fit the supplied group
*/
public static Set getAccountTypes(final AccountGroup group) {
final Set list = getAccountTypeSet();
list.removeIf(accountType -> accountType.getAccountGroup() != group);
return list;
}
@Override
public String toString() {
return description;
}
public AccountGroup getAccountGroup() {
return accountGroup;
}
public boolean isMutable() {
return mutable;
}
private static Set getAccountTypeSet() {
Set set = EnumSet.allOf(AccountType.class);
set.remove(AccountType.ROOT);
return set;
}
AccountProxy getProxy(final Account account) {
try {
Class>[] constParams = new Class>[] { Account.class };
Constructor> accConst = accountProxy.getDeclaredConstructor(constParams);
Object[] params = new Object[] { account };
return (AccountProxy) accConst.newInstance(params);
} catch (final InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException ex) {
Logger.getLogger(AccountType.class.getName()).log(Level.SEVERE, null, ex);
}
return null; // unable to create object
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/AccountUtils.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received account copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
/**
* Static account utilities.
*
* @author Craig Cavanaugh
*/
class AccountUtils {
/**
* Searches an account tree given the supplied parameters.
*
* @param root Base account
* @param name Account name
* @param type Account type
* @param depth Account depth
* @return matched account if it exists
*/
static Account searchTree(final Account root, final String name, final AccountType type, final int depth) {
Account match = null;
for (final Account a : root.getChildren()) {
if (a.getName().equals(name) && a.getAccountType() == type && a.getDepth() == depth) {
match = a;
} else if (a.getChildCount() > 0) {
match = searchTree(a, name, type, depth);
}
if (match != null) {
break;
}
}
return match;
}
private AccountUtils() {
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/AmortizeObject.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Objects;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.ManyToOne;
import javax.persistence.SequenceGenerator;
import jgnash.resource.util.ResourceUtils;
import jgnash.util.NotNull;
import jgnash.util.Nullable;
/**
* This class is used to calculate loan payments.
*
* Because BigDecimal is lacking methods of exponents, calculations are
* performed using StrictMath to maintain portability. Results are returned as
* doubles. Results will need to be scaled and rounded.
*
* @author Craig Cavanaugh
*/
@Entity
@SequenceGenerator(name = "sequence", allocationSize = 10)
public class AmortizeObject implements Serializable {
@SuppressWarnings("unused")
@Id
@GeneratedValue(generator = "sequence", strategy = GenerationType.SEQUENCE)
private long id;
@ManyToOne
private Account interestAccount; // account for interest payment
@ManyToOne
private Account bankAccount; // account for principal account
// (normally the liability account)
@ManyToOne
private Account feesAccount; // account to place non interest fees
/**
* Controls the type of transaction automatically generated
*/
@SuppressWarnings("unused")
private Integer transactionType;
/**
* the number of payments per year.
*/
private int numPayments;
/**
* length of loan in months.
*/
private int length;
/**
* the number of compounding periods per year.
*/
private int numCompPeriods;
/**
* annual interest rate, APR (ex 6.75).
*/
private BigDecimal interestRate;
/**
* original balance of the loan.
*/
private BigDecimal originalBalance;
/**
* PMI, escrow, etc.
*/
private BigDecimal fees = BigDecimal.ZERO;
/**
* the payee to use.
*/
private String payee;
/**
* the memo to use.
*/
private String memo;
// private String checkNumber; // check number to use
/**
* origination date.
*/
private LocalDate date = LocalDate.now();
/**
* calculate interest based on daily periodic rate.
*/
private boolean useDailyRate;
/**
* the number of days per year for daily periodic rate.
*/
private BigDecimal daysPerYear;
/**
* Empty constructor to keep reflection happy.
*/
public AmortizeObject() {
}
public void setDate(final LocalDate localDate) {
date = localDate;
}
public LocalDate getDate() {
return date;
}
public void setPaymentPeriods(final int periods) {
numPayments = periods;
}
public int getPaymentPeriods() {
return numPayments;
}
/**
* Sets the length of the loan (in months).
*
* @param months length of loan
*/
public void setLength(final int months) {
this.length = months;
}
/**
* Gets the length of the loan in months.
*
* @return length of loan in months
*/
public int getLength() {
return length;
}
/**
* Determines if interest will be calculate based on a daily periodic rate,
* or if it is assumed that the interest is paid exactly on the due date.
*
* @param daily true if interest should be calculated using a daily rate
*/
public void setUseDailyRate(final boolean daily) {
useDailyRate = daily;
}
/**
* Returns how interest will be calculated.
*
* @return true if interest is calculated using the daily periodic rate
*/
public boolean getUseDailyRate() {
return useDailyRate;
}
/**
* Sets the number of days per year used to calculate the daily periodic
* interest rate. The value can be a decimal.
*
* @param days The number of days in a year
*/
public void setDaysPerYear(final BigDecimal days) {
daysPerYear = days;
}
/**
* Returns the number of days per year used to calculate a daily periodic
* interest rate.
*
* @return The number of days per year
*/
public BigDecimal getDaysPerYear() {
return daysPerYear;
}
public void setRate(final BigDecimal rate) {
interestRate = rate;
}
public BigDecimal getRate() {
return interestRate;
}
public void setPrincipal(final BigDecimal principal) {
originalBalance = principal;
}
public BigDecimal getPrincipal() {
return originalBalance;
}
public void setInterestPeriods(final int periods) {
numCompPeriods = periods;
}
public int getInterestPeriods() {
return numCompPeriods;
}
public void setFees(final BigDecimal fees) {
this.fees = Objects.requireNonNullElse(fees, BigDecimal.ZERO);
}
public BigDecimal getFees() {
return fees;
}
/**
* Set the id of the interest account.
*
* @param id the id of the interest account
*/
public void setInterestAccount(final Account id) {
interestAccount = id;
}
/**
* Returns the id of the interest account.
*
* @return the id of the interest account
*/
public Account getInterestAccount() {
return interestAccount;
}
/**
* Set the id of the principal account.
*
* @param id the id of the principal account
*/
public void setBankAccount(final Account id) {
bankAccount = id;
}
/**
* Returns the id of the principal account.
*
* @return the id of the principal account
*/
public Account getBankAccount() {
return bankAccount;
}
/**
* Set the id of the fees account.
*
* @param id the id of the fees account
*/
public void setFeesAccount(final Account id) {
feesAccount = id;
}
/**
* Returns the id of the fees account.
*
* @return the id of the fees account
*/
public Account getFeesAccount() {
return feesAccount;
}
public void setPayee(final String payee) {
this.payee = payee;
}
public String getPayee() {
return payee;
}
public void setMemo(String memo) {
this.memo = memo;
}
public String getMemo() {
return memo;
}
/**
* Calculates the effective interest rate.
* Ie = (1 + i/m)^(m/n) - 1
* n = payments per period m = number of times compounded per period
*
* @return effective interest rate
*/
private double getEffectiveInterestRate() {
if (interestRate != null && numPayments > 0 && numCompPeriods > 0) {
double i = interestRate.doubleValue() / 100.0;
return StrictMath.pow(1.0 + i / numCompPeriods, (double) numCompPeriods / (double) numPayments) - 1.0;
}
return 0.0;
}
/**
* Calculates the daily interest rate.
* This works for US, can't find any information on Canada
*
* @return periodic interest rate
*/
private double getDailyPeriodicInterestRate() {
if (interestRate != null && numPayments > 0 && numCompPeriods > 0 && daysPerYear != null) {
double rate = getEffectiveInterestRate();
rate = rate * numPayments;
return rate / daysPerYear.doubleValue();
}
return 0.0;
}
// /**
// * Calculates the sum of compounded interest and principal
// * S = P(1+Ie)^n*term
// *
// * @return sum with interest
// */
// double getSumWithInterest() {
// return getPIPayment() * length;
// }
// public double getTotalInterestPaid() {
// return getSumWithInterest() - originalBalance.doubleValue();
// }
/**
* Calculates the principal and interest payment of an equal payment series
* M = P * ( Ie / (1 - (1 + Ie) ^ -N)) N = total number of periods the loan
* is amortized over.
*
* @return P and I
*/
private double getPIPayment() {
// zero interest loan
if ((interestRate == null || interestRate.compareTo(BigDecimal.ZERO) == 0) && length > 0 && numPayments > 0
&& originalBalance != null) {
return originalBalance.doubleValue() / ((length / 12.0) * numPayments);
}
if (length > 0 && numPayments > 0 && numCompPeriods > 0 && originalBalance != null) {
double i = getEffectiveInterestRate();
double p = originalBalance.doubleValue();
return p * (i / (1.0 - StrictMath.pow(1.0 + i, length * -1.0)));
}
return 0.0;
}
/**
* Calculates the principal and interest plus finance charges.
*
* @return the payment
*/
private double getPayment() {
return getPIPayment() + fees.doubleValue();
}
/**
* Calculates the interest portion of the next loan payment given the
* remaining loan balance.
*
* @param balance remaining balance
* @return interest
*/
private double getIPayment(final BigDecimal balance) {
if (balance != null) {
double i = getEffectiveInterestRate();
return i * balance.doubleValue();
}
return 0.0;
}
/**
* Calculates the interest portion of the next loan payment given the
* remaining loan balance and the dates between payments.
*
* @param balance balance
* @param start start date
* @param end end date
* @return interest
*/
private double getIPayment(final BigDecimal balance, final LocalDate start, final LocalDate end) {
if (balance != null) {
int dayEnd = end.getDayOfYear();
int dayStart = start.getDayOfYear();
int days = Math.abs(dayEnd - dayStart);
double i = getDailyPeriodicInterestRate();
return i * days * balance.doubleValue();
}
return 0.0;
}
// /**
// * Calculates the principal portion of the next loan payment given the
// * remaining loan balance
// *
// * @param balance balance
// * @return principal
// */
// public double getPPayment(BigDecimal balance) {
// return getPIPayment() - getIPayment(balance);
// }
@Nullable
public Transaction generateTransaction(@NotNull final Account account, @NotNull final LocalDate date, final String number) {
BigDecimal balance = account.getBalance().abs();
double payment = getPayment();
double interest;
if (getUseDailyRate()) {
LocalDate last;
if (account.getTransactionCount() > 0) {
last = account.getTransactionAt(account.getTransactionCount() - 1).getLocalDate();
} else {
last = date;
}
interest = getIPayment(balance, last, date); // get the interest portion
} else {
interest = getIPayment(balance); // get the interest portion
}
// get debit account
final Account bank = getBankAccount();
if (bank != null) {
CommodityNode n = bank.getCurrencyNode();
Transaction transaction = new Transaction();
transaction.setDate(date);
transaction.setNumber(number);
transaction.setPayee(getPayee());
// transaction is made relative to the debit/checking account
TransactionEntry entry = new TransactionEntry();
// this entry is the principal payment
entry.setCreditAccount(account);
entry.setDebitAccount(bank);
entry.setAmount(n.round(payment - interest));
entry.setMemo(getMemo());
transaction.addTransactionEntry(entry);
// handle interest portion of the payment
Account i = getInterestAccount();
if (i != null && interest != 0.0) {
entry = new TransactionEntry();
entry.setCreditAccount(i);
entry.setDebitAccount(bank);
entry.setAmount(n.round(interest));
entry.setMemo(ResourceUtils.getString("Word.Interest"));
transaction.addTransactionEntry(entry);
}
// a fee has been assigned
if (getFees().compareTo(BigDecimal.ZERO) != 0) {
Account f = getFeesAccount();
if (f != null) {
entry = new TransactionEntry();
entry.setCreditAccount(f);
entry.setDebitAccount(bank);
entry.setAmount(getFees());
entry.setMemo(ResourceUtils.getString("Word.Fees"));
transaction.addTransactionEntry(entry);
}
}
return transaction;
}
return null;
}
//Creates a payment transaction relative to the liability account
/*
private void paymentActionLiability() {
AmortizeObject ao = ((LiabilityAccount)account).getAmortizeObject();
Transaction tran = null;
if (ao != null) {
DateChkNumberDialog d = new DateChkNumberDialog(null, engine.getAccount(ao.getInterestAccount()));
d.show();
if (!d.getResult()) {
return;
}
BigDecimal balance = account.getBalance().abs();
BigDecimal fees = ao.getFees();
double payment = ao.getPayment();
double interest;
if (ao.getUseDailyRate()) {
Date today = d.getDate();
Date last = account.getTransactionAt(account.getTransactionCount() - 1).getDate();
interest = ao.getIPayment(balance, last, today); // get the interest portion
} else {
interest = ao.getIPayment(balance); // get the interest portion
}
Account b = engine.getAccount(ao.getBankAccount());
if (b != null) {
CommodityNode n = b.getCommodityNode();
SplitEntryTransaction e;
SplitTransaction t = new SplitTransaction(b.getCommodityNode());
t.setAccount(b);
t.setMemo(ao.getMemo());
t.setPayee(ao.getPayee());
t.setNumber(d.getNumber());
t.setDate(d.getDate());
// this entry is the complete payment
e = new SplitEntryTransaction(n);
e.setCreditAccount(account);
e.setDebitAccount(b);
e.setAmount(n.round(payment));
e.setMemo(ao.getMemo());
t.addSplit(e);
try { // maintain transaction order (stretch time)
Thread.sleep(2);
} catch (Exception ie) {}
// handle interest portion of the payment
Account i = engine.getAccount(ao.getInterestAccount());
if (i != null) {
e = new SplitEntryTransaction(n);
e.setCreditAccount(i);
e.setDebitAccount(account);
e.setAmount(n.round(interest));
e.setMemo(rb.getString("Word.Interest"));
t.addSplit(e);
}
try { // maintain transaction order (stretch time)
Thread.sleep(2);
} catch (Exception ie) {}
// a fee has been assigned
if (ao.getFees().compareTo(new BigDecimal("0")) != 0) {
Account f = engine.getAccount(ao.getFeesAccount());
if (f != null) {
e = new SplitEntryTransaction(n);
e.setCreditAccount(f);
e.setDebitAccount(account);
e.setAmount(ao.getFees());
e.setMemo(rb.getString("Word.Fees"));
t.addSplit(e);
}
}
// the total should be the debit to the checking account
tran = t;
}
}
if (tran != null) {// display the transaction in the register
newTransaction(tran);
} else { // could not generate the transaction
if (ao == null) {
Logger.getLogger("jgnashEngine").warning("Please configure amortization");
} else {
Logger.getLogger("jgnashEngine").warning("Not enough information");
}
}
}*/
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/AttachmentUtils.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import jgnash.util.FileUtils;
import jgnash.util.NotNull;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Support methods for handling attachments.
*
* @author Craig Cavanaugh
*/
public class AttachmentUtils {
private static final String ATTACHMENT_BASE = "attachments";
/**
* Utility class.
*/
private AttachmentUtils() {
}
/**
* Creates the attachment directory for the active database.
*
* @param baseFile base directory for file attachments
* @return {@code true} if and only if the directory was created or if
* it already exists; {@code false} otherwise
*/
public static boolean createAttachmentDirectory(final Path baseFile) {
boolean result = false;
final Path attachmentPath = getAttachmentDirectory(baseFile);
if (attachmentPath != null && Files.notExists(attachmentPath)) {
try {
Files.createDirectories(attachmentPath);
result = true;
} catch (IOException e) {
Logger.getLogger(AttachmentUtils.class.getName()).log(Level.SEVERE, e.getLocalizedMessage(), e);
}
} else {
result = true;
}
return result;
}
/**
* Returns the default attachment directory for the given base file.
*
* @param baseFile base file for attachment directory
* @return directory for all attachments
*/
public static Path getAttachmentDirectory(@NotNull final Path baseFile) {
Objects.requireNonNull(baseFile);
if (baseFile.getParent() != null) {
return Paths.get(baseFile.getParent() + FileUtils.SEPARATOR + ATTACHMENT_BASE);
}
return null;
}
public static Path getAttachmentPath() {
return getAttachmentDirectory(Paths.get(EngineFactory.getActiveDatabase()));
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/CashFlow.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import static java.lang.Math.abs;
import static java.time.temporal.ChronoUnit.DAYS;
/**
* Stores a history of cash flow items and calculates their internal rate of
* return. It assumes 365 days per year (Actual/365 Fixed day count convention)
* and uses a simple iterative solver.
*
* @author t-pa
* @author Craig Cavanaugh
*/
public class CashFlow {
private static final double DAYS_PER_YEAR = 365;
private static final int MAX_ITERATIONS = 1000;
private static final double CONVERGENCE = 1.e-5;
private static final Logger logger = Logger.getLogger(CashFlow.class.getName());
private static class CashFlowItem {
final LocalDate date;
final BigDecimal amount;
CashFlowItem(final LocalDate date, final BigDecimal amount) {
this.date = date;
this.amount = amount;
}
@Override
public String toString() {
return String.format("[%s, %f]", date.toString(), amount);
}
}
private final List cashFlows = new ArrayList<>();
/**
* Add an item to the history of cash flows.
*
* @param date the date of the cash flow
* @param amount the amount; negative for an investment, positive for a payout
*/
public void add(final LocalDate date, final BigDecimal amount) {
cashFlows.add(new CashFlowItem(date, amount));
}
/**
* Calculate the internal rate of return of the cash flow. If the iterative
* solution does not converge, NaN is returned.
*
* @return an approximation of the (annualized) internal rate of return
*/
public double internalRateOfReturn() {
if (cashFlows.isEmpty()) {
return 0.0;
}
// the reference date is arbitrary, but for better numerical accuracy,
// use one of the actual dates in the cash flow history
LocalDate referenceDate = cashFlows.get(0).date;
double lastRate = 0.0;
double lastNPV = netPresentValue(referenceDate, lastRate);
double rate = (lastNPV > 0) ? 0.05 : -0.05;
// iteratively calculate the IRR with the secant method
int i = 0;
boolean hasConverged = false;
do {
double npv = netPresentValue(referenceDate, rate);
double newRate = rate - npv * (rate - lastRate) / (npv - lastNPV);
lastRate = rate;
lastNPV = npv;
rate = newRate;
i++;
if (Double.isNaN(rate)) { // check for failure to converge
break;
} else if (rate != 0 || lastRate != 0) {
hasConverged = abs(rate - lastRate) / (abs(rate) + abs(lastRate)) < CONVERGENCE;
} else {
hasConverged = true;
}
} while (!hasConverged && i < MAX_ITERATIONS);
if (!hasConverged) {
rate = Double.NaN;
logger.log(Level.INFO, "IRR calculation did not converge. Data: {0}", cashFlows);
}
return rate;
}
/**
* Calculate the net present value of the cash flow.
*
* @param referenceDate the NPV is relative to this date
* @param rate the discount rate
* @return the net present value
*/
private double netPresentValue(final LocalDate referenceDate, final double rate) {
double npv = 0;
for (final CashFlowItem item : cashFlows) {
double timeDifference = referenceDate.until(item.date, DAYS) / DAYS_PER_YEAR;
npv += item.amount.doubleValue() / StrictMath.pow(1 + rate, timeDifference);
}
return npv;
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/CommodityNode.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import javax.persistence.Entity;
import java.math.BigDecimal;
import jgnash.util.NotNull;
/**
* Abstract class for representing a commodity.
*
* @author Craig Cavanaugh
*/
@Entity
public abstract class CommodityNode extends StoredObject implements Comparable {
private String symbol;
private byte scale = 2;
private String prefix = "";
private String suffix = "";
private String description;
public void setScale(final byte scale) {
this.scale = scale;
}
public byte getScale() {
return scale;
}
public void setSymbol(final String symbol) {
this.symbol = symbol;
}
public String getSymbol() {
return symbol;
}
public void setDescription(final String description) {
this.description = description;
}
public String getDescription() {
return description;
}
public void setPrefix(final String prefix) {
this.prefix = prefix;
}
public String getPrefix() {
return prefix;
}
public void setSuffix(final String suffix) {
this.suffix = suffix;
}
public String getSuffix() {
return suffix;
}
@Override
public String toString() {
if (description != null) {
return symbol + " (" + description + ')';
}
return symbol;
}
@Override
public int compareTo(@NotNull final CommodityNode node) {
return symbol.compareTo(node.symbol);
}
@Override
public boolean equals(final Object other) {
return this == other || other instanceof CommodityNode && getUuid().equals(((CommodityNode) other).getUuid());
}
/**
* Determines if given node matches this node.
*
* The UUID is not used for comparison if equals fails.
*
* @param other CurrencyNode to compare against
* @return true if objects match
*/
public boolean matches(final CommodityNode other) {
boolean result = equals(other);
if (!result) {
result = getSymbol().equals(other.getSymbol());
}
return result;
}
/**
* Rounds a supplied double to the correct scale and returns a BigDecimal.
*
* @param value double to round
* @return properly scaled BigDecimal
*/
public BigDecimal round(final double value) {
return new BigDecimal(value).setScale(scale, MathConstants.roundingMode);
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/Comparators.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.*;
/**
* Utility class consisting of {@code Comparators} useful for sorting lists of {@code StoredObject}
* with the same and mixed inheritance.
*
* @author Craig Cavanaugh
*/
public class Comparators {
public static Comparator getAccountByCode() {
return new AccountByCode();
}
public static Comparator getAccountByName() {
return new AccountByName();
}
public static Comparator getAccountByPathName() {
return new AccountByPathName();
}
public static Comparator getAccountByBalance(LocalDate startDate, LocalDate endDate, CurrencyNode currency, boolean ascending) {
return new AccountByBalance(startDate, endDate, currency, ascending);
}
/**
* Sort {@code Account}s according to their position in the account tree. Parent accounts are
* sorted before their children.
*
* @param subComparator defines the sort order of accounts which have the same parent account
* @return the {@code Comparator}
*/
public static Comparator getAccountByTreePosition(Comparator subComparator) {
return new AccountByTreePosition(subComparator);
}
private static class AccountByCode implements Comparator, Serializable {
@Override
public int compare(final Account a1, final Account a2) {
// Sort by account code first
int result = Integer.compare(a1.getAccountCode(), a2.getAccountCode());
if (result != 0) {
return result;
}
return a1.getName().compareTo(a2.getName());
}
}
private static class AccountByName implements Comparator, Serializable {
@Override
public int compare(final Account a1, final Account a2) {
return a1.getName().compareTo(a2.getName());
}
}
private static class AccountByPathName implements Comparator, Serializable {
@Override
public int compare(Account a1, Account a2) {
return a1.getPathName().compareTo(a2.getPathName());
}
}
private static class AccountByBalance implements Comparator, Serializable {
private final LocalDate startDate;
private final LocalDate endDate;
private final boolean ascending;
private final CurrencyNode currency;
AccountByBalance(final LocalDate startDate, final LocalDate endDate, CurrencyNode currency, boolean ascending) {
this.startDate = startDate;
this.endDate = endDate;
this.currency = currency;
this.ascending = ascending;
}
@Override
public int compare(Account a1, Account a2) {
int result = a1.getBalance(startDate, endDate, currency)
.compareTo(a2.getBalance(startDate, endDate, currency));
if (!ascending) {
result *= -1;
}
return result;
}
}
private static class AccountByTreePosition implements Comparator, Serializable {
private final Comparator subComparator;
AccountByTreePosition(final Comparator subComparator) {
this.subComparator = subComparator;
}
private static Deque accountPath(Account acc) {
final Deque path = new LinkedList<>();
while (acc != null) {
path.addFirst(acc);
acc = acc.getParent();
}
return path;
}
@Override
public int compare(final Account a1, final Account a2) {
final Deque path1 = accountPath(a1);
final Deque path2 = accountPath(a2);
// find the first non-common ancestors
Account pa1, pa2;
do {
pa1 = path1.pollFirst();
pa2 = path2.pollFirst();
} while (pa1 != null && pa1.equals(pa2));
if (pa1 == null && pa2 == null) {
// this can only happen if a1 equals a2
return 0;
}
// if one of the paths ended, this is an ancestor of the other one, so sort it first;
// otherwise, let the subComparator decide on the same-level ancestors
return (pa1 == null) ? -1 : (pa2 == null) ? 1 : subComparator.compare(pa1, pa2);
}
}
/**
* Explicit order Comparator.
*
* @param object type that is being sorted
*/
public static class ExplicitComparator implements Comparator, Serializable {
final List order = new ArrayList<>();
@SafeVarargs
public ExplicitComparator(final T... objects) {
Collections.addAll(order, objects);
}
@Override
public int compare(final T t1, final T t2) {
return Integer.compare(order.indexOf(t1), order.indexOf(t2));
}
}
private Comparators() {
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/Config.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.ElementCollection;
import javax.persistence.Entity;
import javax.persistence.ManyToOne;
import javax.persistence.PostLoad;
import jgnash.resource.util.ResourceUtils;
import jgnash.util.NotNull;
import jgnash.util.Nullable;
/**
* A general configuration class so that global configuration information may be stored inside the database.
*
* @author Craig Cavanaugh
*/
@Entity
public class Config extends StoredObject {
private static final String CREATE_BACKUPS = "CreateBackups";
private static final String MAX_BACKUPS = "MaxBackups";
private static final String REMOVE_BACKUPS = "RemoveBackups";
private static final String LAST_SECURITIES_UPDATE_TIMESTAMP = "LastSecuritiesUpdateTimestamp";
private static final int MAX_BACKUPS_DEFAULT = 5;
@ManyToOne(cascade = CascadeType.PERSIST)
private CurrencyNode defaultCurrency;
private String accountSeparator = ":";
/**
* Current file format
*/
private String fileFormat = Engine.CURRENT_MAJOR_VERSION + "." + Engine.CURRENT_MINOR_VERSION;
private transient ReadWriteLock preferencesLock;
/**
* Contains a list a items to display in the transaction number combo.
*/
@ElementCollection
private final List transactionNumberItems = new ArrayList<>();
/**
* {@code Map} for file based operation preferences.
*
* GUI locations, last used accounts, etc
* should not be stored here as they will be dependent on the user.
* Only values required for operation consistency should be stored here.
*/
@ElementCollection
@Column(columnDefinition = "varchar(8192)")
private final Map preferences = new HashMap<>();
public Config() {
preferencesLock = new ReentrantReadWriteLock(true);
}
void initialize() {
Account.setAccountSeparator(getAccountSeparator());
}
public String getFileFormat() {
return fileFormat;
}
int getMajorFileFormatVersion() {
return Integer.parseInt(getFileFormat().split("\\.")[0]);
}
int getMinorFileFormatVersion() {
return Integer.parseInt(getFileFormat().split("\\.")[1]);
}
void updateFileVersion() {
this.fileFormat = Engine.CURRENT_MAJOR_VERSION + "." + Engine.CURRENT_MINOR_VERSION;
}
void setDefaultCurrency(final CurrencyNode defaultCurrency) {
this.defaultCurrency = defaultCurrency;
}
CurrencyNode getDefaultCurrency() {
return defaultCurrency;
}
void setAccountSeparator(final String accountSeparator) {
this.accountSeparator = accountSeparator;
Account.setAccountSeparator(this.accountSeparator);
}
String getAccountSeparator() {
return accountSeparator;
}
void setTransactionNumberList(final List transactionNumberItems) {
if (transactionNumberItems != null) {
this.transactionNumberItems.clear();
this.transactionNumberItems.addAll(transactionNumberItems);
}
}
List getTransactionNumberList() {
if (transactionNumberItems.isEmpty()) {
final ResourceBundle rb = ResourceUtils.getBundle();
transactionNumberItems.add(rb.getString("Item.EFT"));
transactionNumberItems.add(rb.getString("Item.Trans"));
}
return new ArrayList<>(transactionNumberItems);
}
void setPreference(@NotNull final String key, @Nullable final String value) {
preferencesLock.writeLock().lock();
try {
if (key.isEmpty()) {
throw new RuntimeException(ResourceUtils.getString("Message.Error.EmptyKey"));
}
if (value == null) { // find and remove
preferences.remove(key);
} else {
preferences.put(key, value);
}
} finally {
preferencesLock.writeLock().unlock();
}
}
@Nullable
String getPreference(@NotNull final String key) {
preferencesLock.readLock().lock();
try {
if (key.isEmpty()) {
throw new RuntimeException(ResourceUtils.getString("Message.Error.EmptyKey"));
}
return preferences.get(key);
} finally {
preferencesLock.readLock().unlock();
}
}
boolean createBackups() {
final String result = getPreference(CREATE_BACKUPS);
return result == null || Boolean.parseBoolean(result);
}
void setCreateBackups(final boolean createBackups) {
setPreference(CREATE_BACKUPS, Boolean.toString(createBackups));
}
int getRetainedBackupLimit() {
final String result = getPreference(MAX_BACKUPS);
if (result != null) {
return Integer.parseInt(result);
}
return MAX_BACKUPS_DEFAULT;
}
void setRetainedBackupLimit(final int retainedBackupLimit) {
setPreference(MAX_BACKUPS, Integer.toString(retainedBackupLimit));
}
boolean removeOldBackups() {
final String result = getPreference(REMOVE_BACKUPS);
return result == null || Boolean.parseBoolean(result);
}
void setRemoveOldBackups(final boolean removeOldBackups) {
setPreference(REMOVE_BACKUPS, Boolean.toString(removeOldBackups));
}
void setLastSecuritiesUpdateTimestamp(@NotNull final LocalDateTime localDateTime) {
setPreference(LAST_SECURITIES_UPDATE_TIMESTAMP, localDateTime.toString());
}
@NotNull
LocalDateTime getLastSecuritiesUpdateTimestamp() {
final String result = getPreference(LAST_SECURITIES_UPDATE_TIMESTAMP);
if (result != null) {
return LocalDateTime.parse(result);
}
return LocalDateTime.MIN;
}
/**
* Required by XStream for proper initialization.
*
* @return Properly initialized Config object
*/
protected Object readResolve() {
postLoad();
return this;
}
@PostLoad
private void postLoad() {
preferencesLock = new ReentrantReadWriteLock(true);
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/CurrencyNode.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.math.BigDecimal;
import java.util.logging.Logger;
import javax.persistence.Entity;
/**
* Class for representing currency nodes.
*
* @author Craig Cavanaugh
*/
@Entity
public class CurrencyNode extends CommodityNode {
private transient ExchangeRateDAO exchangeRateDAO;
public CurrencyNode() {
}
/**
* Returns the {@code ExchangeRateDAO}.
*
* @return the exchangeRateStore
*/
synchronized private ExchangeRateDAO getExchangeRateDAO() {
return exchangeRateDAO;
}
/**
* Sets the {@code ExchangeRateDAO}.
*
* @param exchangeRateStore the exchangeRateStore to set
*/
synchronized void setExchangeRateDAO(final ExchangeRateDAO exchangeRateStore) {
this.exchangeRateDAO = exchangeRateStore;
}
/**
* Returns an exchange rate given a currency to convert to.
*
* @param exchangeCurrency currency to convert to
* @return exchange rate
*/
synchronized public BigDecimal getExchangeRate(final CurrencyNode exchangeCurrency) {
if (exchangeCurrency == null) {
Logger.getLogger(CurrencyNode.class.getName()).severe("exchangeCurrency was null");
return BigDecimal.ONE;
}
if (exchangeCurrency.equals(this)) {
return BigDecimal.ONE;
}
BigDecimal rate = getExchangeRateDAO().getExchangeRateNode(this, exchangeCurrency).getRate();
if (getSymbol().compareToIgnoreCase(exchangeCurrency.getSymbol()) < 0) {
rate = BigDecimal.ONE.divide(rate, MathConstants.mathContext);
}
return rate;
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/DataStore.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collection;
import java.util.function.DoubleConsumer;
import jgnash.util.NotNull;
/**
* Interface for data storage backends.
*
* @author Craig Cavanaugh
*/
public interface DataStore {
/**
* Close the engine instance if open.
*/
void closeEngine();
/**
* Create an engine instance connected to a remote server.
*
* @param host host name or IP address
* @param port connection port
* @param password user password
* @param engineName unique name to give the engine instance
* @return Engine instance if a successful connection is made
*/
Engine getClientEngine(final String host, final int port, final char[] password, final String engineName);
/**
* Create an engine instance that uses a file.
*
* @param fileName full path to the file
* @param engineName unique name to give the engine instance
* @param password user password
* @return Engine instance. A new file will be created if it does not exist
*/
Engine getLocalEngine(final String fileName, final String engineName, final char[] password);
/**
* Returns the default file extension for this DataStore.
*
* @return file extension
*/
@NotNull
String getFileExt();
/**
* Returns the full path to the file the DataStore is using.
*
* @return full path to the file, null if this is a remotely connected DataStore
*/
String getFileName();
/**
* Returns this DataStores type.
*
* @return type of data store
*/
DataStoreType getType();
/**
* Local / Remote connection indicator.
*
* @return false if connected to a remote server
*/
boolean isLocal();
/**
* Saves a Collection of StoredObjects to a file other than what is currently open.
*
* The currently open file will not be closed.
* @param path full path to the file to save the database to
* @param objects Collection of StoredObjects to save
* @param percentComplete callback to report the percent complete
*/
void saveAs(Path path, Collection objects, DoubleConsumer percentComplete);
/**
* Renames a datastore.
*
* @param fileName name of the datastore to rename
* @param newFileName the new filename
* @throws java.io.IOException if an I/O error occurs
*/
default void rename(final String fileName, final String newFileName) throws IOException {
final Path path = Paths.get(fileName);
if (Files.exists(path)) {
Files.move(path, Paths.get(newFileName));
}
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/DataStoreType.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import jgnash.engine.jpa.JpaH2DataStore;
import jgnash.engine.jpa.JpaH2MvDataStore;
import jgnash.engine.jpa.JpaHsqlDataStore;
import jgnash.engine.xstream.BinaryXStreamDataStore;
import jgnash.engine.xstream.XMLDataStore;
import jgnash.resource.util.ResourceUtils;
import java.lang.reflect.InvocationTargetException;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Storage type enumeration.
*
* @author Craig Cavanaugh
*/
public enum DataStoreType {
BINARY_XSTREAM(
ResourceUtils.getString("DataStoreType.Bxds"),
false,
BinaryXStreamDataStore.class),
H2_DATABASE (
ResourceUtils.getString("DataStoreType.H2") + " (1.3)",
true,
JpaH2DataStore.class),
H2MV_DATABASE (
ResourceUtils.getString("DataStoreType.H2") + " (1.4)",
true,
JpaH2MvDataStore.class),
HSQL_DATABASE (
ResourceUtils.getString("DataStoreType.HSQL"),
true,
JpaHsqlDataStore.class),
XML(
ResourceUtils.getString("DataStoreType.XML"),
false,
XMLDataStore.class);
/* If true, then this DataStoreType can support remote connections */
public final transient boolean supportsRemote;
private final transient String description;
private final transient Class extends DataStore> dataStore;
DataStoreType(final String description, final boolean supportsRemote, final Class extends DataStore> dataStore) {
this.description = description;
this.supportsRemote = supportsRemote;
this.dataStore = dataStore;
}
public DataStore getDataStore() {
try {
return dataStore.getDeclaredConstructor().newInstance();
} catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException
| NoSuchMethodException | SecurityException ex) {
Logger.getLogger(DataStoreType.class.getName()).log(Level.SEVERE, null, ex);
throw new RuntimeException(ex);
}
}
@Override
public String toString() {
return description;
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/DefaultCurrencies.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2021 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.text.DecimalFormatSymbols;
import java.text.NumberFormat;
import java.util.Currency;
import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Static methods for currency generation and discovery.
*
* These are known to not show up because Java 1.4.2 and older does not have
* a default NumberFormat defined for the currency:
*
* {@code
* "SGD"
* "MYR"
* }
*
* @author Craig Cavanaugh
*/
public class DefaultCurrencies {
/**
* Private Constructor, use static methods only.
*/
private DefaultCurrencies() {
}
/**
* Generates an array of default currency nodes that Java knows about.
*
* @return An array of default CurrencyNodes
*/
public static Set generateCurrencies() {
TreeSet set = new TreeSet<>();
for (Locale locale : NumberFormat.getAvailableLocales()) {
// only try if a valid county length is returned
if (locale.getCountry().length() == 2) {
try {
if (Currency.getInstance(locale) != null) {
set.add(buildNode(locale));
}
} catch (final IllegalArgumentException ignored) {
// ignored, locale is not a supported ISO 3166 country code
} catch (final Exception ex) {
Logger.getLogger(DefaultCurrencies.class.getName()).log(Level.SEVERE, null, ex);
}
}
}
return set;
}
/**
* Creates a custom CurrencyNode given an ISO code. If the ISO code is
* not valid, then a node will be generated using the supplied code. The
* locale is assumed to be the default locale.
*
* @param ISOCode The custom currency to generate
* @return The custom CurrencyNode
*/
public static CurrencyNode buildCustomNode(final String ISOCode) {
final CurrencyNode node = new CurrencyNode();
Currency c;
try {
c = Currency.getInstance(ISOCode);
node.setSymbol(c.getCurrencyCode());
} catch (Exception e) {
node.setSymbol(ISOCode);
Logger.getLogger(DefaultCurrencies.class.getName()).log(Level.FINE, null, e);
} finally {
node.setDescription(Locale.getDefault().toString());
}
return node;
}
/**
* Creates a valid CurrencyNode given a locale.
*
* @param locale Locale to create a CurrencyNode for
* @return The new CurrencyNode
*/
public static CurrencyNode buildNode(final Locale locale) {
DecimalFormatSymbols symbols = new DecimalFormatSymbols(locale);
Currency c = symbols.getCurrency();
CurrencyNode node = new CurrencyNode();
node.setSymbol(c.getCurrencyCode());
node.setPrefix(symbols.getCurrencySymbol());
byte scale = (byte) c.getDefaultFractionDigits();
if (scale == -1) { // The JVM may return a negative value for some Locales
scale = 0; // scale may be -1, but this is not allowed for CurrencyNodes
}
node.setScale(scale);
return node;
}
/**
* Generates the default CurrencyNode for the current locale.
*
* @return The new CurrencyNode
*/
public static CurrencyNode getDefault() {
try {
return buildNode(Locale.getDefault());
} catch (final Exception e) {
return buildNode(Locale.US);
}
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/Engine.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletionService;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorCompletionService;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Function;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import jgnash.engine.attachment.AttachmentManager;
import jgnash.engine.budget.Budget;
import jgnash.engine.budget.BudgetGoal;
import jgnash.engine.concurrent.LockManager;
import jgnash.engine.dao.AccountDAO;
import jgnash.engine.dao.BudgetDAO;
import jgnash.engine.dao.CommodityDAO;
import jgnash.engine.dao.ConfigDAO;
import jgnash.engine.dao.EngineDAO;
import jgnash.engine.dao.RecurringDAO;
import jgnash.engine.dao.TransactionDAO;
import jgnash.engine.dao.TrashDAO;
import jgnash.engine.message.ChannelEvent;
import jgnash.engine.message.Message;
import jgnash.engine.message.MessageBus;
import jgnash.engine.message.MessageChannel;
import jgnash.engine.message.MessageProperty;
import jgnash.engine.recurring.MonthlyReminder;
import jgnash.engine.recurring.PendingReminder;
import jgnash.engine.recurring.RecurringIterator;
import jgnash.engine.recurring.Reminder;
import jgnash.net.currency.CurrencyUpdateFactory;
import jgnash.net.security.UpdateFactory;
import jgnash.resource.util.ResourceUtils;
import jgnash.time.DateUtils;
import jgnash.util.DefaultDaemonThreadFactory;
import jgnash.util.NotNull;
import jgnash.util.Nullable;
import org.apache.commons.collections4.ListUtils;
/**
* Engine class
*
* When objects are removed, they are wrapped in a TrashObject so they may still be referenced for messaging and cleanup
* operations. After a predefined period of time, they are permanently removed.
*
* @author Craig Cavanaugh
*/
public class Engine {
/**
* Current version for the file format.
*/
public static final int CURRENT_MAJOR_VERSION = 3;
public static final int CURRENT_MINOR_VERSION = 6;
// Lock name
private static final String BIG_LOCK = "bigLock";
private static final Logger logger = Logger.getLogger(Engine.class.getName());
private static final long MAXIMUM_TRASH_AGE = 2L * 60L * 1000L; // 2 minutes
/**
* The maximum number of network errors before scheduled tasks are stopped.
*/
private static final short MAX_ERRORS = 2;
/**
* Time in seconds to delay start of background updates.
*/
private static final int SCHEDULED_DELAY = 30;
/**
* Time is seconds for a forced shutdown of background services
*/
private static final int FORCED_SHUTDOWN_TIMEOUT = 15;
private static final String MESSAGE_ACCOUNT_MODIFY = "Message.AccountModify";
private static final String COMMODITY = "Commodity ";
static {
logger.setLevel(Level.ALL);
}
private final ResourceBundle rb = ResourceUtils.getBundle();
/**
* Primary lock for any operation that alters or reads data
*/
private final ReentrantReadWriteLock dataLock;
private final AtomicInteger backGroundCounter = new AtomicInteger();
/**
* Named identifier for this engine instance.
*/
private final String name;
/**
* Unique identifier for this engine instance.
* Used by this distributed lock manager to keep track of who has a lock
*/
private final String uuid = UUID.randomUUID().toString();
private final EngineDAO eDAO;
private final AttachmentManager attachmentManager;
/**
* Background executor service for trash management and currency / security updates
*/
private final ScheduledThreadPoolExecutor backgroundExecutorService;
/**
* All engine instances will share the same message bus.
*/
private final MessageBus messageBus;
/**
* Cached for performance.
*/
private Config config;
/**
* Cached for performance.
*/
private RootAccount rootAccount;
private ExchangeRateDAO exchangeRateDAO;
/**
* Cached for performance.
*/
private String accountSeparator = null;
public Engine(final EngineDAO eDAO, final LockManager lockManager, final AttachmentManager attachmentManager, final String name) {
Objects.requireNonNull(name, "The engine name may not be null");
Objects.requireNonNull(eDAO, "The engineDAO may not be null");
logger.log(Level.INFO, "Release {0}.{1}", new Object[]{CURRENT_MAJOR_VERSION, CURRENT_MINOR_VERSION});
this.attachmentManager = attachmentManager;
this.eDAO = eDAO;
this.name = name;
// Generate lock
dataLock = lockManager.getLock(BIG_LOCK);
messageBus = MessageBus.getInstance(name);
initialize();
checkAndCorrect();
backgroundExecutorService = new ScheduledThreadPoolExecutor(1,
new DefaultDaemonThreadFactory("Engine Background Executor"));
backgroundExecutorService.setRemoveOnCancelPolicy(true);
backgroundExecutorService.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
// run trash cleanup every 5 minutes 45 seconds after startup
backgroundExecutorService.scheduleWithFixedDelay(() -> {
if (!Thread.currentThread().isInterrupted()) {
emptyTrash();
}
}, 45, 5L * 60L, TimeUnit.SECONDS);
backgroundExecutorService.schedule(() -> {
if (UpdateFactory.getUpdateOnStartup()) {
// don't update on weekends unless needed
if (UpdateFactory.shouldAutomaticUpdateOccur(getConfig().getLastSecuritiesUpdateTimestamp())) {
startSecuritiesUpdate(SCHEDULED_DELAY);
}
}
}, 30, TimeUnit.SECONDS);
backgroundExecutorService.schedule(() -> {
if (CurrencyUpdateFactory.getUpdateOnStartup()) {
startExchangeRateUpdate(SCHEDULED_DELAY);
}
}, 30, TimeUnit.SECONDS);
}
boolean isFileDirty() {
return eDAO.isDirty() || getAccountDAO().isDirty() || getBudgetDAO().isDirty() || getCommodityDAO().isDirty()
|| getConfigDAO().isDirty() || getReminderDAO().isDirty() || getTransactionDAO().isDirty()
|| getTrashDAO().isDirty();
}
/**
* Registers a {@code Handler} with the class logger.
* This also ensures the static logger is initialized.
*
* @param handler {@code Handler} to register
*/
public static void addLogHandler(final Handler handler) {
logger.addHandler(handler);
}
/**
* Returns the most current known market price for a requested date. The {@code SecurityNode} history will be
* searched for an exact match first. If an exact match is not found, investment transactions will be searched
* for the closest requested date. {@code SecurityHistoryNode} history values will take precedent over
* a transaction with the same closest or matching date.
*
* @param transactions Collection of transactions utilizing the requested investment
* @param node {@code SecurityNode} we want a price for
* @param baseCurrency {@code CurrencyNode} reporting currency
* @param localDate {@code LocalDate} we want a market price for
* @return The best market price or a value of 0 if no history or transactions exist
*/
public static BigDecimal getMarketPrice(final Collection transactions, final SecurityNode node,
final CurrencyNode baseCurrency, final LocalDate localDate) {
// Search for the exact history node record
Optional optional = node.getHistoryNode(localDate);
// not null, must be an exact match, return the value because it has precedence
if (optional.isPresent()) {
return node.getMarketPrice(localDate, baseCurrency);
}
// Nothing found yet, continue searching for something better
LocalDate priceDate = LocalDate.ofEpochDay(0);
BigDecimal price = BigDecimal.ZERO;
optional = node.getClosestHistoryNode(localDate);
if (optional.isPresent()) { // Closest option so far
price = optional.get().getPrice();
priceDate = optional.get().getLocalDate();
}
// Compare against transactions
for (final Transaction t : transactions) {
if (t instanceof InvestmentTransaction && ((InvestmentTransaction) t).getSecurityNode() == node) {
// The transaction date must be closer than the history node, but not newer than the request date
if ((t.getLocalDate().isAfter(priceDate) && t.getLocalDate().isBefore(localDate)) || t.getLocalDate().equals(localDate)) {
// Check for a dividend, etc that may have returned a price of zero
final BigDecimal p = ((InvestmentTransaction) t).getPrice();
if (p != null && p.compareTo(BigDecimal.ZERO) > 0) {
price = p;
priceDate = t.getLocalDate();
}
}
}
}
// Get the current exchange rate for the security node
final BigDecimal rate = node.getReportedCurrencyNode().getExchangeRate(baseCurrency);
// return the price and factor in the exchange rate
return price.multiply(rate);
}
static String buildExchangeRateId(final CurrencyNode baseCurrency, final CurrencyNode exchangeCurrency) {
String rateId;
if (baseCurrency.getSymbol().compareToIgnoreCase(exchangeCurrency.getSymbol()) > 0) {
rateId = baseCurrency.getSymbol() + exchangeCurrency.getSymbol();
} else {
rateId = exchangeCurrency.getSymbol() + baseCurrency.getSymbol();
}
return rateId;
}
/**
* Returns the engine logger.
*
* @return the engine logger
*/
public static Logger getLogger() {
return logger;
}
/**
* Log a informational message.
*
* @param message message to display
*/
private static void logInfo(final String message) {
logger.log(Level.INFO, message);
}
/**
* Log a warning message.
*
* @param message message to display
*/
private static void logWarning(final String message) {
logger.warning(message);
}
/**
* Log a severe message.
*
* @param message message to display
*/
private static void logSevere(final String message) {
logger.severe(message);
}
private static void shutDownAndWait(final ExecutorService executorService) {
executorService.shutdownNow();
try {
if (!executorService.awaitTermination(FORCED_SHUTDOWN_TIMEOUT, TimeUnit.SECONDS)) {
if (!executorService.awaitTermination(FORCED_SHUTDOWN_TIMEOUT, TimeUnit.SECONDS)) {
logSevere("Unable to shutdown background service");
}
}
} catch (final InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
logger.log(Level.FINEST, e.getLocalizedMessage(), e);
}
}
/**
* Initiates a background exchange rate update with a given start delay.
*
* @param delay delay in seconds
*/
public void startExchangeRateUpdate(final int delay) {
backgroundExecutorService.schedule(new BackgroundCallable(new CurrencyUpdateFactory.UpdateExchangeRatesCallable()), delay,
TimeUnit.SECONDS);
}
/**
* Initiates a background securities history update with a given start delay.
*
* @param delay delay in seconds
*/
public void startSecuritiesUpdate(final int delay) {
final List callables = new ArrayList<>();
getSecurities().stream().filter(securityNode ->
securityNode.getQuoteSource() != QuoteSource.NONE).forEach(securityNode -> { // failure will occur if source is not defined
callables.add(new BackgroundCallable(new UpdateFactory.UpdateSecurityNodeCallable(securityNode)));
callables.add(new BackgroundCallable(new UpdateFactory.UpdateSecurityNodeEventsCallable(securityNode)));
});
// Cleanup thread that monitors for excess network connection failures
new SecuritiesUpdateRunnable(callables, delay).start();
// Save the last update
config.setLastSecuritiesUpdateTimestamp(LocalDateTime.now());
}
/**
* Creates a RootAccount and default currency only if necessary.
*/
private void initialize() {
dataLock.writeLock().lock();
try {
// ask the Config object to perform any needed configuration
getConfig().initialize();
// build the exchange rate storage object
exchangeRateDAO = new ExchangeRateDAO(getCommodityDAO());
// assign the exchange rate store to the currencies
for (final CurrencyNode node : getCurrencies()) {
node.setExchangeRateDAO(exchangeRateDAO);
}
// obtain or establish the root account
RootAccount root = getRootAccount();
if (root == null) {
CurrencyNode node = getDefaultCurrency();
if (node == null) {
node = DefaultCurrencies.getDefault();
node.setExchangeRateDAO(exchangeRateDAO);
addCurrency(node); // force the node to persisted
}
root = new RootAccount(node);
root.setName(rb.getString("Name.Root"));
root.setDescription(rb.getString("Name.Root"));
logInfo("Creating RootAccount");
if (!getAccountDAO().addRootAccount(root)) {
logSevere("Was not able to add the root account");
throw new EngineException("Was not able to add the root account");
}
if (getDefaultCurrency() == null) {
setDefaultCurrency(node);
}
}
} finally {
dataLock.writeLock().unlock();
}
logInfo("Engine initialization is complete");
}
/**
* Corrects minor issues with a database that may occur because of prior bugs or file format upgrades.
*/
private void checkAndCorrect() {
dataLock.writeLock().lock();
try {
// check and correct multiple root accounts from old files... there are still a few.
List accountList = getAccountList().stream().filter(account -> account.getAccountType()
.equals(AccountType.ROOT)).collect(Collectors.toList());
if (accountList.size() > 1) {
for (Account account : accountList) {
if (account.getChildCount() == 0) {
removeAccount(account);
logWarning("Removed an extra / empty root account");
}
}
}
final List list = eDAO.getStoredObjects(Config.class);
if (list.size() > 1) {
// Delete all but the first found config object
for (int i = 1; i < list.size(); i++) {
logWarning("Removed an extra Config object");
moveObjectToTrash(list.get(i));
}
}
// Transaction timestamps were updated for release 2.25
if (getConfig().getMinorFileFormatVersion() < 25 && getConfig().getMajorFileFormatVersion() < 3) {
// Update transactions in chunks of 200
ListUtils.partition(getTransactions(), 200).forEach(eDAO::bulkUpdate);
}
// update the file version if it is not current
if (getConfig().getMajorFileFormatVersion() != CURRENT_MAJOR_VERSION
|| getConfig().getMinorFileFormatVersion() != CURRENT_MINOR_VERSION) {
final Config localConfig = getConfig();
localConfig.updateFileVersion();
getConfigDAO().update(localConfig);
}
} finally {
dataLock.writeLock().unlock();
}
}
private void clearObsoleteExchangeRates() {
getCommodityDAO().getExchangeRates().stream()
.filter(rate -> getBaseCurrencies(rate.getRateId()).length == 0)
.forEach(this::removeExchangeRate);
}
private void removeExchangeRate(final ExchangeRate rate) {
dataLock.writeLock().lock();
try {
for (final ExchangeRateHistoryNode node : rate.getHistory()) {
removeExchangeRateHistory(rate, node);
}
moveObjectToTrash(rate);
} finally {
dataLock.writeLock().unlock();
}
}
void stopBackgroundServices() {
logInfo("Controlled engine shutdown initiated");
shutDownAndWait(backgroundExecutorService);
logInfo("Background services have been stopped");
}
void shutdown() {
eDAO.shutdown();
}
public String getName() {
return name;
}
private AccountDAO getAccountDAO() {
return eDAO.getAccountDAO();
}
private BudgetDAO getBudgetDAO() {
return eDAO.getBudgetDAO();
}
private CommodityDAO getCommodityDAO() {
return eDAO.getCommodityDAO();
}
private ConfigDAO getConfigDAO() {
return eDAO.getConfigDAO();
}
private RecurringDAO getReminderDAO() {
return eDAO.getRecurringDAO();
}
private TransactionDAO getTransactionDAO() {
return eDAO.getTransactionDAO();
}
private TrashDAO getTrashDAO() {
return eDAO.getTrashDAO();
}
private boolean moveObjectToTrash(final Object object) {
boolean result = false;
dataLock.writeLock().lock();
try {
if (object instanceof StoredObject) {
getTrashDAO().add(new TrashObject((StoredObject) object));
} else { // simple object with an annotated JPA entity id of type long is assumed
getTrashDAO().addEntityTrash(object);
}
result = true;
} catch (final Exception ex) {
logger.log(Level.SEVERE, ex.getLocalizedMessage(), ex);
} finally {
dataLock.writeLock().unlock();
}
return result;
}
/**
* Empty the trash if any objects are older than the defined time.
*/
private void emptyTrash() {
if (backGroundCounter.incrementAndGet() == 1) {
messageBus.fireEvent(new Message(MessageChannel.SYSTEM, ChannelEvent.BACKGROUND_PROCESS_STARTED,
Engine.this));
}
dataLock.writeLock().lock();
try {
logger.info("Checking for trash");
final List trash = getTrashDAO().getTrashObjects();
/* always sort by the timestamp of the trash object to prevent
* foreign key removal exceptions when multiple related accounts
* or objects are removed */
Collections.sort(trash);
if (trash.isEmpty()) {
logger.info("No trash was found");
}
trash.stream().filter(o -> ChronoUnit.MILLIS.between(o.getDate(), LocalDateTime.now()) >= MAXIMUM_TRASH_AGE)
.forEach(o -> getTrashDAO().remove(o));
} finally {
dataLock.writeLock().unlock();
if (backGroundCounter.decrementAndGet() == 0) {
messageBus.fireEvent(new Message(MessageChannel.SYSTEM, ChannelEvent.BACKGROUND_PROCESS_STOPPED,
Engine.this));
}
}
}
/**
* Creates a default reminder given a transaction and the primary account. The Reminder will need to persisted.
*
* @param transaction Transaction for the reminder. The transaction will be cloned
* @param account primary account
* @return new default {@code MonthlyReminder}
*/
public static Reminder createDefaultReminder(final Transaction transaction, final Account account) {
final Reminder reminder = new MonthlyReminder();
try {
reminder.setAccount(account);
reminder.setStartDate(transaction.getLocalDate().plusMonths(1));
reminder.setTransaction((Transaction) transaction.clone());
reminder.setDescription(transaction.getPayee());
reminder.setNotes(transaction.getMemo());
} catch (final CloneNotSupportedException e) {
logSevere(e.getLocalizedMessage());
}
return reminder;
}
public boolean addReminder(final Reminder reminder) {
Objects.requireNonNull(reminder.getUuid());
boolean result = false;
// make sure the description has been set
if (reminder.getDescription() != null && !reminder.getDescription().isBlank()) {
result = getReminderDAO().addReminder(reminder);
}
Message message;
if (result) {
message = new Message(MessageChannel.REMINDER, ChannelEvent.REMINDER_ADD, this);
} else {
message = new Message(MessageChannel.REMINDER, ChannelEvent.REMINDER_ADD_FAILED, this);
}
message.setObject(MessageProperty.REMINDER, reminder);
messageBus.fireEvent(message);
return result;
}
public boolean removeReminder(final Reminder reminder) {
boolean result = false;
if (moveObjectToTrash(reminder)) {
if (reminder.getTransaction() != null) {
moveObjectToTrash(reminder.getTransaction());
reminder.setTransaction(null);
}
Message message = new Message(MessageChannel.REMINDER, ChannelEvent.REMINDER_REMOVE, this);
message.setObject(MessageProperty.REMINDER, reminder);
messageBus.fireEvent(message);
result = true;
}
return result;
}
/**
* Returns a list of reminders.
*
* @return List of reminders
*/
public List getReminders() {
return getReminderDAO().getReminderList();
}
public Reminder getReminderByUuid(final UUID uuid) {
return getReminderDAO().getReminderByUuid(uuid);
}
public List getPendingReminders() {
final ArrayList pendingList = new ArrayList<>();
final List list = getReminders();
final LocalDate now = LocalDate.now(); // today's date
for (final Reminder r : list) {
if (r.isEnabled()) {
final RecurringIterator ri = r.getIterator();
LocalDate next = ri.next();
while (next != null) {
LocalDate date = next;
if (r.isAutoCreate()) {
date = date.minusDays(r.getDaysAdvance());
}
if (DateUtils.before(date, now)) { // need to fire this reminder
pendingList.add(new PendingReminder(r, next));
next = ri.next();
} else {
next = null;
}
}
}
}
return pendingList;
}
public static PendingReminder getPendingReminder(@NotNull Reminder reminder) {
final RecurringIterator ri = reminder.getIterator();
LocalDate next = ri.next();
if (next != null) {
return new PendingReminder(reminder, next);
}
return null;
}
public void processPendingReminders(final Collection pendingReminders) {
pendingReminders.stream().filter(PendingReminder::isApproved).forEach(pending -> {
final Reminder reminder = pending.getReminder();
if (reminder.getTransaction() != null) { // add the transaction
final Transaction t = reminder.getTransaction();
// Update to the commit date (commit date can be modified)
t.setDate(pending.getCommitDate());
addTransaction(t);
}
// update the last fired date... date returned from the iterator
reminder.setLastDate(); // mark as complete
if (!updateReminder(reminder)) {
logSevere(rb.getString("Message.Error.ReminderUpdate"));
}
});
}
public T getStoredObjectByUuid(final Class tClass, final UUID uuid) {
return eDAO.getObjectByUuid(tClass, uuid);
}
/**
* Returns a {@code Collection} of all {@code StoredObjects} in a consistent order.
* {@code StoredObjects} marked for removal and {@code TrashObjects} are filtered from the collection.
*
* @return {@code Collection} of {@code StoredObjects}
* @see Collection
* @see StoredObjectComparator
*/
public Collection getStoredObjects() {
dataLock.readLock().lock();
try {
List objects = eDAO.getStoredObjects();
// Filter out objects to be removed
objects.removeIf(TrashObject.class::isInstance);
objects.sort(new StoredObjectComparator());
return objects;
} finally {
dataLock.readLock().unlock();
}
}
/**
* Validate a CommodityNode for correctness.
*
* @param node CommodityNode to validate
* @return true if valid
*/
private boolean isCommodityNodeValid(final CommodityNode node) {
boolean result = true;
if (node.getUuid() == null) {
result = false;
logSevere("Commodity uuid was not valid");
}
if (node.getSymbol() == null || node.getSymbol().isEmpty()) {
result = false;
logSevere("Commodity symbol was not valid");
}
if (node.getScale() < 0) {
result = false;
logSevere(COMMODITY + node + " had a scale less than zero");
}
if (node instanceof SecurityNode && ((SecurityNode) node).getReportedCurrencyNode() == null) {
result = false;
logSevere(COMMODITY + node + " was not assigned a currency");
}
// ensure the UUID being used is unique
if (eDAO.getObjectByUuid(CommodityNode.class, node.getUuid()) != null) {
result = false;
logSevere(COMMODITY + node + " was not unique");
}
return result;
}
/**
* Adds a new CurrencyNode to the data set.
*
* Checks and prevents the addition of a duplicate Currencies.
*
* @param node new CurrencyNode to add
* @return {@code true} if the add it successful
*/
public boolean addCurrency(final CurrencyNode node) {
dataLock.writeLock().lock();
try {
boolean status = isCommodityNodeValid(node);
if (status) {
node.setExchangeRateDAO(exchangeRateDAO);
if (getCurrency(node.getSymbol()) != null) {
logger.log(Level.INFO, "Prevented addition of a duplicate CurrencyNode: {0}", node.getSymbol());
status = false;
}
}
if (status) {
status = getCommodityDAO().addCommodity(node);
logger.log(Level.FINE, "Adding: {0}", node);
}
Message message;
if (status) {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.CURRENCY_ADD, this);
} else {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.CURRENCY_ADD_FAILED, this);
}
message.setObject(MessageProperty.COMMODITY, node);
messageBus.fireEvent(message);
return status;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Links the {@code CurrencyNode} to the exchange DAO.
* Support method to be used during import operations
*
* @param currencyNode {@code CurrencyNode} to link
*/
void attachCurrencyNode(final CurrencyNode currencyNode) {
currencyNode.setExchangeRateDAO(exchangeRateDAO);
}
/**
* Adds a new SecurityNode to the data set.
*
* Checks and prevents the addition of a duplicate SecurityNode.
*
* @param node new SecurityNode to add
* @return {@code true} if the add it successful
*/
public boolean addSecurity(final SecurityNode node) {
dataLock.writeLock().lock();
try {
boolean status = isCommodityNodeValid(node);
if (status) {
if (getSecurity(node.getSymbol()) != null) {
logger.log(Level.INFO, "Prevented addition of a duplicate SecurityNode: {0}", node.getSymbol());
status = false;
}
}
if (status) {
status = getCommodityDAO().addCommodity(node);
logger.log(Level.FINE, "Adding: {0}", node);
}
Message message;
if (status) {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_ADD, this);
} else {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_ADD_FAILED, this);
}
message.setObject(MessageProperty.COMMODITY, node);
messageBus.fireEvent(message);
return status;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Add a SecurityHistoryNode node to a SecurityNode. If the SecurityNode already contains
* an equivalent SecurityHistoryNode, the old SecurityHistoryNode is removed first.
*
* @param node SecurityNode to add to
* @param hNode SecurityHistoryNode to add
* @return true if successful
*/
public boolean addSecurityHistory(@NotNull final SecurityNode node, @NotNull final SecurityHistoryNode hNode) {
dataLock.writeLock().lock();
try {
// Remove old history of the same date if it exists
if (node.contains(hNode.getLocalDate())) {
if (!removeSecurityHistory(node, hNode.getLocalDate())) {
logSevere(ResourceUtils.getString("Message.Error.HistRemoval", hNode.getLocalDate(), node.getSymbol()));
return false;
}
}
boolean status = node.addHistoryNode(hNode);
if (status) {
status = getCommodityDAO().addSecurityHistory(node, hNode);
}
Message message;
if (status) {
clearCachedAccountBalance(node);
message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_ADD, this);
} else {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_ADD_FAILED, this);
}
message.setObject(MessageProperty.COMMODITY, node);
messageBus.fireEvent(message);
return status;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Add a SecurityHistoryNode node to a SecurityNode. If the SecurityNode already contains
* an equivalent SecurityHistoryNode, the old SecurityHistoryNode is removed first.
*
* @param node SecurityNode to add to
* @param historyEvent SecurityHistoryNode to add
* @return true if successful
*/
public boolean addSecurityHistoryEvent(@NotNull final SecurityNode node, @NotNull final SecurityHistoryEvent historyEvent) {
dataLock.writeLock().lock();
try {
// Remove old history event if it exists, equality is used to work around hibernate optimizations
// A defensive copy of the old events is used to prevent concurrent modification errors
new HashSet<>(node.getHistoryEvents()).stream().filter(event -> event.equals(historyEvent))
.forEach(event -> removeSecurityHistoryEvent(node, historyEvent));
boolean status = node.addSecurityHistoryEvent(historyEvent);
if (status) {
status = getCommodityDAO().addSecurityHistoryEvent(node, historyEvent);
}
Message message;
if (status) {
clearCachedAccountBalance(node);
message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_EVENT_ADD, this);
} else {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_EVENT_ADD_FAILED, this);
}
message.setObject(MessageProperty.COMMODITY, node);
messageBus.fireEvent(message);
return status;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Returns a list of investment accounts that use the given security node.
*
* @param node security node
* @return list of investment accounts
*/
private Set getInvestmentAccountList(final SecurityNode node) {
return getInvestmentAccountList().parallelStream()
.filter(account -> account.containsSecurity(node)).collect(Collectors.toSet());
}
/**
* Forces all investment accounts containing the security to clear the cached account balance and reconciled account
* balance and recalculate when queried.
*
* @param node SecurityNode that was changed
*/
private void clearCachedAccountBalance(final SecurityNode node) {
getInvestmentAccountList(node).forEach(this::clearCachedAccountBalance);
}
/**
* Clears an {@code Accounts} cached balance and recursively works up the tree to the root.
*
* @param account {@code Account} to clear
*/
private void clearCachedAccountBalance(final Account account) {
dataLock.writeLock().lock();
try {
account.clearCachedBalances();
// force a persistence update if working as a client / server
if (eDAO.isRemote()) {
getAccountDAO().updateAccount(account);
}
} finally {
dataLock.writeLock().unlock();
}
if (account.getParent() != null && account.getParent().getAccountType() != AccountType.ROOT) {
clearCachedAccountBalance(account.getParent());
}
}
private CurrencyNode[] getBaseCurrencies(final String exchangeRateId) {
dataLock.readLock().lock();
try {
final List currencies = getCurrencies();
Collections.sort(currencies);
Collections.reverse(currencies);
for (final CurrencyNode node1 : currencies) {
for (final CurrencyNode node2 : currencies) {
if (node1 != node2 && buildExchangeRateId(node1, node2).equals(exchangeRateId)) {
return new CurrencyNode[]{node1, node2};
}
}
}
return new CurrencyNode[0];
} finally {
dataLock.readLock().unlock();
}
}
/**
* Returns an array of currencies being used in accounts.
*
* @return Set of CurrencyNodes
*/
public Set getActiveCurrencies() {
dataLock.readLock().lock();
try {
return getCommodityDAO().getActiveCurrencies();
} finally {
dataLock.readLock().unlock();
}
}
/**
* Returns a CurrencyNode given the symbol. This will not generate a new CurrencyNode. It must be explicitly created
* and added.
*
* @param symbol Currency symbol
* @return null if the CurrencyNode as not been defined
*/
public CurrencyNode getCurrency(final String symbol) {
dataLock.readLock().lock();
try {
CurrencyNode rNode = null;
for (CurrencyNode node : getCurrencies()) {
if (node.getSymbol().equals(symbol)) {
rNode = node;
break;
}
}
return rNode;
} finally {
dataLock.readLock().unlock();
}
}
public List getCurrencies() {
dataLock.readLock().lock();
try {
return getCommodityDAO().getCurrencies();
} finally {
dataLock.readLock().unlock();
}
}
public CurrencyNode getCurrencyNodeByUuid(final UUID uuid) {
return getCommodityDAO().getCurrencyByUuid(uuid);
}
public ExchangeRate getExchangeRate(final CurrencyNode baseCurrency, final CurrencyNode exchangeCurrency) {
dataLock.readLock().lock();
try {
return exchangeRateDAO.getExchangeRateNode(baseCurrency, exchangeCurrency);
} finally {
dataLock.readLock().unlock();
}
}
public ExchangeRate getExchangeRateByUuid(final UUID uuid) {
return getCommodityDAO().getExchangeRateByUuid(uuid);
}
@NotNull
public List getSecurities() {
dataLock.readLock().lock();
try {
return getCommodityDAO().getSecurities();
} finally {
dataLock.readLock().unlock();
}
}
/**
* Find a SecurityNode given it's symbol.
*
* @param symbol symbol of security to find
* @return null if not found
*/
public SecurityNode getSecurity(final String symbol) {
dataLock.readLock().lock();
try {
List list = getSecurities();
SecurityNode sNode = null;
for (SecurityNode node : list) {
if (node.getSymbol().equals(symbol)) {
sNode = node;
break;
}
}
return sNode;
} finally {
dataLock.readLock().unlock();
}
}
public SecurityNode getSecurityNodeByUuid(final UUID uuid) {
return getCommodityDAO().getSecurityByUuid(uuid);
}
private boolean isCommodityNodeUsed(final CommodityNode node) {
dataLock.readLock().lock();
try {
List list = getAccountList();
for (Account a : list) {
if (a.getCurrencyNode().equals(node)) {
return true;
}
if (a.getAccountType() == AccountType.INVEST || a.getAccountType() == AccountType.MUTUAL) {
for (SecurityNode j : a.getSecurities()) {
if (j.equals(node) || j.getReportedCurrencyNode().equals(node)) {
return true;
}
}
}
}
List sList = getSecurities();
for (SecurityNode sNode : sList) {
if (sNode.getReportedCurrencyNode().equals(node)) {
return true;
}
}
} finally {
dataLock.readLock().unlock();
}
return false;
}
public boolean removeCommodity(final CurrencyNode node) {
boolean status = true;
dataLock.writeLock().lock();
try {
if (isCommodityNodeUsed(node)) {
status = false;
} else {
clearObsoleteExchangeRates();
moveObjectToTrash(node);
}
Message message;
if (status) {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.CURRENCY_REMOVE, this);
} else {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.CURRENCY_REMOVE_FAILED, this);
}
message.setObject(MessageProperty.COMMODITY, node);
messageBus.fireEvent(message);
return status;
} finally {
dataLock.writeLock().unlock();
}
}
public boolean removeSecurity(final SecurityNode node) {
boolean status = true;
dataLock.writeLock().lock();
try {
if (isCommodityNodeUsed(node)) {
status = false;
} else {
// Remove all history nodes first so they are not left behind
// A copy is made to prevent a concurrent modification error to the underlying list, Bug #208
final List hNodes = new ArrayList<>(node.getHistoryNodes());
hNodes.stream()
.filter(hNode -> !removeSecurityHistory(node, hNode.getLocalDate()))
.forEach(hNode -> logSevere(ResourceUtils.getString("Message.Error.HistRemoval",
hNode.getLocalDate(), node.getSymbol())));
moveObjectToTrash(node);
}
Message message;
if (status) {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_REMOVE, this);
} else {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_REMOVE_FAILED, this);
}
message.setObject(MessageProperty.COMMODITY, node);
messageBus.fireEvent(message);
return status;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Remove a {@code SecurityHistoryNode} given a {@code Date}.
*
* @param node {@code SecurityNode} to remove history from
* @param date the search {@code Date}
* @return {@code true} if a {@code SecurityHistoryNode} was found and removed
*/
public boolean removeSecurityHistory(@NotNull final SecurityNode node, @NotNull final LocalDate date) {
dataLock.writeLock().lock();
boolean status = false;
try {
final Optional optional = node.getHistoryNode(date);
if (optional.isPresent()) {
status = node.removeHistoryNode(date);
if (status) { // removal was a success, make sure we cleanup properly
moveObjectToTrash(optional.get());
status = getCommodityDAO().removeSecurityHistory(node, optional.get());
logInfo(ResourceUtils.getString("Message.RemovingSecurityHistory", date, node.getSymbol()));
}
}
Message message;
if (status) {
clearCachedAccountBalance(node);
message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_REMOVE, this);
} else {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_REMOVE_FAILED, this);
}
message.setObject(MessageProperty.COMMODITY, node);
messageBus.fireEvent(message);
return status;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Remove a {@code SecurityHistoryEvent} from a {@code SecurityNode}.
*
* @param node {@code SecurityNode} to remove history from
* @param historyEvent the {@code SecurityHistoryEvent} to remove
* @return {@code true} if the {@code SecurityHistoryEvent} was found and removed
*/
public boolean removeSecurityHistoryEvent(@NotNull final SecurityNode node, @NotNull final SecurityHistoryEvent historyEvent) {
dataLock.writeLock().lock();
boolean status;
try {
status = node.removeSecurityHistoryEvent(historyEvent);
if (status) { // removal was a success, make sure we cleanup properly
moveObjectToTrash(historyEvent);
status = getCommodityDAO().removeSecurityHistoryEvent(node, historyEvent);
}
Message message;
if (status) {
clearCachedAccountBalance(node);
message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_EVENT_REMOVE, this);
} else {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_HISTORY_EVENT_REMOVE_FAILED, this);
}
message.setObject(MessageProperty.COMMODITY, node);
messageBus.fireEvent(message);
return status;
} finally {
dataLock.writeLock().unlock();
}
}
private Config getConfig() {
dataLock.readLock().lock();
try {
if (config == null) {
config = getConfigDAO().getDefaultConfig();
}
return config;
} finally {
dataLock.readLock().unlock();
}
}
public CurrencyNode getDefaultCurrency() {
dataLock.readLock().lock();
try {
CurrencyNode node = getConfig().getDefaultCurrency();
if (node == null) {
logger.warning("No default currency assigned");
}
return node;
} finally {
dataLock.readLock().unlock();
}
}
public void setDefaultCurrency(final CurrencyNode defaultCurrency) {
// make sure the new default is persisted if it has not been
if (!isStored(defaultCurrency)) {
addCurrency(defaultCurrency);
}
dataLock.writeLock().lock();
try {
final Config currencyConfig = getConfig();
currencyConfig.setDefaultCurrency(defaultCurrency);
getConfigDAO().update(currencyConfig);
logInfo("Setting default currency: " + defaultCurrency);
Message message = new Message(MessageChannel.CONFIG, ChannelEvent.CONFIG_MODIFY, this);
message.setObject(MessageProperty.CONFIG, currencyConfig);
messageBus.fireEvent(message);
Account root = getRootAccount();
// The root account holds a reference to the default currency
root.setCurrencyNode(defaultCurrency);
getAccountDAO().updateAccount(root);
message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY, this);
message.setObject(MessageProperty.ACCOUNT, root);
messageBus.fireEvent(message);
} finally {
dataLock.writeLock().unlock();
}
}
public void setExchangeRate(final CurrencyNode baseCurrency, final CurrencyNode exchangeCurrency,
final BigDecimal rate) {
setExchangeRate(baseCurrency, exchangeCurrency, rate, LocalDate.now());
}
public void setExchangeRate(final CurrencyNode baseCurrency, final CurrencyNode exchangeCurrency,
final BigDecimal rate, final LocalDate localDate) {
Objects.requireNonNull(rate);
if (rate.compareTo(BigDecimal.ZERO) < 1) {
throw new EngineException("Rate must be greater than zero");
}
if (baseCurrency.equals(exchangeCurrency)) {
return;
}
// find the correct ExchangeRate and create if needed
ExchangeRate exchangeRate = getExchangeRate(baseCurrency, exchangeCurrency);
if (exchangeRate == null) {
exchangeRate = new ExchangeRate(buildExchangeRateId(baseCurrency, exchangeCurrency));
getCommodityDAO().addExchangeRate(exchangeRate);
}
// Remove old history of the same date if it exists
if (exchangeRate.contains(localDate)) {
removeExchangeRateHistory(exchangeRate, exchangeRate.getHistory(localDate));
}
dataLock.writeLock().lock();
try {
// create the new history node
ExchangeRateHistoryNode historyNode;
if (baseCurrency.getSymbol().compareToIgnoreCase(exchangeCurrency.getSymbol()) > 0) {
historyNode = new ExchangeRateHistoryNode(localDate, rate);
} else {
historyNode = new ExchangeRateHistoryNode(localDate, BigDecimal.ONE.divide(rate, MathConstants.mathContext));
}
final Message message;
boolean result = false;
if (exchangeRate.addHistoryNode(historyNode)) {
result = getCommodityDAO().addExchangeRateHistory(exchangeRate);
}
if (result) {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.EXCHANGE_RATE_ADD, this);
} else {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.EXCHANGE_RATE_ADD_FAILED, this);
}
message.setObject(MessageProperty.EXCHANGE_RATE, exchangeRate);
messageBus.fireEvent(message);
} finally {
dataLock.writeLock().unlock();
}
}
public void removeExchangeRateHistory(final ExchangeRate exchangeRate, final ExchangeRateHistoryNode history) {
dataLock.writeLock().lock();
try {
final Message message;
boolean result = false;
if (exchangeRate.contains(history)) {
if (exchangeRate.removeHistoryNode(history)) {
moveObjectToTrash(history);
result = getCommodityDAO().removeExchangeRateHistory(exchangeRate);
}
}
if (result) {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.EXCHANGE_RATE_REMOVE, this);
} else {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.EXCHANGE_RATE_REMOVE_FAILED, this);
}
message.setObject(MessageProperty.EXCHANGE_RATE, exchangeRate);
messageBus.fireEvent(message);
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Modifies an existing currency node in place. The supplied node should not be a reference to the original
*
* @param oldNode old CommodityNode
* @param templateNode template CommodityNode
* @return true if successful
*/
public boolean updateCommodity(final CommodityNode oldNode, final CommodityNode templateNode) {
Objects.requireNonNull(oldNode);
Objects.requireNonNull(templateNode);
if (oldNode == templateNode) {
throw new EngineException("node were the same");
}
dataLock.writeLock().lock();
try {
boolean status;
if (oldNode.getClass().equals(templateNode.getClass())) {
oldNode.setDescription(templateNode.getDescription());
oldNode.setPrefix(templateNode.getPrefix());
oldNode.setScale(templateNode.getScale());
oldNode.setSuffix(templateNode.getSuffix());
if (templateNode instanceof SecurityNode) {
oldNode.setSymbol(templateNode.getSymbol()); // allow symbol to change
((SecurityNode) oldNode).setReportedCurrencyNode(((SecurityNode) templateNode).getReportedCurrencyNode());
((SecurityNode) oldNode).setQuoteSource(((SecurityNode) templateNode).getQuoteSource());
((SecurityNode) oldNode).setISIN(((SecurityNode) templateNode).getISIN());
}
status = getCommodityDAO().updateCommodityNode(oldNode);
} else {
status = false;
logger.warning("Template object class did not match old object class");
}
final Message message;
if (templateNode instanceof SecurityNode) {
if (status) {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_MODIFY, this);
message.setObject(MessageProperty.COMMODITY, oldNode);
} else {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.SECURITY_MODIFY_FAILED, this);
message.setObject(MessageProperty.COMMODITY, templateNode);
}
} else {
if (status) {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.CURRENCY_MODIFY, this);
message.setObject(MessageProperty.COMMODITY, oldNode);
} else {
message = new Message(MessageChannel.COMMODITY, ChannelEvent.CURRENCY_MODIFY_FAILED, this);
message.setObject(MessageProperty.COMMODITY, templateNode);
}
}
messageBus.fireEvent(message);
return status;
} finally {
dataLock.writeLock().unlock();
}
}
private boolean updateReminder(final Reminder reminder) {
final boolean result = getReminderDAO().updateReminder(reminder);
final Message message;
if (result) {
message = new Message(MessageChannel.REMINDER, ChannelEvent.REMINDER_UPDATE, this);
} else {
message = new Message(MessageChannel.REMINDER, ChannelEvent.REMINDER_UPDATE_FAILED, this);
}
message.setObject(MessageProperty.REMINDER, reminder);
messageBus.fireEvent(message);
return result;
}
public String getAccountSeparator() {
dataLock.readLock().lock();
try {
if (accountSeparator == null) {
accountSeparator = getConfig().getAccountSeparator();
}
return accountSeparator;
} finally {
dataLock.readLock().unlock();
}
}
public void setAccountSeparator(final String separator) {
dataLock.writeLock().lock();
try {
accountSeparator = separator;
Config localConfig = getConfig();
localConfig.setAccountSeparator(separator);
getConfigDAO().update(localConfig);
Message message = new Message(MessageChannel.CONFIG, ChannelEvent.CONFIG_MODIFY, this);
message.setObject(MessageProperty.CONFIG, localConfig);
messageBus.fireEvent(message);
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Returns a list of all Accounts excluding the rootAccount.
*
* @return List of accounts
*/
public List getAccountList() {
final List accounts = getAccountDAO().getAccountList();
accounts.remove(getRootAccount());
return accounts;
}
public Account getAccountByUuid(final UUID id) {
return getAccountDAO().getAccountByUuid(id);
}
/**
* Search for an account with a matching account name.
*
* @param accountName Account name to search for. Must not be null
* @return The matching account. {@code null} if not found.
*/
public Account getAccountByName(@NotNull final String accountName) {
Objects.requireNonNull(accountName);
final List list = getAccountList();
// sort for consistent search order
Collections.sort(list);
for (final Account account : list) {
if (accountName.equals(account.getName())) {
return account;
}
}
return null;
}
/**
* Returns a list of IncomeAccounts excluding the rootIncomeAccount.
*
* @return List of income accounts
*/
@NotNull
public List getIncomeAccountList() {
return getAccountDAO().getIncomeAccountList();
}
/**
* Returns a list of ExpenseAccounts excluding the rootExpenseAccount.
*
* @return List if expense accounts
*/
@NotNull
public List getExpenseAccountList() {
return getAccountDAO().getExpenseAccountList();
}
/**
* Returns a list of all accounts excluding the rootAccount and IncomeAccounts and ExpenseAccounts.
*
* @return List of investment accounts
*/
public List getInvestmentAccountList() {
return getAccountDAO().getInvestmentAccountList();
}
public void refresh(final StoredObject object) {
eDAO.refresh(object);
}
/**
* Adds a new account.
*
* @param parent The parent account
* @param child A new Account object
* @return true if successful
*/
public boolean addAccount(final Account parent, final Account child) {
Objects.requireNonNull(child);
Objects.requireNonNull(child.getUuid());
if (child.getAccountType() == AccountType.ROOT) {
throw new IllegalArgumentException("Invalid Account");
}
dataLock.writeLock().lock();
try {
Message message;
boolean result;
result = parent.addChild(child);
if (result) {
result = getAccountDAO().addAccount(parent, child);
}
if (result) {
message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_ADD, this);
message.setObject(MessageProperty.ACCOUNT, child);
messageBus.fireEvent(message);
logInfo(rb.getString("Message.AccountAdd"));
result = true;
} else {
message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_ADD_FAILED, this);
message.setObject(MessageProperty.ACCOUNT, child);
messageBus.fireEvent(message);
result = false;
}
return result;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Return the root account.
*
* @return RootAccount
*/
public RootAccount getRootAccount() {
dataLock.readLock().lock();
try {
if (rootAccount == null) {
rootAccount = getAccountDAO().getRootAccount();
}
return rootAccount;
} finally {
dataLock.readLock().unlock();
}
}
/**
* Move an account to a new parent account..
*
* @param account account to move
* @param newParent the new parent account
* @return true if successful
*/
public boolean moveAccount(final Account account, final Account newParent) {
Objects.requireNonNull(account);
Objects.requireNonNull(newParent);
dataLock.writeLock().lock();
try {
// cannot invert the child/parent relationship of an account
if (account.contains(newParent)) {
Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY_FAILED, this);
message.setObject(MessageProperty.ACCOUNT, account);
messageBus.fireEvent(message);
logInfo(rb.getString("Message.AccountMoveFailed"));
return false;
}
Account oldParent = account.getParent();
if (oldParent != null) { // check for detached account
oldParent.removeChild(account);
getAccountDAO().updateAccount(account);
getAccountDAO().updateAccount(oldParent);
Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY, this);
message.setObject(MessageProperty.ACCOUNT, oldParent);
messageBus.fireEvent(message);
}
newParent.addChild(account);
getAccountDAO().updateAccount(account);
getAccountDAO().updateAccount(newParent);
Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY, this);
message.setObject(MessageProperty.ACCOUNT, newParent);
messageBus.fireEvent(message);
logInfo(rb.getString(MESSAGE_ACCOUNT_MODIFY));
return true;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Changes an Account's code.
*
* @param account Account to change
* @param code new code
* @return true is successful
*/
public boolean setAccountCode(final Account account, final int code) {
account.setAccountCode(code);
boolean result = getAccountDAO().updateAccount(account);
if (result) {
final Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY, this);
message.setObject(MessageProperty.ACCOUNT, account);
messageBus.fireEvent(message);
logInfo(rb.getString(MESSAGE_ACCOUNT_MODIFY));
} else {
final Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY_FAILED, this);
message.setObject(MessageProperty.ACCOUNT, account);
messageBus.fireEvent(message);
}
return result;
}
/**
* Modifies an existing account given an account as a template. The type of the account cannot be changed.
*
* @param template The Account object to use as a template
* @param account The existing account
* @return true if successful
*/
public boolean modifyAccount(final Account template, final Account account) {
boolean result;
Message message;
dataLock.writeLock().lock();
try {
account.setName(template.getName());
account.setDescription(template.getDescription());
account.setNotes(template.getNotes());
account.setLocked(template.isLocked());
account.setPlaceHolder(template.isPlaceHolder());
account.setVisible(template.isVisible());
account.setExcludedFromBudget(template.isExcludedFromBudget());
account.setAccountNumber(template.getAccountNumber());
account.setBankId(template.getBankId());
account.setAccountCode(template.getAccountCode());
if (account.getAccountType().isMutable()) {
account.setAccountType(template.getAccountType());
}
// allow allow a change if the account does not contain transactions
if (account.getTransactionCount() == 0) {
account.setCurrencyNode(template.getCurrencyNode());
}
result = getAccountDAO().updateAccount(account);
if (result) {
message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY, this);
message.setObject(MessageProperty.ACCOUNT, account);
messageBus.fireEvent(message);
logInfo(rb.getString(MESSAGE_ACCOUNT_MODIFY));
} else {
message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY_FAILED, this);
message.setObject(MessageProperty.ACCOUNT, account);
messageBus.fireEvent(message);
}
/* Check to see if the account needs to be moved */
if (account.parentAccount != template.parentAccount && template.parentAccount != null && result) {
if (!moveAccount(account, template.parentAccount)) {
logWarning(rb.getString("Message.Error.MoveAccount"));
result = false;
}
}
// Force clearing of any budget goals if an empty account has been changed to become a place holder
if (account.isPlaceHolder()) {
purgeBudgetGoal(account);
}
return result;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Purges any {@code BudgetGoal} associated with an account.
*
* @param account {@code Account} to remove all associated budget goal history
*/
private void purgeBudgetGoal(@NotNull final Account account) {
// clear budget history
for (final Budget budget : getBudgetList()) {
budget.removeBudgetGoal(account);
if (!updateBudget(budget)) {
logWarning("Unable to remove account goals from the budget");
}
}
}
/**
* Sets the account number of an account.
*
* @param account account to change
* @param number new account number
*/
public void setAccountNumber(final Account account, final String number) {
dataLock.writeLock().lock();
try {
account.setAccountNumber(number);
getAccountDAO().updateAccount(account);
Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY, this);
message.setObject(MessageProperty.ACCOUNT, account);
messageBus.fireEvent(message);
logInfo(rb.getString(MESSAGE_ACCOUNT_MODIFY));
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Sets an attribute for an {@code Account}. The key and values are string based
*
* @param account {@code Account} to add or update an attribute
* @param key the key for the attribute
* @param value the value of the attribute
*/
public void setAccountAttribute(final Account account, @NotNull final String key, @Nullable final String value) {
// Throw an error if the value exceeds the maximum length
if (value != null && value.length() > Account.MAX_ATTRIBUTE_LENGTH) {
Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_MODIFY_FAILED, this);
message.setObject(MessageProperty.ACCOUNT, account);
messageBus.fireEvent(message);
logInfo("The maximum length of the attribute was exceeded");
return;
}
dataLock.writeLock().lock();
try {
account.setAttribute(key, value);
getAccountDAO().updateAccount(account);
Message message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_ATTRIBUTE_MODIFY, this);
message.setObject(MessageProperty.ACCOUNT, account);
messageBus.fireEvent(message);
logInfo(rb.getString(MESSAGE_ACCOUNT_MODIFY));
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Returns an {@code Account} attribute.
*
* @param account account to extract attribute from
* @param key the attribute key
* @return the attribute if found
* @see #setAccountAttribute
*/
public static String getAccountAttribute(@NotNull final Account account, @NotNull final String key) {
return account.getAttribute(key);
}
/**
* Removes an existing account given it's ID.
*
* @param account The account to remove
* @return true if successful
*/
public boolean removeAccount(final Account account) {
dataLock.writeLock().lock();
try {
boolean result = false;
if (account.getTransactionCount() == 0 && account.getChildCount() == 0) {
Account parent = account.getParent();
if (parent != null) {
result = parent.removeChild(account);
if (result) {
getAccountDAO().updateAccount(parent);
// clear budget history
purgeBudgetGoal(account);
}
}
moveObjectToTrash(account);
}
Message message;
if (result) {
message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_REMOVE, this);
message.setObject(MessageProperty.ACCOUNT, account);
messageBus.fireEvent(message);
logInfo(rb.getString("Message.AccountRemove"));
} else {
message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_REMOVE_FAILED, this);
message.setObject(MessageProperty.ACCOUNT, account);
messageBus.fireEvent(message);
}
return result;
} finally {
dataLock.writeLock().unlock();
}
}
public Future getAttachment(final String attachment) {
return attachmentManager.getAttachment(attachment);
}
public boolean addAttachment(final Path path, final boolean copy) {
boolean result = false;
try {
result = attachmentManager.addAttachment(path, copy);
} catch (IOException e) {
logSevere(e.getLocalizedMessage());
}
return result;
}
public boolean removeAttachment(final String attachment) {
return attachmentManager.removeAttachment(attachment);
}
/**
* Sets the amortize object of an account.
*
* @param account The Liability account to change
* @param amortizeObject the new AmortizeObject
* @return true if successful
*/
public boolean setAmortizeObject(final Account account, final AmortizeObject amortizeObject) {
dataLock.writeLock().lock();
try {
if (account != null && amortizeObject != null && account.getAccountType() == AccountType.LIABILITY) {
account.setAmortizeObject(amortizeObject);
if (!getAccountDAO().updateAccount(account)) {
logSevere("Was not able to save the amortize object");
}
return true;
}
return false;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Toggles the visibility of an account given its ID.
*
* @param account The account to toggle visibility
*/
public void toggleAccountVisibility(final Account account) {
dataLock.writeLock().lock();
try {
Message message;
account.setVisible(!account.isVisible());
if (getAccountDAO().toggleAccountVisibility(account)) {
message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_VISIBILITY_CHANGE, this);
} else {
message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_VISIBILITY_CHANGE_FAILED, this);
}
message.setObject(MessageProperty.ACCOUNT, account);
messageBus.fireEvent(message);
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Adds a SecurityNode from a InvestmentAccount.
*
* @param account destination account
* @param node SecurityNode to add
* @return true if add was successful
*/
public boolean addAccountSecurity(final Account account, final SecurityNode node) {
dataLock.writeLock().lock();
try {
Message message;
boolean result = account.addSecurity(node);
if (result) {
result = getAccountDAO().addAccountSecurity(account, node);
}
if (result) {
message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_SECURITY_ADD, this);
} else {
message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_SECURITY_ADD_FAILED, this);
}
message.setObject(MessageProperty.ACCOUNT, account);
message.setObject(MessageProperty.COMMODITY, node);
messageBus.fireEvent(message);
return result;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Removes a SecurityNode from an InvestmentAccount.
*
* @param account Account to remove SecurityNode from
* @param node SecurityNode to remove
* @return true if successful
*/
private boolean removeAccountSecurity(final Account account, final SecurityNode node) {
Objects.requireNonNull(node);
dataLock.writeLock().lock();
try {
Message message;
boolean result = account.removeSecurity(node);
if (result) {
getAccountDAO().updateAccount(account);
}
if (result) {
message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_SECURITY_REMOVE, this);
} else {
message = new Message(MessageChannel.ACCOUNT, ChannelEvent.ACCOUNT_SECURITY_REMOVE_FAILED, this);
}
message.setObject(MessageProperty.ACCOUNT, account);
message.setObject(MessageProperty.COMMODITY, node);
messageBus.fireEvent(message);
return result;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Update an account's securities list. This compares the old list of securities and the supplied list and adds or
* removes securities to make sure the lists are the same.
*
* @param acc Destination account
* @param list Collection of SecurityNodes
* @return true if successful
*/
public boolean updateAccountSecurities(final Account acc, final Collection list) {
boolean result = true;
if (acc.memberOf(AccountGroup.INVEST)) {
dataLock.writeLock().lock();
try {
final Collection oldList = acc.getSecurities();
for (SecurityNode node : oldList) {
if (!list.contains(node)) {
if (!removeAccountSecurity(acc, node)) {
logWarning(ResourceUtils.getString("Message.Error.SecurityAccountRemove", node.toString(),
acc.getName()));
result = false;
}
}
}
for (SecurityNode node : list) {
if (!oldList.contains(node)) {
if (!addAccountSecurity(acc, node)) {
logWarning(ResourceUtils.getString("Message.Error.SecurityAccountRemove", node.toString(),
acc.getName()));
result = false;
}
}
}
} finally {
dataLock.writeLock().unlock();
}
}
return result;
}
public boolean addBudget(final Budget budget) {
boolean result;
dataLock.writeLock().lock();
try {
Message message;
result = getBudgetDAO().add(budget);
if (result) {
message = new Message(MessageChannel.BUDGET, ChannelEvent.BUDGET_ADD, this);
} else {
message = new Message(MessageChannel.BUDGET, ChannelEvent.BUDGET_ADD_FAILED, this);
}
message.setObject(MessageProperty.BUDGET, budget);
messageBus.fireEvent(message);
return result;
} finally {
dataLock.writeLock().unlock();
}
}
public boolean removeBudget(final Budget budget) {
boolean result = false;
dataLock.writeLock().lock();
try {
moveObjectToTrash(budget);
Message message = new Message(MessageChannel.BUDGET, ChannelEvent.BUDGET_REMOVE, this);
message.setObject(MessageProperty.BUDGET, budget);
messageBus.fireEvent(message);
result = true;
} catch (final Exception ex) {
logger.log(Level.SEVERE, ex.getLocalizedMessage(), ex);
} finally {
dataLock.writeLock().unlock();
}
return result;
}
public void updateBudgetGoals(final Budget budget, final Account account, final BudgetGoal newGoals) {
dataLock.writeLock().lock();
try {
BudgetGoal oldGoals = budget.getBudgetGoal(account);
budget.setBudgetGoal(account, newGoals);
moveObjectToTrash(oldGoals); // need to keep the old goal around, will be cleaned up later, orphan removal causes refresh issues
updateBudgetGoals(budget, account);
} finally {
dataLock.writeLock().unlock();
}
}
private void updateBudgetGoals(final Budget budget, final Account account) {
dataLock.writeLock().lock();
try {
Message message;
boolean result = getBudgetDAO().update(budget);
if (result) {
message = new Message(MessageChannel.BUDGET, ChannelEvent.BUDGET_GOAL_UPDATE, this);
} else {
message = new Message(MessageChannel.BUDGET, ChannelEvent.BUDGET_GOAL_UPDATE_FAILED, this);
}
message.setObject(MessageProperty.BUDGET, budget);
message.setObject(MessageProperty.ACCOUNT, account);
messageBus.fireEvent(message);
logger.log(Level.FINE, "Budget goal updated for {0}", account.getPathName());
} finally {
dataLock.writeLock().unlock();
}
}
public boolean updateBudget(final Budget budget) {
boolean result;
dataLock.writeLock().lock();
try {
Message message;
result = getBudgetDAO().update(budget);
if (result) {
message = new Message(MessageChannel.BUDGET, ChannelEvent.BUDGET_UPDATE, this);
} else {
message = new Message(MessageChannel.BUDGET, ChannelEvent.BUDGET_UPDATE_FAILED, this);
}
message.setObject(MessageProperty.BUDGET, budget);
messageBus.fireEvent(message);
logger.log(Level.FINE, "Budget updated");
return result;
} finally {
dataLock.writeLock().unlock();
}
}
public List getBudgetList() {
dataLock.readLock().lock();
try {
return getBudgetDAO().getBudgets();
} finally {
dataLock.readLock().unlock();
}
}
public Budget getBudgetByUuid(final UUID uuid) {
return getBudgetDAO().getBudgetByUuid(uuid);
}
public boolean isTransactionValid(final Transaction transaction) {
for (final Account a : transaction.getAccounts()) {
if (a.isLocked()) {
logWarning(rb.getString("Message.TransactionAccountLocked"));
return false;
}
}
if (transaction.isMarkedForRemoval()) {
logger.log(Level.SEVERE, "Transaction already marked for removal", new Throwable());
return false;
}
if (eDAO.getObjectByUuid(Transaction.class, transaction.getUuid()) != null) {
logger.log(Level.WARNING, "Transaction UUID was not unique");
return false;
}
if (transaction.size() < 1) {
logger.log(Level.WARNING, "Invalid Transaction");
return false;
}
for (final TransactionEntry e : transaction.getTransactionEntries()) {
if (e == null) {
logger.log(Level.WARNING, "Null TransactionEntry");
return false;
}
}
for (final TransactionEntry e : transaction.getTransactionEntries()) {
if (e.getTransactionTag() == null) {
logger.log(Level.WARNING, "Null TransactionTag");
return false;
}
}
for (final TransactionEntry e : transaction.getTransactionEntries()) {
if (e.getCreditAccount() == null) {
logger.log(Level.WARNING, "Null Credit Account");
return false;
}
if (e.getDebitAccount() == null) {
logger.log(Level.WARNING, "Null Debit Account");
return false;
}
if (e.getCreditAmount() == null) {
logger.log(Level.WARNING, "Null Credit Amount");
return false;
}
if (e.getDebitAmount() == null) {
logger.log(Level.WARNING, "Null Debit Amount");
return false;
}
}
if (transaction.getTransactionType() == TransactionType.SPLITENTRY && transaction.getCommonAccount() == null) {
logger.log(Level.WARNING, "Entries do not share a common account");
return false;
}
if (transaction instanceof InvestmentTransaction) {
final InvestmentTransaction investmentTransaction = (InvestmentTransaction) transaction;
if (!investmentTransaction.getInvestmentAccount().containsSecurity(investmentTransaction.getSecurityNode())) {
logger.log(Level.WARNING, "Investment Account is missing the security");
return false;
}
}
return transaction.getTransactionType() != TransactionType.INVALID;
}
/**
* Determine if a StoredObject is persisted in the database.
*
* @param object StoredObject to check
* @return true if persisted
*/
public boolean isStored(final StoredObject object) {
return eDAO.getObjectByUuid(StoredObject.class, object.getUuid()) != null;
}
public boolean addTransaction(final Transaction transaction) {
dataLock.writeLock().lock();
try {
boolean result = isTransactionValid(transaction);
if (result) {
/* Add the transaction to each account */
transaction.getAccounts().stream()
.filter(account -> !account.addTransaction(transaction))
.forEach(account -> logSevere("Failed to add the Transaction"));
result = getTransactionDAO().addTransaction(transaction);
logInfo(rb.getString("Message.TransactionAdd"));
/* If successful, extract and enter a default exchange rate for the transaction date if a rate has not been set */
if (result) {
// no rate for the date has been set
transaction.getTransactionEntries().stream()
.filter(TransactionEntry::isMultiCurrency)
.forEach(entry -> {
final ExchangeRate rate = getExchangeRate(entry.getDebitAccount().getCurrencyNode(),
entry.getCreditAccount().getCurrencyNode());
if (rate.getRate(transaction.getLocalDate()).compareTo(BigDecimal.ZERO) == 0) { // no rate for the date has been set
final BigDecimal exchangeRate = entry.getDebitAmount().abs()
.divide(entry.getCreditAmount().abs(),
MathConstants.mathContext);
setExchangeRate(entry.getCreditAccount().getCurrencyNode(),
entry.getDebitAccount().getCurrencyNode(), exchangeRate, transaction.getLocalDate());
}
});
}
}
postTransactionAdd(transaction, result);
return result;
} finally {
dataLock.writeLock().unlock();
}
}
public boolean removeTransaction(final Transaction transaction) {
dataLock.writeLock().lock();
try {
for (final Account account : transaction.getAccounts()) {
if (account.isLocked()) {
logWarning(rb.getString("Message.TransactionRemoveLocked"));
return false;
}
}
/* Remove the transaction from each account */
transaction.getAccounts().stream()
.filter(account -> !account.removeTransaction(transaction))
.forEach(account -> logSevere("Failed to remove the Transaction"));
logInfo(rb.getString("Message.TransactionRemove"));
boolean result = getTransactionDAO().removeTransaction(transaction);
// move transactions into the trash
if (result) {
moveObjectToTrash(transaction);
}
postTransactionRemove(transaction, result);
return result;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Changes the reconciled state of a transaction.
*
* @param transaction transaction to change
* @param account account to change state for
* @param state new reconciled state
*/
public void setTransactionReconciled(final Transaction transaction, final Account account, final ReconciledState state) {
dataLock.writeLock().lock(); // hold a write lock to ensure nothing slips in between the remove and add
try {
final Transaction newTransaction = (Transaction) transaction.clone();
ReconcileManager.reconcileTransaction(account, newTransaction, state);
if (removeTransaction(transaction)) {
addTransaction(newTransaction);
}
} catch (final CloneNotSupportedException e) {
logger.log(Level.SEVERE, "Failed to reconcile the Transaction", e);
} finally {
dataLock.writeLock().unlock();
}
}
public List getTransactionNumberList() {
dataLock.readLock().lock();
try {
return getConfig().getTransactionNumberList();
} finally {
dataLock.readLock().unlock();
}
}
public void setTransactionNumberList(final List list) {
dataLock.writeLock().lock();
try {
final Config transactionConfig = getConfig();
transactionConfig.setTransactionNumberList(list);
getConfigDAO().update(transactionConfig);
Message message = new Message(MessageChannel.CONFIG, ChannelEvent.CONFIG_MODIFY, this);
message.setObject(MessageProperty.CONFIG, transactionConfig);
messageBus.fireEvent(message);
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Get all transactions.
*
* @return List of transactions that may be altered without concern of side effects
*/
public List getTransactions() {
return getTransactionDAO().getTransactions();
}
/**
* Returns a list of transactions with external links.
*
* @return List of transactions that may be altered without concern of side effects
*/
public List getTransactionsWithAttachments() {
return getTransactionDAO().getTransactionsWithAttachments();
}
public Transaction getTransactionByUuid(final UUID uuid) {
return getTransactionDAO().getTransactionByUuid(uuid);
}
private void postTransactionAdd(final Transaction transaction, final boolean result) {
for (Account a : transaction.getAccounts()) {
Message message;
if (result) {
message = new Message(MessageChannel.TRANSACTION, ChannelEvent.TRANSACTION_ADD, this);
} else {
message = new Message(MessageChannel.TRANSACTION, ChannelEvent.TRANSACTION_ADD_FAILED, this);
}
message.setObject(MessageProperty.ACCOUNT, a);
message.setObject(MessageProperty.TRANSACTION, transaction);
messageBus.fireEvent(message);
}
}
private void postTransactionRemove(final Transaction transaction, final boolean result) {
for (Account a : transaction.getAccounts()) {
Message message;
if (result) {
message = new Message(MessageChannel.TRANSACTION, ChannelEvent.TRANSACTION_REMOVE, this);
} else {
message = new Message(MessageChannel.TRANSACTION, ChannelEvent.TRANSACTION_REMOVE_FAILED, this);
}
message.setObject(MessageProperty.ACCOUNT, a);
message.setObject(MessageProperty.TRANSACTION, transaction);
messageBus.fireEvent(message);
}
}
/**
* Adds a new Tag
*
* @param tag Tag to add
* @return true is successful
*/
public boolean addTag(@NotNull Tag tag) {
Objects.requireNonNull(tag);
dataLock.writeLock().lock();
try {
boolean result = eDAO.getTagDAO().add(tag);
final Message message = new Message(MessageChannel.TAG, result ? ChannelEvent.TAG_ADD
: ChannelEvent.TAG_ADD_FAILED, this);
message.setObject(MessageProperty.TAG, tag);
messageBus.fireEvent(message);
return result;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Updates an existing Tag
*
* @param tag Tag to update
* @return true is successful
*/
public boolean updateTag(@NotNull Tag tag) {
Objects.requireNonNull(tag);
dataLock.writeLock().lock();
try {
boolean result = eDAO.getTagDAO().update(tag);
final Message message = new Message(MessageChannel.TAG, result ? ChannelEvent.TAG_MODIFY
: ChannelEvent.TAG_MODIFY_FAILED, this);
message.setObject(MessageProperty.TAG, tag);
messageBus.fireEvent(message);
return result;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Retrieves all Tags
*
* @return a Set of Tags.
*/
@NotNull
public Set getTags() {
dataLock.readLock().lock();
try {
return eDAO.getTagDAO().getTags();
} finally {
dataLock.readLock().unlock();
}
}
/**
* Retrieves the Tags that are in use
*
* @return a Set of Tags.
*/
@NotNull
public Set getTagsInUse() {
dataLock.readLock().lock();
try {
return getTransactions()
.parallelStream()
.flatMap((Function>) transaction -> transaction.getTags().stream())
.collect(Collectors.toSet());
} finally {
dataLock.readLock().unlock();
}
}
/**
* Removes a Tag from the database.
*
* The removal will fail if the Tag is in use.
*
* @param tag Tag to remove
* @return true if successful
*/
public boolean removeTag(@NotNull Tag tag) {
Objects.requireNonNull(tag);
dataLock.writeLock().lock();
try {
boolean result = !getTagsInUse().contains(tag); // make sure the tag is not used
if (result) {
result = moveObjectToTrash(tag);
}
final Message message = new Message(MessageChannel.TAG, result ? ChannelEvent.TAG_REMOVE
: ChannelEvent.TAG_REMOVE_FAILED, this);
message.setObject(MessageProperty.TAG, tag);
messageBus.fireEvent(message);
return result;
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Returns the unique identifier for this engine instance.
*
* @return uuid
*/
public String getUuid() {
return uuid;
}
public void setPreference(@NotNull final String key, @Nullable final String value) {
dataLock.writeLock().lock();
try {
getConfig().setPreference(key, value);
getConfigDAO().update(getConfig());
config = null; // clear stale cached reference
Message message = new Message(MessageChannel.CONFIG, ChannelEvent.CONFIG_MODIFY, this);
message.setObject(MessageProperty.CONFIG, getConfig());
messageBus.fireEvent(message);
} finally {
dataLock.writeLock().unlock();
}
}
@Nullable
public String getPreference(@NotNull final String key) {
dataLock.readLock().lock();
try {
return getConfig().getPreference(key);
} finally {
dataLock.readLock().unlock();
}
}
public boolean getBoolean(@NotNull final String key, final boolean defaultValue) {
boolean value = defaultValue;
final String stringResult = getPreference(key);
if (stringResult != null && !stringResult.isEmpty()) {
value = Boolean.parseBoolean(stringResult);
}
return value;
}
public void putBoolean(@NotNull final String key, final boolean value) {
setPreference(key, Boolean.toString(value));
}
public boolean createBackups() {
return getConfig().createBackups();
}
public void setCreateBackups(final boolean createBackups) {
dataLock.writeLock().lock();
try {
final Config backupConfig = getConfig();
backupConfig.setCreateBackups(createBackups);
getConfigDAO().update(backupConfig);
config = null; // clear stale cached reference
Message message = new Message(MessageChannel.CONFIG, ChannelEvent.CONFIG_MODIFY, this);
message.setObject(MessageProperty.CONFIG, backupConfig);
messageBus.fireEvent(message);
} finally {
dataLock.writeLock().unlock();
}
}
public int getRetainedBackupLimit() {
return getConfig().getRetainedBackupLimit();
}
public void setRetainedBackupLimit(final int retainedBackupLimit) {
dataLock.writeLock().lock();
try {
final Config backupConfig = getConfig();
backupConfig.setRetainedBackupLimit(retainedBackupLimit);
getConfigDAO().update(backupConfig);
config = null; // clear stale cached reference
Message message = new Message(MessageChannel.CONFIG, ChannelEvent.CONFIG_MODIFY, this);
message.setObject(MessageProperty.CONFIG, backupConfig);
messageBus.fireEvent(message);
} finally {
dataLock.writeLock().unlock();
}
}
public boolean removeOldBackups() {
return getConfig().removeOldBackups();
}
public void setRemoveOldBackups(final boolean removeOldBackups) {
dataLock.writeLock().lock();
try {
final Config backupConfig = getConfig();
backupConfig.setRemoveOldBackups(removeOldBackups);
getConfigDAO().update(backupConfig);
config = null; // clear stale cached reference
Message message = new Message(MessageChannel.CONFIG, ChannelEvent.CONFIG_MODIFY, this);
message.setObject(MessageProperty.CONFIG, backupConfig);
messageBus.fireEvent(message);
} finally {
dataLock.writeLock().unlock();
}
}
/**
* Handles Background removal of {@code SecurityNode} history. This can an expensive operation that block normal
* operations, so the removal is partitioned into small events to prevent stalling.
*
* @param securityNode SecurityNode being processed
* @param daysOfWeek Collection of {code DayOfWeek} to remove
*/
public void removeSecurityHistoryByDayOfWeek(final SecurityNode securityNode, final Collection daysOfWeek) {
final Thread thread = new Thread(() -> {
long delay = 0;
for (final SecurityHistoryNode historyNode : new ArrayList<>(securityNode.getHistoryNodes())) {
for (final DayOfWeek dayOfWeek : daysOfWeek) {
if (historyNode.getLocalDate().getDayOfWeek() == dayOfWeek) {
backgroundExecutorService.schedule(new BackgroundCallable(() -> {
removeSecurityHistory(securityNode, historyNode.getLocalDate());
return true;
}), delay, TimeUnit.MILLISECONDS);
delay += 750;
}
}
}
});
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
thread.start();
}
/**
* Thread to monitor background update of securities and terminate is network errors are occurring
*/
private class SecuritiesUpdateRunnable extends Thread {
private final List backgroundCallables;
private final int delay;
SecuritiesUpdateRunnable(final List callables, final int delay) {
this.backgroundCallables = callables;
this.delay = delay;
}
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(delay); // for controlled delay at startup
} catch (final InterruptedException ignored) {
return;
}
int errors = 0;
final CompletionService completionService = new ExecutorCompletionService<>(backgroundExecutorService);
// submit the callables
for (final BackgroundCallable backgroundCallable : backgroundCallables) {
try {
completionService.submit(backgroundCallable);
} catch (final RejectedExecutionException ignored) {
// ignore, race to shut down the executor was won
}
}
// poll until complete or there have been too many errors
while (errors < MAX_ERRORS && !Thread.currentThread().isInterrupted()) {
try {
final Future future = completionService.poll(1, TimeUnit.MINUTES);
if (future == null) { // all done, no issues
break;
}
errors += future.get() ? 0 : 1;
} catch (final InterruptedException | ExecutionException e) {
errors = Integer.MAX_VALUE;
logger.log(Level.SEVERE, e.getLocalizedMessage(), e);
}
}
// if there are too many errors, force cancellation
if (errors > MAX_ERRORS || Thread.currentThread().isInterrupted()) {
for (final BackgroundCallable backgroundCallable : backgroundCallables) {
backgroundCallable.cancel = true; // stop all other callables
}
}
}
}
/**
* Decorates a Callable to indicate background engine activity is occurring.
*/
private class BackgroundCallable implements Callable {
private final Callable callable;
volatile boolean cancel = false; // may be set to true to interrupt operation
BackgroundCallable(@NotNull final Callable callable) {
this.callable = callable;
}
@Override
public Boolean call() throws Exception {
if (!cancel) {
if (backGroundCounter.incrementAndGet() == 1) {
messageBus.fireEvent(new Message(MessageChannel.SYSTEM, ChannelEvent.BACKGROUND_PROCESS_STARTED,
Engine.this));
}
try {
return callable.call();
} finally {
if (backGroundCounter.decrementAndGet() == 0) {
messageBus.fireEvent(new Message(MessageChannel.SYSTEM, ChannelEvent.BACKGROUND_PROCESS_STOPPED,
Engine.this));
}
}
}
return false;
}
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/EngineException.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
/**
* Unchecked Engine Exception
*/
public class EngineException extends RuntimeException {
public EngineException(final String message) {
super(message);
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/EngineFactory.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.DoubleConsumer;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.prefs.Preferences;
import jgnash.engine.jpa.JpaNetworkServer;
import jgnash.engine.jpa.SqlUtils;
import jgnash.engine.message.ChannelEvent;
import jgnash.engine.message.Message;
import jgnash.engine.message.MessageBus;
import jgnash.engine.message.MessageChannel;
import jgnash.engine.xstream.BinaryXStreamDataStore;
import jgnash.engine.xstream.XMLDataStore;
import jgnash.resource.util.OS;
import jgnash.resource.util.ResourceUtils;
import jgnash.util.FileMagic;
import jgnash.util.FileMagic.FileType;
import jgnash.util.FileUtils;
import jgnash.util.Nullable;
/**
* Factory class for obtaining an engine instance.
*
* The filename of the database or remote server must be explicitly set before an Engine instance will be returned
*
* @author Craig Cavanaugh
*/
public class EngineFactory {
public static final char[] EMPTY_PASSWORD = new char[]{};
public static final String LOCALHOST = "localhost";
public static final String DEFAULT = "default";
public static final String REMOTE_PREFIX = "@";
private static final String LAST_DATABASE = "LastDatabase";
private static final String LAST_HOST = "LastHost";
private static final String LAST_PORT = "LastPort";
private static final String USED_PASSWORD = "LastUsedPassword";
private static final String LAST_REMOTE = "LastRemote";
/**
* Default directory for jGnash data. To be located in the default user
* directory
*/
private static final String DEFAULT_DIR = "jGnash";
private static final Logger logger = Logger.getLogger(EngineFactory.class.getName());
private static final Map engineMap = new HashMap<>();
private static final Map dataStoreMap = new HashMap<>();
private EngineFactory() {
}
/**
* Registers a {@code Handler} with the class logger.
* This also ensures the static logger is initialized.
*
* @param handler {@code Handler} to register
*/
public static void addLogHandler(final Handler handler) {
logger.addHandler(handler);
}
public static boolean doesDatabaseExist(final String database, final DataStoreType type) {
if (FileUtils.fileHasExtension(database)) {
return Files.isReadable(Paths.get(database));
}
return Files.isReadable(Paths.get(database + type.getDataStore().getFileExt()));
}
public static boolean deleteDatabase(final String database) {
try {
return Files.deleteIfExists(Paths.get(database));
} catch (final IOException e) {
logger.warning(e.getLocalizedMessage());
return false;
}
}
/**
* Returns the engine with the given name.
*
* @param name engine name to look for
* @return returns {@code null} if it does not exist
*/
@Nullable
public static synchronized Engine getEngine(final String name) {
return engineMap.get(name);
}
private static void exportCompressedXML(final String engineName) {
final Engine oldEngine = engineMap.get(engineName);
final DataStore oldDataStore = dataStoreMap.get(engineName);
exportCompressedXML(oldDataStore.getFileName(), oldEngine.getStoredObjects());
}
public static void exportCompressedXML(final String fileName, final Collection objects) {
final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm");
final DataStore xmlDataStore = new XMLDataStore();
Path xmlFile = Paths.get(FileUtils.stripFileExtension(fileName) + "-" + dateTimeFormatter.format(LocalDateTime.now())
+ xmlDataStore.getFileExt());
// push the intermediary file to the temporary directory
xmlFile = Paths.get(System.getProperty("java.io.tmpdir") + xmlFile.getFileSystem().getSeparator()
+ xmlFile.getFileName().toString());
xmlDataStore.saveAs(xmlFile, objects, ignored -> { });
Path zipFile = Paths.get(FileUtils.stripFileExtension(fileName) + "-" + dateTimeFormatter.format(LocalDateTime.now())
+ ".zip");
FileUtils.compressFile(xmlFile, zipFile);
try {
Files.delete(xmlFile);
} catch (final IOException e) {
logger.log(Level.SEVERE, e.getLocalizedMessage(), e);
logger.log(Level.WARNING, "Was not able to delete the temporary file: {0}", xmlFile);
}
}
public static void removeOldCompressedXML(final String fileName, final int limit) {
final Path path = Paths.get(fileName);
String baseFile = FileUtils.stripFileExtension(path.toString());
// '\' on Windows platform must be replaced with '\\' to prevent an exception
if (OS.isSystemWindows()) {
baseFile = baseFile.replace("\\","\\\\");
}
// old files use the base file name plus a '-' and a 8 digit date plus a '-' and a 4 digit time stamp
final List fileList = FileUtils.getDirectoryListing(path.getParent(), baseFile + "-\\d{8}-\\d{4}.zip");
if (fileList.size() > limit) {
for (int i = 0; i < fileList.size() - limit; i++) {
try {
Files.delete(fileList.get(i));
} catch (final IOException e) {
logger.log(Level.WARNING, "Unable to delete the file: {0}", fileList.get(i));
}
}
}
}
public static synchronized void closeEngine(final String engineName) {
Engine oldEngine = engineMap.get(engineName);
DataStore oldDataStore = dataStoreMap.get(engineName);
if (oldEngine != null) {
// stop and wait for all working background services to complete
oldEngine.stopBackgroundServices();
// Post a message so the GUI knows what is going on
final Message message = new Message(MessageChannel.SYSTEM, ChannelEvent.FILE_CLOSING, oldEngine);
MessageBus.getInstance(engineName).fireBlockingEvent(message); // block until event has been completely processed
if (oldEngine.isFileDirty()) { // should a backup file be created?
// Dump an XML backup
if (oldEngine.createBackups() && oldDataStore.isLocal()) {
exportCompressedXML(engineName);
}
// Purge old backups
if (oldEngine.removeOldBackups() && oldDataStore.isLocal()) {
removeOldCompressedXML(oldDataStore.getFileName(), oldEngine.getRetainedBackupLimit());
}
} else {
logger.info("File was not dirty");
}
// Initiate a complete shutdown
oldEngine.shutdown();
MessageBus.getInstance(engineName).setLocal();
oldDataStore.closeEngine();
engineMap.remove(engineName);
dataStoreMap.remove(engineName);
}
}
/**
* Boots a local Engine for a preexisting file. The API determines the
* correct file type and uses the correct DataStoreType for engine
* initialization. If successful, a new
* {@code Engine} instance will be returned.
*
* @param fileName filename to load
* @param engineName engine identifier
* @param password connection password
* @return new {@code Engine} instance if successful, null otherwise
* @see Engine
*/
public static synchronized Engine bootLocalEngine(final String fileName, final String engineName,
final char[] password) {
final DataStoreType type = getDataStoreByType(fileName);
Engine engine = null;
if (type != null) {
engine = bootLocalEngine(fileName, engineName, password, type);
}
return engine;
}
/**
* Boots a local Engine for a file. If the file does not exist, it will be created. Otherwise it will be loaded.
* If successful, a new {@code Engine} instance will be returned.
*
* @param fileName filename to load or create
* @param engineName engine identifier
* @param password password for the file
* @param type {@code DataStoreType} type to use for storage
* @return new {@code Engine} instance if successful
* @see Engine
* @see DataStoreType
*/
public static synchronized Engine bootLocalEngine(final String fileName, final String engineName,
final char[] password, final DataStoreType type) {
Instant start = Instant.now();
MessageBus.getInstance(engineName).setLocal();
final DataStore dataStore = type.getDataStore();
final Engine engine = dataStore.getLocalEngine(fileName, engineName, password);
if (engine != null) {
logger.info(ResourceUtils.getString("Message.EngineStart"));
engineMap.put(engineName, engine);
dataStoreMap.put(engineName, dataStore);
Message message = new Message(MessageChannel.SYSTEM, ChannelEvent.FILE_LOAD_SUCCESS, engine);
MessageBus.getInstance(engineName).fireEvent(message);
if (engineName.equals(EngineFactory.DEFAULT)) {
Preferences pref = Preferences.userNodeForPackage(EngineFactory.class);
pref.putBoolean(USED_PASSWORD, password.length > 0);
pref.put(LAST_DATABASE, fileName);
pref.putBoolean(LAST_REMOTE, false);
}
logger.log(Level.INFO, "Boot time was {0} milliseconds",
ChronoUnit.MILLIS.between(start, Instant.now()));
}
return engine;
}
public static synchronized Engine bootClientEngine(final String host, final int port, final char[] password,
final String engineName) {
if (engineMap.get(engineName) != null) {
throw new EngineException("A stale engine was found in the map");
}
final Preferences pref = Preferences.userNodeForPackage(EngineFactory.class);
Engine engine = null;
// start the client message bus
if (MessageBus.getInstance(engineName).setRemote(host, port + JpaNetworkServer.MESSAGE_SERVER_INCREMENT,
password)) {
pref.putInt(LAST_PORT, port);
pref.put(LAST_HOST, host);
pref.putBoolean(LAST_REMOTE, true);
final MessageBus messageBus = MessageBus.getInstance(engineName);
// after starting the remote message bus, it should receive the path on the server
final String remoteDataBasePath = messageBus.getRemoteDataBasePath();
final DataStoreType dataStoreType = messageBus.getRemoteDataStoreType();
if (remoteDataBasePath == null || remoteDataBasePath.isEmpty() || dataStoreType == null) {
throw new EngineException("Invalid connection wih the message bus");
}
logger.log(Level.INFO, "Remote path was {0}", remoteDataBasePath);
logger.log(Level.INFO, "Remote data store was {0}", dataStoreType.name());
logger.log(Level.INFO, "Engine name was {0}", engineName);
DataStore dataStore = dataStoreType.getDataStore();
// connect to the remote server
engine = dataStore.getClientEngine(host, port, password, remoteDataBasePath);
if (engine != null) {
logger.info(ResourceUtils.getString("Message.EngineStart"));
engineMap.put(engineName, engine);
dataStoreMap.put(engineName, dataStore);
// remember if the user used a password for the last session
pref.putBoolean(USED_PASSWORD, password.length > 0);
final Message message = new Message(MessageChannel.SYSTEM, ChannelEvent.FILE_LOAD_SUCCESS, engine);
MessageBus.getInstance(engineName).fireEvent(message);
}
}
return engine;
}
private static DataStoreType getDataStoreByType(final Path file) {
final FileType type = FileMagic.magic(file);
switch (type) {
case jGnash2XML:
return DataStoreType.XML;
case BinaryXStream:
return DataStoreType.BINARY_XSTREAM;
case h2:
return DataStoreType.H2_DATABASE;
case h2mv:
return DataStoreType.H2MV_DATABASE;
case hsql:
return DataStoreType.HSQL_DATABASE;
default:
break;
}
return null;
}
public static DataStoreType getDataStoreByType(final String fileName) {
return getDataStoreByType(Paths.get(fileName));
}
public static float getFileVersion(final Path file, final char[] password) {
float version = 0;
final FileType type = FileMagic.magic(file);
switch (type) {
case jGnash2XML:
version = XMLDataStore.getFileVersion(file);
break;
case BinaryXStream:
version = BinaryXStreamDataStore.getFileVersion(file);
break;
case h2:
case h2mv:
case hsql:
try {
version = SqlUtils.getFileVersion(file.toString(), password);
} catch (final Exception e) {
version = 0;
}
break;
default:
break;
}
return version;
}
/**
* Returns the default path to the database without a file extension.
*
* @return Default path to the database
*/
public static synchronized String getDefaultDatabase() {
final String base = System.getProperty("user.home");
final String userName = System.getProperty("user.name");
return base + FileUtils.SEPARATOR + DEFAULT_DIR + FileUtils.SEPARATOR + userName;
}
/**
* Returns the last open database. If a database has not been opened, then
* the default database will be returned.
*
* @return Last open or default database
*/
public static synchronized String getLastDatabase() {
final Preferences pref = Preferences.userNodeForPackage(EngineFactory.class);
return pref.get(LAST_DATABASE, getDefaultDatabase());
}
public static synchronized String getActiveDatabase() {
if (getLastRemote()) {
return REMOTE_PREFIX + getLastHost();
}
return getLastDatabase();
}
/**
* Returns the host of the last remote database connection. If a remote
* database has not been opened, then the default host will be returned.
*
* @return Last remote database host or default
*/
public static synchronized String getLastHost() {
final Preferences pref = Preferences.userNodeForPackage(EngineFactory.class);
return pref.get(LAST_HOST, EngineFactory.LOCALHOST);
}
/**
* Returns the port of the last remote database connection. If a remote
* database has not been opened, then the default port will be returned.
*
* @return Last remote database port or default
*/
public static synchronized int getLastPort() {
final Preferences pref = Preferences.userNodeForPackage(EngineFactory.class);
return pref.getInt(LAST_PORT, JpaNetworkServer.DEFAULT_PORT);
}
/**
* Returns true if the last connection was made to a remote host.
*
* @return true if the last connection was made to a remote host
*/
public static synchronized boolean getLastRemote() {
final Preferences pref = Preferences.userNodeForPackage(EngineFactory.class);
return pref.getBoolean(LAST_REMOTE, false);
}
public static synchronized boolean usedPassword() {
final Preferences pref = Preferences.userNodeForPackage(EngineFactory.class);
return pref.getBoolean(USED_PASSWORD, false);
}
/**
* Saves the active database as a new file/format
*
* @param destination new file
* @param percentCompleteConsumer progress consumer
* @throws IOException IO error
*/
public static void saveAs(final String destination, final DoubleConsumer percentCompleteConsumer) throws IOException {
final String fileExtension = "." + FileUtils.getFileExtension(destination);
DataStoreType newFileType = DataStoreType.BINARY_XSTREAM; // default for a new file
if (fileExtension.length() > 1) { // should have more than just the period in it
for (final DataStoreType type : DataStoreType.values()) {
if (type.getDataStore().getFileExt().equalsIgnoreCase(fileExtension)) {
newFileType = type;
break;
}
}
}
final Path newFile = Paths.get(FileUtils.stripFileExtension(destination)
+ newFileType.getDataStore().getFileExt());
final Path current = Paths.get(EngineFactory.getActiveDatabase());
// don't perform the save if the destination is going to overwrite the current database
if (!current.equals(newFile)) {
final DataStoreType currentType = dataStoreMap.get(EngineFactory.DEFAULT).getType();
// Need to create an interim copy when converting a relational database
if (currentType.supportsRemote && newFileType.supportsRemote) {
final Path tempFile = Files.createTempFile("jgnash-tmp", BinaryXStreamDataStore.FILE_EXT);
Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT);
if (engine != null) {
// Get collection of object to persist
Collection objects = engine.getStoredObjects();
// Write everything to a temporary file
DataStoreType.BINARY_XSTREAM.getDataStore().saveAs(tempFile, objects, value -> {
percentCompleteConsumer.accept(value * 0.5); // doing it twice
});
// Close the current file
EngineFactory.closeEngine(EngineFactory.DEFAULT);
// Boot the engine using the temporary file
EngineFactory.bootLocalEngine(tempFile.toString(), EngineFactory.DEFAULT,
EngineFactory.EMPTY_PASSWORD);
engine = EngineFactory.getEngine(EngineFactory.DEFAULT);
if (engine != null) {
// Get collection of object to persist
objects = engine.getStoredObjects();
// Write everything to the new file and close
newFileType.getDataStore().saveAs(newFile, objects,
value -> percentCompleteConsumer.accept(0.5 + value * 0.5));
EngineFactory.closeEngine(EngineFactory.DEFAULT);
percentCompleteConsumer.accept(1);
// Boot the engine with the new file
EngineFactory.bootLocalEngine(newFile.toString(), EngineFactory.DEFAULT,
EngineFactory.EMPTY_PASSWORD);
}
try {
Files.delete(tempFile);
} catch (final IOException ioe) {
Logger.getLogger(EngineFactory.class.getName())
.info(ResourceUtils.getString("Message.Error.RemoveTempFile"));
}
}
} else { // Simple
Engine engine = EngineFactory.getEngine(EngineFactory.DEFAULT);
if (engine != null) {
final Collection objects = engine.getStoredObjects();
newFileType.getDataStore().saveAs(newFile, objects, percentCompleteConsumer);
EngineFactory.closeEngine(EngineFactory.DEFAULT);
EngineFactory.bootLocalEngine(newFile.toString(), EngineFactory.DEFAULT,
EngineFactory.EMPTY_PASSWORD);
}
}
}
}
/**
* Saves a closed database as a new file/format
*
* @param fileName file to save a copy of
* @param newFileName new file
* @param password password
* @param percentCompleteConsumer progress consumer
*
* @throws IOException IO error
*/
public static void saveAs(final String fileName, final String newFileName, final char[] password,
final DoubleConsumer percentCompleteConsumer) throws IOException {
Objects.requireNonNull(fileName);
Objects.requireNonNull(newFileName);
Objects.requireNonNull(password);
final String ENGINE = UUID.randomUUID().toString(); // create a temporary engine ID for utility use only
final String fileExtension = "." + FileUtils.getFileExtension(newFileName);
DataStoreType newFileType = DataStoreType.BINARY_XSTREAM; // default for a new file
// Determine the data store type given the file extension
if (fileExtension.length() > 1) { // should have more than just the period in it
for (final DataStoreType type : DataStoreType.values()) {
if (type.getDataStore().getFileExt().equalsIgnoreCase(fileExtension)) {
newFileType = type;
break;
}
}
}
final Path newFile = Paths.get(FileUtils.stripFileExtension(newFileName)
+ newFileType.getDataStore().getFileExt());
final Path current = Paths.get(fileName);
// don't perform the save if the destination is going to overwrite the current database
if (!current.equals(newFile)) {
// Need to know the data store type for correct behavior
final DataStoreType currentType = EngineFactory.getDataStoreByType(fileName);
Objects.requireNonNull(currentType); // fail if type is null
// Create a utility engine instead of using the default
Engine engine = EngineFactory.bootLocalEngine(fileName, ENGINE, password);
if (currentType.supportsRemote && newFileType.supportsRemote) { // Relational database
final Path tempFile = Files.createTempFile("jgnash", BinaryXStreamDataStore.FILE_EXT);
if (engine != null) {
// Get collection of object to persist
Collection objects = engine.getStoredObjects();
// Write everything to a temporary file
DataStoreType.BINARY_XSTREAM.getDataStore().saveAs(tempFile, objects, value -> {
percentCompleteConsumer.accept(value * 0.5); // doing it twice
});
EngineFactory.closeEngine(ENGINE);
// Boot the engine with the temporary file
engine = EngineFactory.bootLocalEngine(tempFile.toString(), ENGINE, EngineFactory.EMPTY_PASSWORD);
if (engine != null) {
// Get collection of object to persist
objects = engine.getStoredObjects();
// Write everything to the new file
newFileType.getDataStore().saveAs(newFile, objects,
value -> percentCompleteConsumer.accept(0.5 + value * 0.5));
EngineFactory.closeEngine(ENGINE);
// reset the password
SqlUtils.changePassword(newFileName, EngineFactory.EMPTY_PASSWORD, password);
percentCompleteConsumer.accept(1);
}
try {
Files.delete(tempFile);
} catch (final IOException ioe) {
Logger.getLogger(EngineFactory.class.getName())
.info(ResourceUtils.getString("Message.Error.RemoveTempFile"));
}
}
} else { // Simple
if (engine != null) {
final Collection objects = engine.getStoredObjects();
newFileType.getDataStore().saveAs(newFile, objects, percentCompleteConsumer);
EngineFactory.closeEngine(ENGINE);
}
}
}
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/ExchangeRate.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.persistence.CascadeType;
import javax.persistence.Entity;
import javax.persistence.JoinTable;
import javax.persistence.OneToMany;
import javax.persistence.OrderBy;
import javax.persistence.PostLoad;
import jgnash.util.Nullable;
/**
* Exchange rate object.
*
* @author Craig Cavanaugh
*/
@Entity
public class ExchangeRate extends StoredObject {
@JoinTable
@OrderBy("date") //applying a sort order prevents refresh issues
@OneToMany(cascade = {CascadeType.ALL})
private final Set historyNodes = new HashSet<>();
/**
* Cache the last exchange rate.
*/
transient private BigDecimal lastRate;
/**
* Identifier for the ExchangeRate object.
*/
private String rateId;
/**
* ReadWrite lock.
*/
private transient ReadWriteLock lock = new ReentrantReadWriteLock();
/**
* No argument constructor for reflection purposes.
*
* Do not use to create a new instance
*/
@SuppressWarnings("unused")
public ExchangeRate() {
}
ExchangeRate(final String rateId) {
this.rateId = rateId;
}
public boolean contains(final ExchangeRateHistoryNode node) {
lock.readLock().lock();
try {
return historyNodes.contains(node);
} finally {
lock.readLock().unlock();
}
}
public boolean contains(final LocalDate localDate) {
lock.readLock().lock();
boolean result = false;
try {
for (final ExchangeRateHistoryNode node : historyNodes) {
if (localDate.compareTo(node.getLocalDate()) == 0) {
result = true;
break;
}
}
} finally {
lock.readLock().unlock();
}
return result;
}
public List getHistory() {
// return a defensive copy
List nodes = new ArrayList<>(historyNodes);
Collections.sort(nodes);
return nodes;
}
boolean addHistoryNode(final ExchangeRateHistoryNode node) {
boolean result = false;
lock.writeLock().lock();
try {
historyNodes.add(node);
lastRate = null; // force an update
result = true;
} catch (final Exception ex) {
Logger.getLogger(ExchangeRate.class.getName()).log(Level.SEVERE, ex.getLocalizedMessage(), ex);
} finally {
lock.writeLock().unlock();
}
return result;
}
@Nullable
ExchangeRateHistoryNode getHistory(final LocalDate localDate) {
ExchangeRateHistoryNode node = null;
lock.readLock().lock();
try {
for (final ExchangeRateHistoryNode historyNode : historyNodes) {
if (localDate.compareTo(historyNode.getLocalDate()) == 0) {
node = historyNode;
break;
}
}
} finally {
lock.readLock().unlock();
}
return node;
}
boolean removeHistoryNode(final ExchangeRateHistoryNode hNode) {
lock.writeLock().lock();
try {
final boolean result = historyNodes.remove(hNode);
if (result) {
lastRate = null; // force an update
}
return result;
} finally {
lock.writeLock().unlock();
}
}
public String getRateId() {
return rateId;
}
public BigDecimal getRate() {
lock.readLock().lock();
try {
if (lastRate == null) {
if (!historyNodes.isEmpty()) {
List nodes = getHistory();
lastRate = nodes.get(nodes.size() - 1).getRate();
} else {
lastRate = BigDecimal.ONE;
}
}
} finally {
lock.readLock().unlock();
}
return lastRate;
}
/**
* Returns the exchange rate for a given {@code LocalDate}.
*
* If a rate has not be set, {@code BigDecimal.ZERO} is returned
*
* @param localDate {@code LocalDate} for exchange
* @return the exchange rate if known, otherwise {@code BigDecimal.ZERO}
*/
BigDecimal getRate(final LocalDate localDate) {
lock.readLock().lock();
BigDecimal rate = BigDecimal.ZERO;
try {
for (ExchangeRateHistoryNode historyNode : historyNodes) {
if (localDate.equals(historyNode.getLocalDate())) {
rate = historyNode.getRate();
break;
}
}
} finally {
lock.readLock().unlock();
}
return rate;
}
@Override
public boolean equals(final Object other) {
return this == other || other instanceof ExchangeRate && rateId.equals(((ExchangeRate) other).rateId);
}
@Override
public int hashCode() {
return super.hashCode() * 67 + rateId.hashCode();
}
/**
* Required by XStream for proper initialization.
*
* @return Properly initialized ExchangeRate
*/
protected Object readResolve() {
postLoad();
return this;
}
@PostLoad
private void postLoad() {
lock = new ReentrantReadWriteLock(true);
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/ExchangeRateDAO.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import jgnash.engine.dao.CommodityDAO;
/**
* DAO for exchange rate access.
*
* @author Craig Cavanaugh
*
*/
class ExchangeRateDAO {
private final CommodityDAO commodityDAO;
ExchangeRateDAO(final CommodityDAO commodityDAO) {
this.commodityDAO = commodityDAO;
}
ExchangeRate getExchangeRateNode(final CurrencyNode baseCurrency, final CurrencyNode exchangeCurrency) {
if (baseCurrency.equals(exchangeCurrency)) {
return null;
}
final String rateId = Engine.buildExchangeRateId(baseCurrency, exchangeCurrency);
ExchangeRate node = commodityDAO.getExchangeNode(rateId);
if (node == null) {
node = new ExchangeRate(Engine.buildExchangeRateId(baseCurrency, exchangeCurrency));
commodityDAO.addExchangeRate(node);
}
return node;
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/ExchangeRateHistoryNode.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import jgnash.util.NotNull;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.SequenceGenerator;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Objects;
/**
* Exchange rate history node for a {@code ExchangeRate}.
* {@code ExchangeRateHistoryNode} objects are immutable.
*
* @author Craig Cavanaugh
*/
@Entity
@SequenceGenerator(name = "sequence", allocationSize = 10)
public class ExchangeRateHistoryNode implements Comparable, Serializable {
@SuppressWarnings("UnusedDeclaration")
@Id
@GeneratedValue(generator = "sequence", strategy = GenerationType.SEQUENCE)
public long id;
@Column(precision = 20, scale = 8)
private BigDecimal rate = BigDecimal.ZERO;
private LocalDate date = LocalDate.now();
/**
* No argument constructor for reflection purposes.
* Do not use to create a new instance
*/
@SuppressWarnings("unused")
protected ExchangeRateHistoryNode() {
}
/**
* Constructor.
*
* @param localDate date for this history node. The date will be trimmed
* @param rate exchange rate for the given date
*/
ExchangeRateHistoryNode(final LocalDate localDate, final BigDecimal rate) {
Objects.requireNonNull(date);
Objects.requireNonNull(rate);
this.date = localDate;
this.rate = rate;
}
public LocalDate getLocalDate() {
return date;
}
@Override
public int compareTo(@NotNull final ExchangeRateHistoryNode node) {
return getLocalDate().compareTo(node.getLocalDate());
}
@Override
public boolean equals(final Object o) {
return this == o || o instanceof ExchangeRateHistoryNode
&& date.equals(((ExchangeRateHistoryNode) o).date);
}
@Override
public int hashCode() {
return date.hashCode();
}
public BigDecimal getRate() {
return rate;
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/InvestmentAccountProxy.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.locks.Lock;
/**
* Investment Account Proxy class.
*
* @author Craig Cavanaugh
*/
class InvestmentAccountProxy extends AccountProxy {
public InvestmentAccountProxy(final Account account) {
super(account);
}
@Override
public BigDecimal getBalance(final LocalDate start, final LocalDate end) {
return getCashBalance(start, end).add(getMarketValue(start, end));
}
/**
* Returns the cash balance plus the market value of the shares.
*
* @return cash balance
*/
@Override
public BigDecimal getBalance() {
return getCashBalance().add(getMarketValue());
}
@Override
public BigDecimal getBalance(final LocalDate date) {
return getCashBalance(date).add(getMarketValue(date));
}
/**
* Returns the cash balance of this account. Cash balance may be referred to as the "sweep" account where
* the money market fund (cash) does not have it's own account number and the user see's it as a cash balance
* in their account statements.
*
* @return cash balance of the account
*/
@Override
public BigDecimal getCashBalance() {
return super.getBalance();
}
/**
* Get the account's cash balance up to a specified index.
*
* @param index the balance of the account at the specified index.
* @return the balance of the account at the specified index.
*/
private BigDecimal getCashBalanceAt(final int index) {
return super.getBalanceAt(index);
}
/**
* Returns the balance of the transactions inclusive of the start and end dates.
*
* The balance includes the cash transactions and is based on the current market value.
*
* @param start The inclusive start date
* @param end The inclusive end date
* @return The ending balance
*/
private BigDecimal getCashBalance(final LocalDate start, final LocalDate end) {
return super.getBalance(start, end);
}
/**
* Returns the cash account balance up to and inclusive of the supplied date.
*
* @param end The inclusive ending date
* @return The ending cash balance
*/
private BigDecimal getCashBalance(final LocalDate end) {
final Lock l = account.getTransactionLock().readLock();
l.lock();
try {
return !account.transactions.isEmpty()
? getCashBalance(account.getSortedTransactionList().get(0).getLocalDate(), end) : BigDecimal.ZERO;
} finally {
l.unlock();
}
}
/**
* Returns a market price for the supplied {@code SecurityNode} that is closest to the supplied date without
* exceeding it. The history of the {@code SecurityNode} is searched as well as the account's transaction
* history to find the closest market price without exceeding the supplied date.
*
* @param node security to search against
* @param date date to search against
* @return market price
*/
private BigDecimal getMarketPrice(final SecurityNode node, final LocalDate date) {
account.getTransactionLock().readLock().lock();
try {
return Engine.getMarketPrice(account.getSortedTransactionList(), node, account.getCurrencyNode(), date);
} finally {
account.getTransactionLock().readLock().unlock();
}
}
/**
* Returns the market value of this account.
*
* @return the market value of the account
*/
@Override
public BigDecimal getMarketValue() {
final Lock l = account.getTransactionLock().readLock();
l.lock();
try {
BigDecimal marketValue = BigDecimal.ZERO;
int count = account.getTransactionCount();
if (count > 0) {
LocalDate lastDate = account.getSortedTransactionList().get(count - 1).getLocalDate();
/*
* If the user was to enter a date value greater than the current date, then
* "new Date()" is not sufficient to pick up the last transaction. If the
* current date is greater, than it is used to force use of the latest
* security price.
*/
final LocalDate startDate = account.getSortedTransactionList().get(0).getLocalDate();
if (lastDate.compareTo(LocalDate.now()) >= 0) {
marketValue = getMarketValue(startDate, lastDate);
} else {
marketValue = getMarketValue(startDate, LocalDate.now());
}
}
return marketValue;
} finally {
l.unlock();
}
}
/**
* Returns the market value of the account at a specified date. The closest market price is used and only investment
* transactions earlier and inclusive of the specified date are considered.
*
* @param date the end date to calculate the market value
* @return the ending balance
*/
private BigDecimal getMarketValue(final LocalDate date) {
final Lock l = account.getTransactionLock().readLock();
l.lock();
try {
BigDecimal marketValue = BigDecimal.ZERO;
if (account.getTransactionCount() > 0) {
marketValue = getMarketValue(account.getSortedTransactionList().get(0).getLocalDate(), date);
}
return marketValue;
} finally {
l.unlock();
}
}
/**
* Returns the market value for an account.
*
* @param start inclusive start date
* @param end inclusive end date
* @return market value
*/
private BigDecimal getMarketValue(final LocalDate start, final LocalDate end) {
final Lock l = account.getTransactionLock().readLock();
l.lock();
try {
final HashMap priceMap = new HashMap<>();
// build lookup map for market prices
for (final SecurityNode node : account.getSecurities()) {
priceMap.put(node, getMarketPrice(node, end));
}
BigDecimal balance = BigDecimal.ZERO;
// Get a defensive copy, JPA lazy updates can have side effects
List transactions = account.getSortedTransactionList();
for (final Transaction t : transactions) {
if (t.getLocalDate().compareTo(start) >= 0 && t.getLocalDate().compareTo(end) <= 0) {
if (t instanceof InvestmentTransaction) {
balance = balance.add(((InvestmentTransaction) t).getMarketValue(priceMap.get(((InvestmentTransaction) t).getSecurityNode())));
}
}
}
return round(balance);
} finally {
l.unlock();
}
}
/**
* Calculates the accounts market value based on the latest security price.
*
* @param index index to calculate the balance to
* @return market value
*/
private BigDecimal getMarketValueAt(final int index) {
final Lock l = account.getTransactionLock().readLock();
l.lock();
try {
final HashMap priceMap = new HashMap<>();
LocalDate today = LocalDate.now();
// build lookup map for market prices
for (final SecurityNode node : account.getSecurities()) {
priceMap.put(node, getMarketPrice(node, today));
}
BigDecimal balance = BigDecimal.ZERO;
for (int i = 0; i <= index; i++) {
Transaction t = account.getTransactionAt(i);
if (t instanceof InvestmentTransaction) {
balance = balance.add(((InvestmentTransaction) t).getMarketValue(priceMap.get(((InvestmentTransaction) t).getSecurityNode())));
}
}
return round(balance);
} finally {
l.unlock();
}
}
private BigDecimal getReconciledMarketValue() {
final Lock l = account.getTransactionLock().readLock();
l.lock();
try {
final HashMap priceMap = new HashMap<>();
// build lookup map for market prices
for (final SecurityNode node :account.getSecurities()) {
priceMap.put(node, getMarketPrice(node, LocalDate.now()));
}
BigDecimal balance = BigDecimal.ZERO;
// Get a defensive copy, JPA lazy updates can have side effects
List transactions = account.getSortedTransactionList();
for (final Transaction t : transactions) {
if (t instanceof InvestmentTransaction && t.getReconciled(account) == ReconciledState.RECONCILED) {
balance = balance.add(((InvestmentTransaction) t).getMarketValue(priceMap.get(((InvestmentTransaction) t).getSecurityNode())));
}
}
return round(balance);
} finally {
l.unlock();
}
}
/**
* Calculates the reconciled balance of the account.
*
* @return the reconciled balance of this account
*/
@Override
public BigDecimal getReconciledBalance() {
return super.getReconciledBalance().add(getReconciledMarketValue());
}
/**
* Get the default opening balance for reconciling the account.
*
* @return Opening balance for reconciling the account
*/
@Override
public BigDecimal getOpeningBalanceForReconcile() {
final Lock l = account.getTransactionLock().readLock();
l.lock();
try {
final LocalDate date = account.getFirstUnreconciledTransactionDate();
BigDecimal balance = BigDecimal.ZERO;
final List transactions = account.getSortedTransactionList();
for (int i = 0; i < transactions.size(); i++) {
if (transactions.get(i).getLocalDate().equals(date)) {
if (i > 0) {
balance = getCashBalanceAt(i - 1).add(getMarketValueAt(i - 1));
}
break;
}
}
return round(balance);
} finally {
l.unlock();
}
}
/**
* Scales / Rounds a given value to the scale of the accounts currency. Calculating Market value will result
* in minor discrepancies. Use this before returning values for consistent calculations.
*
* @param value value to round
* @return rounded value
*/
private BigDecimal round(final BigDecimal value) {
return value.setScale(account.getCurrencyNode().getScale(), MathConstants.roundingMode);
}
}
================================================
FILE: jgnash-core/src/main/java/jgnash/engine/InvestmentPerformanceSummary.java
================================================
/*
* jGnash, a personal finance application
* Copyright (C) 2001-2020 Craig Cavanaugh
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
package jgnash.engine;
import java.math.BigDecimal;
import java.text.NumberFormat;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.apache.commons.lang3.tuple.Pair;
/**
* Investment Performance Summary Class.
*
* @author Craig Cavanaugh
*/
public class InvestmentPerformanceSummary {
private final Account account;
private LocalDate startDate;
private LocalDate endDate;
private final Map performanceData = new TreeMap<>();
private final List