diff --git a/buxfer-cli/.gitignore b/buxfer-cli/.gitignore
new file mode 100644
index 0000000..223a375
--- /dev/null
+++ b/buxfer-cli/.gitignore
@@ -0,0 +1,3 @@
+# Personal
+buxfer-personal.properties
+
diff --git a/buxfer-cli/pom.xml b/buxfer-cli/pom.xml
new file mode 100644
index 0000000..903c405
--- /dev/null
+++ b/buxfer-cli/pom.xml
@@ -0,0 +1,110 @@
+
+ 4.0.0
+ buxfer-cli
+ Buxfer CLI
+
+
+ com.inteligr8.buxfer
+ buxfer-public-rest-parent
+ 1.0-SNAPSHOT
+ ../
+
+
+
+ jersey
+ 5.2.14.RELEASE
+
+
+
+
+ commons-cli
+ commons-cli
+ 1.5.0
+
+
+ ${project.parent.groupId}
+ buxfer-public-rest-client
+ ${project.parent.version}
+ jersey
+
+
+ com.inteligr8.polygon
+ polygon-public-rest-client
+ 1.0-SNAPSHOT
+ jersey
+
+
+ org.glassfish.jersey.core
+ jersey-client
+ runtime
+
+
+ org.glassfish.jersey.ext
+ jersey-proxy-client
+ runtime
+
+
+ org.glassfish.jersey.inject
+ jersey-hk2
+ runtime
+
+
+ org.glassfish.jersey.media
+ jersey-media-json-jackson
+ runtime
+
+
+ org.apache.logging.log4j
+ log4j-slf4j-impl
+ runtime
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.springframework
+ spring-test
+ ${spring.version}
+ test
+
+
+ org.apache.httpcomponents
+ httpclient
+ 4.5.9
+ test
+
+
+
+
+
+
+
+ maven-assembly-plugin
+ 3.3.0
+
+
+ main-jar
+ package
+
+ single
+
+
+
+
+ com.inteligr8.buxfer.CLI
+
+
+
+ jar-with-dependencies
+
+
+
+
+
+
+
+
diff --git a/buxfer-cli/src/assembly/resources/log4j2.xml b/buxfer-cli/src/assembly/resources/log4j2.xml
new file mode 100644
index 0000000..fe18cc7
--- /dev/null
+++ b/buxfer-cli/src/assembly/resources/log4j2.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/BuxferTransactionParser.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/BuxferTransactionParser.java
new file mode 100644
index 0000000..dfd584c
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/BuxferTransactionParser.java
@@ -0,0 +1,9 @@
+package com.inteligr8.buxfer;
+
+import com.inteligr8.buxfer.model.Transaction;
+
+public interface BuxferTransactionParser {
+
+ ParsedTransaction parse(Transaction buxferTx) throws InterruptedException;
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/CLI.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/CLI.java
new file mode 100644
index 0000000..1ba8d12
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/CLI.java
@@ -0,0 +1,64 @@
+package com.inteligr8.buxfer;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.cli.HelpFormatter;
+import org.apache.commons.cli.Options;
+import org.apache.commons.cli.ParseException;
+
+public class CLI {
+
+ public static void main(String[] args) throws ParseException, IOException, InterruptedException {
+ Options options = new Options();
+ if (args.length == 0) {
+ help(options, null, null, "A function is required");
+ return;
+ } else if (args[0].equals("help")) {
+ help(options, null, null);
+ return;
+ }
+
+ String func = args[0];
+ String[] subargs = Arrays.copyOfRange(args, 1, args.length);
+
+ switch (func.toLowerCase()) {
+ case "investdetail":
+ InvestNormalizeCLI.main(subargs);
+ break;
+ case "investgl":
+ InvestGainsLossesCLI.main(subargs);
+ break;
+ case "writer":
+ WriterCLI.main(subargs);
+ break;
+ default:
+ help(options, null, null, "The function '" + func + "' is not valid");
+ }
+ }
+
+ static void help(Options options, String extraCommand, List extraLines, String message) {
+ System.err.println(message);
+ help(options, extraCommand, extraLines);
+ }
+
+ static void help(Options options, String extraCommand, List extraLines) {
+ System.out.println("A set of Buxfer tools");
+ System.out.print("usage: java -jar buxfer-cli-*.jar");
+ System.out.print(" ( InvestDetail | InvestGL | Writer )");
+ if (extraCommand != null)
+ System.out.print(" " + extraCommand);
+ System.out.println();
+ HelpFormatter help = new HelpFormatter();
+ help.printOptions(new PrintWriter(System.out, true), 120, options, 3, 3);
+ System.out.println("InvestDetail: Reformats descriptions and sets the appropriate transaction types for investments");
+ System.out.println(" InvestGL: After 'InvestDetail', compute gains as transactions and reconcile");
+ System.out.println(" Writer: Writes transactions to external file");
+ if (extraLines != null)
+ for (String extraLine : extraLines)
+ System.out.println(extraLine);
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/CsvTransactionWriter.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/CsvTransactionWriter.java
new file mode 100644
index 0000000..d4779c8
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/CsvTransactionWriter.java
@@ -0,0 +1,21 @@
+package com.inteligr8.buxfer;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import com.inteligr8.buxfer.model.Transaction;
+
+public class CsvTransactionWriter implements Observer {
+
+ private final OutputStream ostream;
+
+ public CsvTransactionWriter(OutputStream ostream) {
+ this.ostream = ostream;
+ }
+
+ @Override
+ public int observed(Transaction tx) throws IOException {
+ return 0;
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/InvestGainsLossesCLI.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/InvestGainsLossesCLI.java
new file mode 100644
index 0000000..4fda66c
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/InvestGainsLossesCLI.java
@@ -0,0 +1,308 @@
+package com.inteligr8.buxfer;
+
+import java.io.IOException;
+import java.time.LocalDate;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.DefaultParser;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.Options;
+import org.apache.commons.cli.ParseException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.inteligr8.buxfer.api.CommandApi;
+import com.inteligr8.buxfer.model.Transaction;
+import com.inteligr8.buxfer.model.Transaction.Status;
+import com.inteligr8.buxfer.model.Transaction.Type;
+import com.inteligr8.buxfer.model.TransactionsResponse;
+
+public class InvestGainsLossesCLI {
+
+ private static final Logger logger = LoggerFactory.getLogger(InvestGainsLossesCLI.class);
+
+ private static final Pattern splitFormat =
+ Pattern.compile("Split ([A-Z]+) ([0-9]+) to ([0-9]+)");
+
+ private static final Map unsoldShares = new HashMap<>();
+ private static final Map> unsoldBuys = new HashMap<>();
+
+ public static void main(String[] args) throws ParseException, IOException, InterruptedException {
+ Options options = buildOptions();
+ String extraCommand = "[options] \"accountName\"";
+ List extraLines = Arrays.asList("accountName: A Buxfer account name");
+
+ CommandLine cli = new DefaultParser().parse(options, args);
+ if (cli.getArgList().isEmpty()) {
+ CLI.help(options, extraCommand, extraLines, "An account name is required");
+ return;
+ } else if (cli.hasOption("help")) {
+ CLI.help(options, extraCommand, extraLines);
+ return;
+ }
+
+ final boolean taxExempt = cli.hasOption("tax-exempt");
+ final boolean dryRun = cli.hasOption("dry-run");
+ final int searchLimit = Integer.valueOf(cli.getOptionValue("limit", "0"));
+
+ String buxferAccountName = cli.getArgList().iterator().next();
+ final CommandApi bapi = findBuxferApi(cli);
+
+ Observer observer = new Observer() {
+ @Override
+ public int observed(Transaction tx) throws IOException, InterruptedException {
+ logger.debug("observed tx: {}", tx.getId());
+
+ try {
+ ParsedTransaction ptx = NormalizedParser.getInstance().parse(tx);
+ if (!ptx.getType().equals(tx.getType()))
+ throw new IllegalStateException("The tx " + tx.getId() + " description and its type conflict: one 'Buy' one 'Sell'");
+
+ TransactionWrapper txw = new TransactionWrapper(ptx);
+ switch (tx.getType()) {
+ case Buy:
+ registerBuy(txw);
+ break;
+ case Sell:
+ return processSell(txw, bapi, taxExempt, dryRun);
+ default:
+ logger.debug("ignoring: " + tx.getDescription());
+ }
+ } catch (IllegalArgumentException iae) {
+ Matcher matcher = splitFormat.matcher(tx.getDescription());
+ if (matcher.matches()) {
+ String symbol = matcher.group(1);
+ int fromSecurities = Integer.valueOf(matcher.group(2));
+ int toSecurities = Integer.valueOf(matcher.group(3));
+ float ratio = 1f * toSecurities / fromSecurities;
+
+ logger.debug("splitting {} with ratio {}", symbol, ratio);
+
+ Double outstandingSecurities = unsoldShares.get(symbol);
+ if (outstandingSecurities != null)
+ unsoldShares.put(symbol, outstandingSecurities * ratio);
+
+ if (unsoldBuys.get(symbol) != null)
+ for (TransactionWrapper txw : unsoldBuys.get(symbol))
+ txw.recordSplit(ratio);
+ } else {
+ logger.debug("ignoring: " + tx.getDescription());
+ }
+ }
+
+ return 0;
+ }
+ };
+
+ searchTransactions(bapi, buxferAccountName, searchLimit, observer);
+
+ for (List txws : unsoldBuys.values()) {
+ for (TransactionWrapper txw : txws) {
+ if (txw.getParsedTransaction().getExpirationDate() != null) {
+ if (LocalDate.now().isAfter(txw.getParsedTransaction().getExpirationDate())) {
+ recordCapitalGain(txw.getOutstandingSecurities(), 0.0, txw.getParsedTransaction().getExpirationDate(), txw, bapi, taxExempt, dryRun);
+
+ if (!dryRun)
+ bapi.editTransaction(txw.getTransaction().getId(), txw.getParsedTransaction().getNormalizedDescription(), null, Status.Reconciled);
+ }
+ } else {
+ long days = LocalDate.now().toEpochDay() - txw.getTransaction().getDate().toEpochDay();
+ logger.debug("unreconciled tx {}: {} ({} days)", txw.getTransaction().getId(), txw.getTransaction().getDescription(), days);
+
+ String description = txw.getDescription();
+ if (!description.equals(txw.getTransaction().getDescription())) {
+ logger.debug("updating partially reconciled tx {}: {}", txw.getTransaction().getId(), description);
+ if (!dryRun)
+ bapi.editTransaction(txw.getTransaction().getId(), description, null, null);
+ }
+ }
+ }
+ }
+ }
+
+ private static void registerBuy(TransactionWrapper txw) {
+ Double unsoldSecurities = unsoldShares.get(txw.getParsedTransaction().getKey());
+ unsoldSecurities = unsoldSecurities == null ? txw.getSplitAdjustedSecurities() : (unsoldSecurities + txw.getSplitAdjustedSecurities());
+ unsoldShares.put(txw.getParsedTransaction().getKey(), unsoldSecurities);
+ if (logger.isDebugEnabled())
+ logger.debug("{} shares of: {}", unsoldShares.get(txw.getParsedTransaction().getKey()), txw.getParsedTransaction().getKey());
+
+ List txws = unsoldBuys.get(txw.getParsedTransaction().getKey());
+ if (txws == null)
+ unsoldBuys.put(txw.getParsedTransaction().getKey(), txws = new LinkedList());
+ logger.debug("{} outstanding buys of: {}", txws.size()+1, txw.getParsedTransaction().getKey());
+ txws.add(txw);
+ }
+
+ private static int processSell(TransactionWrapper txw, CommandApi api, boolean taxExempt, boolean dryRun) {
+ Double outstandingSecurities = unsoldShares.get(txw.getParsedTransaction().getKey());
+ if (outstandingSecurities != null && outstandingSecurities < 0) {
+ logger.debug("ignoring: {}", txw.getParsedTransaction().getKey());
+ // security processing ignored
+ return 0;
+ } else if (outstandingSecurities == null || outstandingSecurities < txw.getSplitAdjustedSecurities()) {
+ logger.warn("'" + txw.getParsedTransaction().getKey() + "' is out of shares; data integrity issue; not processing anymore sells on that security");
+ unsoldShares.put(txw.getParsedTransaction().getKey(), Double.MIN_VALUE);
+ return 0;
+ }
+
+ unsoldShares.put(txw.getParsedTransaction().getKey(), outstandingSecurities.doubleValue() - txw.getSplitAdjustedSecurities());
+ if (logger.isDebugEnabled())
+ logger.debug("{} shares of: {}", unsoldShares.get(txw.getParsedTransaction().getKey()), txw.getParsedTransaction().getKey());
+
+ int reconciled = 0;
+ String symbolTag = "Investment / Security / " + txw.getParsedTransaction().getSecuritySymbol();
+
+ List buyTxws = unsoldBuys.get(txw.getParsedTransaction().getKey());
+ ListIterator i = buyTxws.listIterator();
+ //ListIterator i = buyTxws.listIterator(buyTxws.size());
+ while (i.hasNext() && txw.getOutstandingSecurities() > 0) {
+ TransactionWrapper buyTxw = i.next();
+ logger.debug("reconciling with buy txId: {}", buyTxw.getTransaction().getId());
+
+ double sharesToDrain = txw.getOutstandingSecurities();
+ if (buyTxw.getOutstandingSecurities() <= sharesToDrain) {
+ sharesToDrain = buyTxw.getOutstandingSecurities();
+ i.remove();
+ }
+
+ double sellValueDrained = txw.decrementOutstandingSecurities(sharesToDrain);
+
+ recordCapitalGain(sharesToDrain, sellValueDrained, txw.getTransaction().getDate(), buyTxw, api, taxExempt, dryRun);
+ }
+
+ if (!dryRun) {
+ logger.debug("reconciled sell txId: {}", txw.getTransaction().getId());
+ api.editTransaction(txw.getTransaction().getId(), null, symbolTag, Status.Reconciled);
+ reconciled++;
+ }
+
+ return reconciled;
+ }
+
+ private static boolean recordCapitalGain(double sharesToDrain, double sellValueDrained, LocalDate sellDate, TransactionWrapper txw, CommandApi api, boolean taxExempt, boolean dryRun) {
+ logger.debug("reconciling {} securities", sharesToDrain);
+
+ double buyValueDrained = txw.decrementOutstandingSecurities(sharesToDrain);
+ double gain = Math.round((sellValueDrained - buyValueDrained) * 100) / 100.0;
+
+ long days = sellDate.toEpochDay() - txw.getTransaction().getDate().toEpochDay();
+ boolean longTerm = sellDate.minusYears(1L).plusDays(1).isAfter(txw.getTransaction().getDate());
+
+ String description = days + " Day " + (gain > 0.0 ? "Gain" : "Loss") + " " + txw.getParsedTransaction().getKey();
+
+ String capitalGainTag = "Investment / Capital Gain / ";
+ if (taxExempt) capitalGainTag += "Exempt";
+ else if (longTerm) capitalGainTag += "Long Term";
+ else capitalGainTag += "Short Term";
+ String symbolTag = "Investment / Security / " + txw.getParsedTransaction().getSecuritySymbol();
+
+ logger.debug("adding capital gain {}: {}", gain, description);
+
+ if (!dryRun) {
+ Long fromAccountId = (gain < 0.0) ? txw.getTransaction().getAccountId() : null;
+ Long toAccountId = (gain < 0.0) ? null : txw.getTransaction().getAccountId();
+
+ api.addTransaction(
+ Type.Transfer.getOutgoingValue(),
+ fromAccountId,
+ toAccountId,
+ sellDate,
+ description,
+ gain,
+ symbolTag + "," + capitalGainTag,
+ Status.Reconciled);
+
+ if (txw.getOutstandingSecurities() == 0) {
+ logger.debug("reconciled buy txId: {}", txw.getTransaction().getId());
+ api.editTransaction(txw.getTransaction().getId(), txw.getDescription(), null, Status.Reconciled);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static void searchTransactions(CommandApi api, String buxferAccountName, int limit, Observer observer) throws IOException, InterruptedException {
+ TransactionsResponse response = api.getTransactionsInAccount(buxferAccountName, null, null, Status.Cleared, 1).getResponse();
+
+ long total = response.getTotalItems();
+ long maxToProcess = limit == 0 ? Long.MAX_VALUE : (long)limit;
+ int pages = (int)((total-1) / 100) + 1;
+ long processed = 0L;
+ long reconciled = 0L;
+
+ // must go in reverse
+ for (int p = pages; p > 0 && processed < maxToProcess; p--) {
+ logger.debug("searching '{}' page {}", buxferAccountName, p);
+ response = api.getTransactionsInAccount(buxferAccountName, null, null, Status.Cleared, p).getResponse();
+ if ((total-reconciled) != response.getTotalItems()) {
+ logger.error("Transaction count changed unexpectedly while processing: {} => {}", total-reconciled, response.getTotalItems());
+ return;
+ }
+
+ Collections.sort(response.getItems(), new Comparator() {
+ @Override
+ public int compare(Transaction tx1, Transaction tx2) {
+ int compare = tx1.getDate().compareTo(tx2.getDate());
+ if (compare == 0)
+ // we want buys before sells
+ compare = tx1.getDescription().compareTo(tx2.getDescription());
+ return compare;
+ }
+ });
+
+ for (Transaction tx : response.getItems()) {
+ try {
+ reconciled += observer.observed(tx);
+ } catch (IllegalArgumentException | IllegalStateException ie) {
+ logger.debug("transaction cannot be processed: " + ie.getMessage());
+ }
+
+ processed++;
+ if (processed >= maxToProcess)
+ break;
+ }
+
+ logger.info("Processed page {} ({} txs; {} existing tx reconciled)", p, processed, reconciled);
+
+ // let the Buxfer indexer catch up
+ Thread.sleep(5000L);
+ }
+
+ logger.info("Reconciled {} existing transactions ({} txs)", reconciled, processed);
+ }
+
+ private static Options buildOptions() {
+ return new Options()
+ .addOption(new Option("bu", "buxfer-email", true, "A Buxfer email for authentication"))
+ .addOption(new Option("bp", "buxfer-password", true, "A Buxfer password for authentication"))
+ .addOption(new Option("t", "tax-exempt", false, "Tag capital gains/losses and dividends as tax exempt"))
+ .addOption(new Option("dr", "dry-run", false, "Do not updated Buxfer (for testing)"))
+ .addOption(new Option("l", "limit", true, "Only process this many transactions (for testing)"));
+ }
+
+ private static CommandApi findBuxferApi(CommandLine cli) {
+ BuxferClientConfiguration config = new BuxferClientConfiguration();
+ config.setBaseUrl("https://www.buxfer.com");
+
+ if (cli.hasOption("buxfer-email"))
+ config.setAuthEmail(cli.getOptionValue("buxfer-email"));
+ if (cli.hasOption("buxfer-password"))
+ config.setAuthPassword(cli.getOptionValue("buxfer-password"));
+
+ BuxferClientJerseyImpl client = new BuxferClientJerseyImpl(config);
+ return client.getApi().getCommandApi();
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/InvestNormalizeCLI.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/InvestNormalizeCLI.java
new file mode 100644
index 0000000..f623d82
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/InvestNormalizeCLI.java
@@ -0,0 +1,157 @@
+package com.inteligr8.buxfer;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.DefaultParser;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.Options;
+import org.apache.commons.cli.ParseException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.inteligr8.buxfer.model.Transaction;
+import com.inteligr8.buxfer.model.Transaction.Status;
+import com.inteligr8.buxfer.model.TransactionsResponse;
+import com.inteligr8.polygon.PolygonClientConfiguration;
+import com.inteligr8.polygon.PolygonClientJerseyImpl;
+import com.inteligr8.polygon.PolygonPublicRestApi;
+
+public class InvestNormalizeCLI {
+
+ private static final Logger logger = LoggerFactory.getLogger(InvestNormalizeCLI.class);
+
+ public static void main(String[] args) throws ParseException, IOException, InterruptedException {
+ Options options = buildOptions();
+ String extraCommand = "[options] \"accountName\"";
+ List extraLines = Arrays.asList("accountName: A Buxfer account name");
+
+ CommandLine cli = new DefaultParser().parse(options, args);
+ if (cli.getArgList().isEmpty()) {
+ CLI.help(options, extraCommand, extraLines, "An account name is required");
+ return;
+ } else if (cli.hasOption("help")) {
+ CLI.help(options, extraCommand, extraLines);
+ return;
+ }
+
+ final boolean dryRun = cli.hasOption("dry-run");
+ final int searchLimit = Integer.valueOf(cli.getOptionValue("limit", "0"));
+
+ String buxferAccountName = cli.getArgList().iterator().next();
+ final BuxferPublicRestApi bapi = findBuxferApi(cli);
+ final PolygonPublicRestApi papi = findPolygonApi(cli);
+
+ final List parsers = Arrays.asList(
+ TdAmeritradeParser.getInstance(),
+ SofiInvestParser.getInstance(papi));
+
+ Observer observer = new Observer() {
+ @Override
+ public int observed(Transaction tx) throws IOException, InterruptedException {
+ logger.debug("observed tx: {}", tx.getId());
+
+ // ignore null/empty descriptions
+ if (tx.getDescription() == null || tx.getDescription().length() == 0)
+ return 0;
+
+ try {
+ NormalizedParser.getInstance().parse(tx);
+ logger.trace("tx {} already formatted/processed: {}", tx.getId(), tx.getDescription());
+ return 0;
+ } catch (IllegalArgumentException iae) {
+ // continue
+ }
+
+ for (BuxferTransactionParser parser : parsers) {
+ try {
+ ParsedTransaction ptx = parser.parse(tx);
+ logger.debug("tx {} formatted: {} => {}", tx.getId(), tx.getDescription(), ptx.getNormalizedDescription());
+
+ if (!tx.getType().equals(ptx.getType()))
+ logger.debug("tx {} re-typed: {} => {}", tx.getId(), tx.getType(), ptx.getType());
+
+ if (!dryRun)
+ bapi.getCommandApi().editTransaction(tx.getId(), ptx.getType().getOutgoingValue(), ptx.getNormalizedDescription(),
+ "Investment / Security / " + ptx.getSecuritySymbol(), null);
+ return 1;
+ } catch (IllegalArgumentException iae) {
+ // try another
+ }
+ }
+
+ logger.info("tx {} cannot be formatted: {}", tx.getId(), tx.getDescription());
+ return 0;
+ }
+ };
+
+ searchTransactions(bapi, buxferAccountName, searchLimit, observer);
+ }
+
+ private static void searchTransactions(BuxferPublicRestApi api, String buxferAccountName, int limit, Observer observer) throws IOException, InterruptedException {
+ long maxToProcess = limit == 0 ? Long.MAX_VALUE : (long)limit;
+ long total = -1L;
+ long processed = 0L;
+ long updated = 0L;
+ int pages = 1;
+ for (int p = 1; p <= pages && processed < maxToProcess; p++) {
+ logger.debug("searching '{}' page {}", buxferAccountName, p);
+ TransactionsResponse response = api.getCommandApi().getTransactionsInAccount(buxferAccountName, null, null, Status.Cleared, p).getResponse();
+ if (total < 0L) {
+ total = response.getTotalItems();
+ pages = (int)((response.getTotalItems()-1) / 100) + 1;
+ logger.debug("found {} total transactions covering {} pages", total, pages);
+ } else if (total != response.getTotalItems()) {
+ logger.error("Transaction count changed while processing");
+ return;
+ }
+
+ for (Transaction tx : response.getItems()) {
+ updated += observer.observed(tx);
+ processed++;
+ if (processed >= maxToProcess)
+ break;
+ }
+
+ logger.info("Processed page {} ({} txs; {} updated)", p, processed, updated);
+ }
+
+ logger.info("Updated {} transactions ({} txs)", updated, processed);
+ }
+
+ private static Options buildOptions() {
+ return new Options()
+ .addOption(new Option("bu", "buxfer-email", true, "A Buxfer email for authentication"))
+ .addOption(new Option("bp", "buxfer-password", true, "A Buxfer password for authentication"))
+ .addOption(new Option("pak", "polygon-apiKey", true, "A Polygon.IO API key for authentication"))
+ .addOption(new Option("dr", "dry-run", false, "Do not updated Buxfer (for testing)"))
+ .addOption(new Option("l", "limit", true, "Only process this many transactions (for testing)"));
+ }
+
+ private static BuxferPublicRestApi findBuxferApi(CommandLine cli) {
+ BuxferClientConfiguration config = new BuxferClientConfiguration();
+ config.setBaseUrl("https://www.buxfer.com");
+
+ if (cli.hasOption("buxfer-email"))
+ config.setAuthEmail(cli.getOptionValue("buxfer-email"));
+ if (cli.hasOption("buxfer-password"))
+ config.setAuthPassword(cli.getOptionValue("buxfer-password"));
+
+ BuxferClientJerseyImpl client = new BuxferClientJerseyImpl(config);
+ return client.getApi();
+ }
+
+ private static PolygonPublicRestApi findPolygonApi(CommandLine cli) {
+ PolygonClientConfiguration config = new PolygonClientConfiguration();
+ config.setBaseUrl("https://api.polygon.io");
+
+ if (cli.hasOption("polygon-apiKey"))
+ config.setApiKey(cli.getOptionValue("polygon-apiKey"));
+
+ PolygonClientJerseyImpl client = new PolygonClientJerseyImpl(config);
+ return client.getApi();
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/JsonTransactionWriter.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/JsonTransactionWriter.java
new file mode 100644
index 0000000..c73f057
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/JsonTransactionWriter.java
@@ -0,0 +1,24 @@
+package com.inteligr8.buxfer;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.inteligr8.buxfer.model.Transaction;
+
+public class JsonTransactionWriter implements Observer {
+
+ private final ObjectMapper om = new ObjectMapper();
+ private final OutputStream ostream;
+
+ public JsonTransactionWriter(OutputStream ostream) {
+ this.ostream = ostream;
+ }
+
+ @Override
+ public int observed(Transaction tx) throws IOException {
+ this.om.writeValue(this.ostream, tx);
+ return 1;
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/MyStockPortfolioCsvTransactionWriter.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/MyStockPortfolioCsvTransactionWriter.java
new file mode 100644
index 0000000..30544a3
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/MyStockPortfolioCsvTransactionWriter.java
@@ -0,0 +1,11 @@
+package com.inteligr8.buxfer;
+
+import java.io.OutputStream;
+
+public class MyStockPortfolioCsvTransactionWriter extends CsvTransactionWriter {
+
+ public MyStockPortfolioCsvTransactionWriter(OutputStream ostream) {
+ super(ostream);
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/NormalizedDividendTransaction.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/NormalizedDividendTransaction.java
new file mode 100644
index 0000000..cd35038
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/NormalizedDividendTransaction.java
@@ -0,0 +1,62 @@
+package com.inteligr8.buxfer;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.inteligr8.buxfer.model.Transaction;
+import com.inteligr8.buxfer.model.Transaction.Type;
+
+public class NormalizedDividendTransaction implements ParsedTransaction {
+
+ private final Pattern descriptionFormat =
+ Pattern.compile("((Qualified|Ordinary) )?Dividend ([A-Z]+)");
+
+ private final Transaction tx;
+ private final String symbol;
+
+ public NormalizedDividendTransaction(Transaction tx) {
+ this.tx = tx;
+
+ Matcher matcher = this.descriptionFormat.matcher(tx.getDescription());
+ if (!matcher.find())
+ throw new IllegalArgumentException();
+
+ this.symbol = matcher.group(3).toUpperCase();
+ }
+
+ @Override
+ public Transaction getTransaction() {
+ return this.tx;
+ }
+
+ @Override
+ public String getNormalizedDescription() {
+ return this.tx.getDescription();
+ }
+
+ @Override
+ public Type getType() {
+ return Type.Dividend;
+ }
+
+ @Override
+ public String getKey() {
+ return this.symbol;
+ }
+
+ @Override
+ public double getSecurities() {
+ return Double.NaN;
+ }
+
+ @Override
+ public String getSecuritySymbol() {
+ return this.symbol;
+ }
+
+ @Override
+ public double getPricePerSecurity() {
+ return Double.NaN;
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/NormalizedOptionTransaction.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/NormalizedOptionTransaction.java
new file mode 100644
index 0000000..ebd0e84
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/NormalizedOptionTransaction.java
@@ -0,0 +1,121 @@
+package com.inteligr8.buxfer;
+
+import java.time.LocalDate;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.inteligr8.buxfer.model.Transaction;
+import com.inteligr8.buxfer.model.Transaction.Type;
+
+public class NormalizedOptionTransaction implements ParsedTransaction, PartiallyReconciledTransaction {
+
+ private final Pattern descriptionFormat =
+ Pattern.compile("(Bought|Sold) ([0-9\\.]+) ([A-Z]+) (2[0-9]{3}-[0-9]{2}-[0-9]{2}) ([0-9\\.]+) (PUT|CALL) @ ([0-9\\.]+)(; ([0-9\\.]+) unsold)?");
+
+ private final Transaction tx;
+ private final Type type;
+ private final double securities;
+ private final Double unsoldSecurities;
+ private final String symbol;
+ private final LocalDate expirationDate;
+ private final double strikePrice;
+ private final ContractType contractType;
+ private final double perSecurityPrice;
+ private final String description;
+
+ public NormalizedOptionTransaction(Transaction tx) {
+ this.tx = tx;
+
+ Matcher matcher = this.descriptionFormat.matcher(tx.getDescription());
+ if (!matcher.find())
+ throw new IllegalArgumentException();
+
+ String buysell = matcher.group(1);
+ this.securities = Double.valueOf(matcher.group(2));
+ this.symbol = matcher.group(3).toUpperCase();
+ this.expirationDate = LocalDate.parse(matcher.group(4));
+ this.strikePrice = Double.valueOf(matcher.group(5));
+ this.contractType = ContractType.valueOf(matcher.group(6));
+ this.perSecurityPrice = Double.valueOf(matcher.group(7));
+ String unsoldSecurities = matcher.group(9);
+
+ this.type = this.determineTransactionType(buysell);
+ this.unsoldSecurities = unsoldSecurities == null ? null : Double.valueOf(unsoldSecurities);
+
+ this.description = new StringBuilder()
+ .append(buysell).append(' ')
+ .append(NumberFormatFactory.getSecuritiesFormatter().format(this.securities)).append(' ')
+ .append(this.symbol).append(' ')
+ .append(this.expirationDate.toString()).append(' ')
+ .append(NumberFormatFactory.getStrikePriceFormatter().format(this.strikePrice)).append(' ')
+ .append(this.contractType.toString()).append(" @ ")
+ .append(NumberFormatFactory.getPriceFormatter().format(this.perSecurityPrice)).toString();
+ }
+
+ private Type determineTransactionType(String buysell) {
+ buysell = buysell.toLowerCase();
+ if (buysell.charAt(0) == 'b') {
+ return Type.Buy;
+ } else if (buysell.charAt(0) == 's') {
+ return Type.Sell;
+ } else {
+ throw new IllegalArgumentException();
+ }
+ }
+
+ @Override
+ public Transaction getTransaction() {
+ return this.tx;
+ }
+
+ @Override
+ public String getNormalizedDescription() {
+ return this.description;
+ }
+
+ @Override
+ public Type getType() {
+ return this.type;
+ }
+
+ @Override
+ public String getKey() {
+ return this.symbol + ":" + this.expirationDate + ":" + this.strikePrice + ":" + this.contractType;
+ }
+
+ @Override
+ public double getSecurities() {
+ return this.securities;
+ }
+
+ @Override
+ public Double getUnsoldSecurities() {
+ return this.unsoldSecurities;
+ }
+
+ @Override
+ public String getSecuritySymbol() {
+ return this.symbol;
+ }
+
+ @Override
+ public double getPricePerSecurity() {
+ return this.perSecurityPrice;
+ }
+
+ @Override
+ public ContractType getContractType() {
+ return this.contractType;
+ }
+
+ @Override
+ public LocalDate getExpirationDate() {
+ return this.expirationDate;
+ }
+
+ @Override
+ public Double getStrikePrice() {
+ return this.strikePrice;
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/NormalizedParser.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/NormalizedParser.java
new file mode 100644
index 0000000..18f04b2
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/NormalizedParser.java
@@ -0,0 +1,31 @@
+package com.inteligr8.buxfer;
+
+import com.inteligr8.buxfer.model.Transaction;
+
+public class NormalizedParser implements BuxferTransactionParser {
+
+ private static final NormalizedParser INSTANCE = new NormalizedParser();
+
+ public static NormalizedParser getInstance() {
+ return INSTANCE;
+ }
+
+
+
+ private NormalizedParser() {
+ }
+
+ @Override
+ public ParsedTransaction parse(Transaction buxferTx) {
+ try {
+ return new NormalizedOptionTransaction(buxferTx);
+ } catch (IllegalArgumentException iae) {
+ try {
+ return new NormalizedStockTransaction(buxferTx);
+ } catch (IllegalArgumentException iae2) {
+ return new NormalizedDividendTransaction(buxferTx);
+ }
+ }
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/NormalizedStockTransaction.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/NormalizedStockTransaction.java
new file mode 100644
index 0000000..f41261c
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/NormalizedStockTransaction.java
@@ -0,0 +1,96 @@
+package com.inteligr8.buxfer;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.inteligr8.buxfer.model.Transaction;
+import com.inteligr8.buxfer.model.Transaction.Type;
+
+public class NormalizedStockTransaction implements ParsedTransaction, PartiallyReconciledTransaction {
+
+ private final Pattern descriptionFormat =
+ Pattern.compile("(Bought|Sold) ([0-9\\.]+) ([A-Z]+) @ ([0-9\\.]+)(; ([0-9\\.]+) unsold)?");
+
+ private final Transaction tx;
+ private final Type type;
+ private final double securities;
+ private final Double unsoldSecurities;
+ private final String symbol;
+ private final String description;
+ private final double perSecurityPrice;
+
+ public NormalizedStockTransaction(Transaction tx) {
+ this.tx = tx;
+
+ Matcher matcher = this.descriptionFormat.matcher(tx.getDescription());
+ if (!matcher.find())
+ throw new IllegalArgumentException();
+
+ String buysell = matcher.group(1);
+ this.securities = Double.valueOf(matcher.group(2));
+ this.symbol = matcher.group(3).toUpperCase();
+ this.perSecurityPrice = Double.valueOf(matcher.group(4));
+ String unsoldSecurities = matcher.group(6);
+
+ this.type = this.determineTransactionType(buysell);
+ this.unsoldSecurities = unsoldSecurities == null ? null : Double.valueOf(unsoldSecurities);
+
+ this.description = new StringBuilder()
+ .append(buysell).append(' ')
+ .append(NumberFormatFactory.getSecuritiesFormatter().format(this.securities)).append(' ')
+ .append(this.symbol).append(" @ ")
+ .append(NumberFormatFactory.getPriceFormatter().format(this.perSecurityPrice)).toString();
+ }
+
+ private Type determineTransactionType(String buysell) {
+ buysell = buysell.toLowerCase();
+ if (buysell.charAt(0) == 'b') {
+ return Type.Buy;
+ } else if (buysell.charAt(0) == 's') {
+ return Type.Sell;
+ } else {
+ throw new IllegalArgumentException();
+ }
+ }
+
+ @Override
+ public Transaction getTransaction() {
+ return this.tx;
+ }
+
+ @Override
+ public String getNormalizedDescription() {
+ return this.description;
+ }
+
+ @Override
+ public Type getType() {
+ return this.type;
+ }
+
+ @Override
+ public String getKey() {
+ return this.symbol;
+ }
+
+ @Override
+ public double getSecurities() {
+ return this.securities;
+ }
+
+ @Override
+ public Double getUnsoldSecurities() {
+ return this.unsoldSecurities;
+ }
+
+ @Override
+ public String getSecuritySymbol() {
+ return this.symbol;
+ }
+
+ @Override
+ public double getPricePerSecurity() {
+ return this.perSecurityPrice;
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/NumberFormatFactory.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/NumberFormatFactory.java
new file mode 100644
index 0000000..eba75b1
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/NumberFormatFactory.java
@@ -0,0 +1,44 @@
+package com.inteligr8.buxfer;
+
+import java.math.RoundingMode;
+import java.text.NumberFormat;
+
+public class NumberFormatFactory {
+
+ private static final NumberFormat strikePriceFormatter = NumberFormat.getNumberInstance();
+ static {
+ strikePriceFormatter.setMaximumFractionDigits(4);
+ strikePriceFormatter.setMinimumFractionDigits(1);
+ strikePriceFormatter.setRoundingMode(RoundingMode.HALF_UP);
+ strikePriceFormatter.setGroupingUsed(false);
+ }
+
+ private static final NumberFormat priceFormatter = NumberFormat.getNumberInstance();
+ static {
+ priceFormatter.setMaximumFractionDigits(4);
+ priceFormatter.setMinimumFractionDigits(0);
+ priceFormatter.setRoundingMode(RoundingMode.HALF_UP);
+ priceFormatter.setGroupingUsed(false);
+ }
+
+ private static final NumberFormat securityFormatter = NumberFormat.getNumberInstance();
+ static {
+ securityFormatter.setMaximumFractionDigits(12);
+ securityFormatter.setMinimumFractionDigits(0);
+ securityFormatter.setRoundingMode(RoundingMode.HALF_UP);
+ securityFormatter.setGroupingUsed(false);
+ }
+
+ public static NumberFormat getStrikePriceFormatter() {
+ return strikePriceFormatter;
+ }
+
+ public static NumberFormat getPriceFormatter() {
+ return priceFormatter;
+ }
+
+ public static NumberFormat getSecuritiesFormatter() {
+ return securityFormatter;
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/NumericRange.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/NumericRange.java
new file mode 100644
index 0000000..2f902d9
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/NumericRange.java
@@ -0,0 +1,39 @@
+package com.inteligr8.buxfer;
+
+public class NumericRange {
+
+ private final T from;
+ private final T to;
+
+ public NumericRange(T fromAndTo) {
+ this.from = fromAndTo;
+ this.to = fromAndTo;
+ }
+
+ public NumericRange(T from, T to) {
+ this.from = from;
+ this.to = to;
+ }
+
+ public T getFrom() {
+ return this.from;
+ }
+
+ public T getTo() {
+ return this.to;
+ }
+
+ public boolean isPoint() {
+ return this.from.equals(this.to);
+ }
+
+ public double getMidpoint() {
+ return (this.from.doubleValue() + this.to.doubleValue()) / 2.0;
+ }
+
+ @Override
+ public String toString() {
+ return this.from + "-" + this.to;
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/Observer.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/Observer.java
new file mode 100644
index 0000000..9cd62f2
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/Observer.java
@@ -0,0 +1,9 @@
+package com.inteligr8.buxfer;
+
+import java.io.IOException;
+
+public interface Observer {
+
+ int observed(T t) throws IOException, InterruptedException;
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/ParsedTransaction.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/ParsedTransaction.java
new file mode 100644
index 0000000..2830dd3
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/ParsedTransaction.java
@@ -0,0 +1,61 @@
+package com.inteligr8.buxfer;
+
+import java.time.LocalDate;
+
+import com.inteligr8.buxfer.model.Transaction;
+import com.inteligr8.buxfer.model.Transaction.Type;
+
+public interface ParsedTransaction {
+
+ public enum ContractType {
+ PUT,
+ CALL
+ }
+
+ /**
+ * This provides the Buxfer transaction object and all of its raw meta-data.
+ */
+ Transaction getTransaction();
+
+ String getNormalizedDescription();
+
+ /**
+ * This provides a hash key for matching buys with sells. It should uniquely identify the security.
+ */
+ String getKey();
+
+ /**
+ * This provides the type of transaction as described in the transaction's description.
+ */
+ Type getType();
+
+ /**
+ * This provides the number of shares, option contracts, or even fractional shares (for crypto) in the transaction.
+ */
+ double getSecurities();
+
+ /**
+ * This provides only the security symbol of the transaction; not the expiration date or strike price included (for options).
+ */
+ String getSecuritySymbol();
+
+ double getPricePerSecurity();
+
+ /**
+ * This provides the option contract type: Put or Call
+ *
+ * @return The option contract type; null if not an option
+ */
+ default ContractType getContractType() {
+ return null;
+ }
+
+ default LocalDate getExpirationDate() {
+ return null;
+ }
+
+ default Double getStrikePrice() {
+ return null;
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/PartiallyReconciledTransaction.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/PartiallyReconciledTransaction.java
new file mode 100644
index 0000000..2261d8d
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/PartiallyReconciledTransaction.java
@@ -0,0 +1,7 @@
+package com.inteligr8.buxfer;
+
+public interface PartiallyReconciledTransaction {
+
+ Double getUnsoldSecurities();
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/SofiInvestParser.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/SofiInvestParser.java
new file mode 100644
index 0000000..0f81eb1
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/SofiInvestParser.java
@@ -0,0 +1,29 @@
+package com.inteligr8.buxfer;
+
+import com.inteligr8.buxfer.model.Transaction;
+import com.inteligr8.polygon.PolygonPublicRestApi;
+
+public class SofiInvestParser implements BuxferTransactionParser {
+
+ private static SofiInvestParser INSTANCE;
+
+ public synchronized static SofiInvestParser getInstance(PolygonPublicRestApi papi) {
+ if (INSTANCE == null)
+ INSTANCE = new SofiInvestParser(papi);
+ return INSTANCE;
+ }
+
+
+
+ private final PolygonPublicRestApi papi;
+
+ private SofiInvestParser(PolygonPublicRestApi papi) {
+ this.papi = papi;
+ }
+
+ @Override
+ public ParsedTransaction parse(Transaction buxferTx) throws InterruptedException {
+ return new SofiInvestTransaction(buxferTx, this.papi);
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/SofiInvestTransaction.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/SofiInvestTransaction.java
new file mode 100644
index 0000000..df07d25
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/SofiInvestTransaction.java
@@ -0,0 +1,163 @@
+package com.inteligr8.buxfer;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.inteligr8.buxfer.model.Transaction;
+import com.inteligr8.buxfer.model.Transaction.Type;
+import com.inteligr8.polygon.PolygonPublicRestApi;
+import com.inteligr8.polygon.model.StockDateSummary;
+
+public class SofiInvestTransaction implements ParsedTransaction {
+
+ private final Logger logger = LoggerFactory.getLogger(this.getClass());
+
+ private final Pattern descriptionFormat =
+ Pattern.compile("(Buy|Sell|Dividend) ([A-Za-z]+)");
+
+ private static long nextExecutionTime = 0L;
+ private final long polygonServiceThrottleTime = 20L * 1000L;
+
+ private final PolygonPublicRestApi papi;
+ private final Transaction tx;
+ private final Type type;
+ private final NumericRange extends Number> securities;
+ private final String symbol;
+ private final String description;
+ private final NumericRange perSecurityPrice;
+
+ public SofiInvestTransaction(Transaction tx, PolygonPublicRestApi papi) throws InterruptedException {
+ this.papi = papi;
+ this.tx = tx;
+
+ Matcher matcher = this.descriptionFormat.matcher(tx.getDescription());
+ if (!matcher.find())
+ throw new IllegalArgumentException();
+
+ String buysell = matcher.group(1);
+ this.symbol = matcher.group(2).toUpperCase();
+
+ this.type = this.determineTransactionType(buysell);
+
+ if (Type.Dividend.equals(this.type)) {
+ this.description = "Dividend " + this.symbol;
+ this.securities = null;
+ this.perSecurityPrice = null;
+ } else {
+ this.throttle();
+
+ this.securities = this.estimateSecurites();
+ this.perSecurityPrice = this.estimatePrice(securities);
+
+ String action = Type.Buy.equals(type) ? "Bought" : "Sold";
+ this.description = new StringBuilder()
+ .append(action).append(' ')
+ .append(this.securities == null ? "NaN" : NumberFormatFactory.getSecuritiesFormatter().format(this.securities)).append(' ')
+ .append(this.symbol).append(" @ ")
+ .append(this.perSecurityPrice == null ? "NaN" : NumberFormatFactory.getPriceFormatter().format(this.perSecurityPrice)).toString();
+ }
+ }
+
+ private Type determineTransactionType(String buysell) {
+ buysell = buysell.toLowerCase();
+ if (buysell.charAt(0) == 'b') {
+ return Type.Buy;
+ } else if (buysell.charAt(0) == 's') {
+ return Type.Sell;
+ } else if (buysell.charAt(0) == 'd') {
+ return Type.Dividend;
+ } else {
+ throw new IllegalArgumentException();
+ }
+ }
+
+ private synchronized void throttle() throws InterruptedException {
+ // Polygon.IO throttling
+ if (SofiInvestTransaction.nextExecutionTime > System.currentTimeMillis()) {
+ long waitTime = SofiInvestTransaction.nextExecutionTime - System.currentTimeMillis();
+ logger.debug("Throttled for Polygon.IO service for {} ms", waitTime);
+ Thread.sleep(waitTime);
+ }
+ SofiInvestTransaction.nextExecutionTime = System.currentTimeMillis() + this.polygonServiceThrottleTime;
+ }
+
+ protected NumericRange extends Number> estimateSecurites() {
+ this.logger.debug("Searching for price activity for {} on {}", symbol, this.tx.getDate());
+ StockDateSummary summary = this.papi.getStocksApi().getStockSummaryOnDate(this.symbol, this.tx.getDate(), false);
+ if (summary.getLow() == null || summary.getHigh() == null)
+ return null;
+
+ double maxFracShares = this.tx.getAmount() / summary.getLow();
+ double minFracShares = this.tx.getAmount() / summary.getHigh();
+ int maxShares = (int)Math.floor(maxFracShares);
+ int minShares = (int)Math.ceil(minFracShares);
+
+ if (minShares == maxShares) {
+ logger.debug("Found the highly likely shares: {}", minShares);
+ return new NumericRange(minShares);
+ } else if (minShares < maxShares) {
+ logger.info("Found a range of possible shares; needs manual update: {} => {}", this.tx.getDate(), this.tx.getDescription());
+ return new NumericRange(minShares, maxShares);
+ } else if (maxFracShares - maxShares < 0.05) {
+ logger.debug("Found the highly likely shares (w/ commission): {}", maxShares);
+ return new NumericRange(maxShares);
+ } else {
+ logger.info("Found a fractional range of possible shares; needs manual update: {} => {}", this.tx.getDate(), this.tx.getDescription());
+ return new NumericRange(minFracShares, maxFracShares);
+ }
+ }
+
+ protected NumericRange estimatePrice(NumericRange extends Number> securities) {
+ if (securities == null)
+ return null;
+ if (securities.isPoint())
+ return new NumericRange(Math.round(this.tx.getAmount().doubleValue() * 100 / securities.getFrom().doubleValue()) / 100.0);
+
+ return new NumericRange(
+ Math.round(this.tx.getAmount().doubleValue() * 100 / securities.getTo().doubleValue()) / 100.0,
+ Math.round(this.tx.getAmount().doubleValue() * 100 / securities.getFrom().doubleValue()) / 100.0);
+ }
+
+ @Override
+ public Transaction getTransaction() {
+ return this.tx;
+ }
+
+ @Override
+ public String getNormalizedDescription() {
+ return this.description;
+ }
+
+ @Override
+ public Type getType() {
+ return this.type;
+ }
+
+ @Override
+ public String getKey() {
+ return this.symbol;
+ }
+
+ @Override
+ public double getSecurities() {
+ if (this.securities == null) return Double.NaN;
+ else if (this.securities.isPoint()) return this.securities.getFrom().doubleValue();
+ else return this.securities.getMidpoint();
+ }
+
+ @Override
+ public String getSecuritySymbol() {
+ return this.symbol;
+ }
+
+ @Override
+ public double getPricePerSecurity() {
+ if (this.perSecurityPrice == null) return Double.NaN;
+ else if (this.perSecurityPrice.isPoint()) return this.perSecurityPrice.getFrom().doubleValue();
+ else return this.perSecurityPrice.getMidpoint();
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/TdAmeritradeDividendTransaction.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/TdAmeritradeDividendTransaction.java
new file mode 100644
index 0000000..b07caac
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/TdAmeritradeDividendTransaction.java
@@ -0,0 +1,68 @@
+package com.inteligr8.buxfer;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.inteligr8.buxfer.model.Transaction;
+import com.inteligr8.buxfer.model.Transaction.Type;
+
+public class TdAmeritradeDividendTransaction implements ParsedTransaction {
+
+ private final Pattern descriptionFormat =
+ Pattern.compile("(Qualified|Ordinary) Dividend \\(([A-Za-z]+)\\)");
+
+ private final Transaction tx;
+ private final String symbol;
+ private final String description;
+
+ public TdAmeritradeDividendTransaction(Transaction tx) {
+ this.tx = tx;
+
+ Matcher matcher = this.descriptionFormat.matcher(tx.getDescription());
+ if (!matcher.find())
+ throw new IllegalArgumentException();
+
+ String dividendType = matcher.group(1);
+ this.symbol = matcher.group(2).toUpperCase();
+
+ this.description = new StringBuilder()
+ .append(dividendType).append(" Dividend ")
+ .append(this.symbol).toString();
+ }
+
+ @Override
+ public Transaction getTransaction() {
+ return this.tx;
+ }
+
+ @Override
+ public String getNormalizedDescription() {
+ return this.description;
+ }
+
+ @Override
+ public Type getType() {
+ return Type.Dividend;
+ }
+
+ @Override
+ public String getKey() {
+ return this.symbol;
+ }
+
+ @Override
+ public double getSecurities() {
+ return Double.NaN;
+ }
+
+ @Override
+ public String getSecuritySymbol() {
+ return this.symbol;
+ }
+
+ @Override
+ public double getPricePerSecurity() {
+ return Double.NaN;
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/TdAmeritradeOptionTransaction.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/TdAmeritradeOptionTransaction.java
new file mode 100644
index 0000000..87a5cd7
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/TdAmeritradeOptionTransaction.java
@@ -0,0 +1,169 @@
+package com.inteligr8.buxfer;
+
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.Month;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.inteligr8.buxfer.model.Transaction;
+import com.inteligr8.buxfer.model.Transaction.Type;
+
+public class TdAmeritradeOptionTransaction implements ParsedTransaction {
+
+ private final Pattern descriptionFormat =
+ Pattern.compile(".*(Bought|Sold)( To (Open|Close))? ([0-9\\.]+) ([A-Za-z]+) ([A-Za-z]+) ([0-9Xx]+) (2[0-9]{3}) ([0-9\\.]+) (Put|Call) @ ([0-9\\.]+)");
+
+ private final Transaction tx;
+ private final Type type;
+ private final int securities;
+ private final String symbol;
+ private final LocalDate expirationDate;
+ private final double strikePrice;
+ private final ContractType contractType;
+ private final double perSecurityPrice;
+ private final String description;
+
+ public TdAmeritradeOptionTransaction(Transaction tx) {
+ this.tx = tx;
+
+ Matcher matcher = this.descriptionFormat.matcher(tx.getDescription());
+ if (!matcher.find())
+ throw new IllegalArgumentException();
+
+ String buysell = matcher.group(1);
+ //String openclose = matcher.group(3).toLowerCase();
+ this.securities = Integer.valueOf(matcher.group(4));
+ this.symbol = matcher.group(5).toUpperCase();
+ String strikeMonth = matcher.group(6);
+ String strikeDay = matcher.group(7);
+ String strikeYear = matcher.group(8);
+ this.strikePrice = Double.valueOf(matcher.group(9));
+ this.contractType = ContractType.valueOf(matcher.group(10).toUpperCase());
+ this.perSecurityPrice = Double.valueOf(matcher.group(11));
+
+ this.type = this.determineTransactionType(buysell);
+ this.expirationDate = this.determineExpirationDate(strikeYear, strikeMonth, strikeDay);
+
+ String action = Type.Buy.equals(type) ? "Bought" : "Sold";
+ this.description = new StringBuilder()
+ .append(action).append(' ')
+ .append(NumberFormatFactory.getSecuritiesFormatter().format(this.securities)).append(' ')
+ .append(this.symbol).append(' ')
+ .append(this.expirationDate.toString()).append(' ')
+ .append(NumberFormatFactory.getStrikePriceFormatter().format(this.strikePrice)).append(' ')
+ .append(this.contractType.toString()).append(" @ ")
+ .append(NumberFormatFactory.getPriceFormatter().format(this.perSecurityPrice)).toString();
+ }
+
+ private Type determineTransactionType(String buysell) {
+ buysell = buysell.toLowerCase();
+ if (buysell.charAt(0) == 'b') {
+ return Type.Buy;
+ } else if (buysell.charAt(0) == 's') {
+ return Type.Sell;
+ } else {
+ throw new IllegalArgumentException();
+ }
+ }
+
+ private LocalDate determineExpirationDate(String strikeYearStr, String strikeMonthStr, String strikeDayStr) {
+ return this.determineExpirationDate(
+ Integer.parseInt(strikeYearStr),
+ this.parseStrikeMonth(strikeMonthStr),
+ Integer.parseInt(strikeDayStr));
+ }
+
+ private LocalDate determineExpirationDate(int strikeYear, Month strikeMonth, int strikeDay) {
+ try {
+ return LocalDate.of(Integer.valueOf(strikeYear), strikeMonth, Integer.valueOf(strikeDay));
+ } catch (NumberFormatException nfe) {
+ LocalDate expirationDate = LocalDate.of(Integer.valueOf(strikeYear), strikeMonth, 1);
+ while (!DayOfWeek.FRIDAY.equals(expirationDate.getDayOfWeek()))
+ expirationDate = expirationDate.plusDays(1L);
+ return expirationDate.plusWeeks(2L); // 3rd friday of month
+ }
+ }
+
+ @Override
+ public Transaction getTransaction() {
+ return this.tx;
+ }
+
+ @Override
+ public String getNormalizedDescription() {
+ return this.description;
+ }
+
+ @Override
+ public Type getType() {
+ return this.type;
+ }
+
+ @Override
+ public String getKey() {
+ return this.symbol + ":" + this.expirationDate + ":" + this.strikePrice + ":" + this.contractType;
+ }
+
+ @Override
+ public double getSecurities() {
+ return (double)this.securities;
+ }
+
+ @Override
+ public String getSecuritySymbol() {
+ return this.symbol;
+ }
+
+ @Override
+ public double getPricePerSecurity() {
+ return this.perSecurityPrice;
+ }
+
+ @Override
+ public ContractType getContractType() {
+ return this.contractType;
+ }
+
+ @Override
+ public LocalDate getExpirationDate() {
+ return this.expirationDate;
+ }
+
+ @Override
+ public Double getStrikePrice() {
+ return this.strikePrice;
+ }
+
+ protected Month parseStrikeMonth(String month) {
+ month = month.toLowerCase();
+ switch (month.charAt(0)) {
+ case 'f': return Month.FEBRUARY;
+ case 's': return Month.SEPTEMBER;
+ case 'o': return Month.OCTOBER;
+ case 'n': return Month.NOVEMBER;
+ case 'd': return Month.DECEMBER;
+ case 'j':
+ switch (month.substring(0, 3)) {
+ case "jan": return Month.JANUARY;
+ case "jun": return Month.JUNE;
+ case "jul": return Month.JULY;
+ default: return null;
+ }
+ case 'm':
+ switch (month.charAt(2)) {
+ case 'r': return Month.MARCH;
+ case 'y': return Month.MAY;
+ default: return null;
+ }
+ case 'a':
+ switch (month.charAt(1)) {
+ case 'p': return Month.APRIL;
+ case 'u': return Month.AUGUST;
+ default: return null;
+ }
+ default: return null;
+ }
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/TdAmeritradeParser.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/TdAmeritradeParser.java
new file mode 100644
index 0000000..4c0c8c2
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/TdAmeritradeParser.java
@@ -0,0 +1,31 @@
+package com.inteligr8.buxfer;
+
+import com.inteligr8.buxfer.model.Transaction;
+
+public class TdAmeritradeParser implements BuxferTransactionParser {
+
+ private static final TdAmeritradeParser INSTANCE = new TdAmeritradeParser();
+
+ public static TdAmeritradeParser getInstance() {
+ return INSTANCE;
+ }
+
+
+
+ private TdAmeritradeParser() {
+ }
+
+ @Override
+ public ParsedTransaction parse(Transaction buxferTx) {
+ try {
+ return new TdAmeritradeOptionTransaction(buxferTx);
+ } catch (IllegalArgumentException iae) {
+ try {
+ return new TdAmeritradeStockTransaction(buxferTx);
+ } catch (IllegalArgumentException iae2) {
+ return new TdAmeritradeDividendTransaction(buxferTx);
+ }
+ }
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/TdAmeritradeStockTransaction.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/TdAmeritradeStockTransaction.java
new file mode 100644
index 0000000..b978fdf
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/TdAmeritradeStockTransaction.java
@@ -0,0 +1,89 @@
+package com.inteligr8.buxfer;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.inteligr8.buxfer.model.Transaction;
+import com.inteligr8.buxfer.model.Transaction.Type;
+
+public class TdAmeritradeStockTransaction implements ParsedTransaction {
+
+ private final Pattern descriptionFormat =
+ Pattern.compile(".*(Bought|Sold) ([0-9\\.]+) ([A-Za-z]+) @ ([0-9\\.]+)");
+
+ private final Transaction tx;
+ private final Type type;
+ private final int securities;
+ private final String symbol;
+ private final String description;
+ private final double perSecurityPrice;
+
+ public TdAmeritradeStockTransaction(Transaction tx) {
+ this.tx = tx;
+
+ Matcher matcher = this.descriptionFormat.matcher(tx.getDescription());
+ if (!matcher.find())
+ throw new IllegalArgumentException();
+
+ String buysell = matcher.group(1);
+ this.securities = Integer.valueOf(matcher.group(2));
+ this.symbol = matcher.group(3).toUpperCase();
+ this.perSecurityPrice = Double.valueOf(matcher.group(4));
+
+ this.type = this.determineTransactionType(buysell);
+
+ String action = Type.Buy.equals(type) ? "Bought" : "Sold";
+ this.description = new StringBuilder()
+ .append(action).append(' ')
+ .append(NumberFormatFactory.getSecuritiesFormatter().format(this.securities)).append(' ')
+ .append(this.symbol).append(" @ ")
+ .append(NumberFormatFactory.getPriceFormatter().format(this.perSecurityPrice)).toString();
+ }
+
+ private Type determineTransactionType(String buysell) {
+ buysell = buysell.toLowerCase();
+ if (buysell.charAt(0) == 'b') {
+ return Type.Buy;
+ } else if (buysell.charAt(0) == 's') {
+ return Type.Sell;
+ } else {
+ throw new IllegalArgumentException();
+ }
+ }
+
+ @Override
+ public Transaction getTransaction() {
+ return this.tx;
+ }
+
+ @Override
+ public String getNormalizedDescription() {
+ return this.description;
+ }
+
+ @Override
+ public Type getType() {
+ return this.type;
+ }
+
+ @Override
+ public String getKey() {
+ return this.symbol;
+ }
+
+ @Override
+ public double getSecurities() {
+ return (double)this.securities;
+ }
+
+ @Override
+ public String getSecuritySymbol() {
+ return this.symbol;
+ }
+
+ @Override
+ public double getPricePerSecurity() {
+ return this.perSecurityPrice;
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/TransactionWrapper.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/TransactionWrapper.java
new file mode 100644
index 0000000..d977403
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/TransactionWrapper.java
@@ -0,0 +1,97 @@
+package com.inteligr8.buxfer;
+
+import com.inteligr8.buxfer.model.Transaction;
+
+public class TransactionWrapper {
+
+ private final ParsedTransaction ptx;
+ private final PartiallyReconciledTransaction prtx;
+ private double outstandingSecurities;
+ private double outstandingValue = 0.0;
+ private float splitRatio = 1f;
+
+ public TransactionWrapper(ParsedTransaction ptx) {
+ this.ptx = ptx;
+
+ if (ptx instanceof PartiallyReconciledTransaction) {
+ this.prtx = (PartiallyReconciledTransaction)ptx;
+ if (this.prtx.getUnsoldSecurities() != null)
+ this.outstandingSecurities = this.prtx.getUnsoldSecurities().doubleValue();
+ } else {
+ this.prtx = null;
+ }
+
+ this.outstandingValue = ptx.getTransaction().getAmount();
+ if (this.outstandingSecurities == 0.0) {
+ this.outstandingSecurities = ptx.getSecurities();
+ } else {
+ double frac = this.outstandingSecurities / ptx.getSecurities();
+ this.outstandingValue = Math.round(this.outstandingValue * frac * 10000L) / 10000.0;
+ }
+ }
+
+ public ParsedTransaction getParsedTransaction() {
+ return this.ptx;
+ }
+
+ public Transaction getTransaction() {
+ return this.ptx.getTransaction();
+ }
+
+ public String getDescription() {
+ if (this.outstandingSecurities == this.getParsedTransaction().getSecurities() || this.outstandingSecurities == 0.0) {
+ return this.ptx.getNormalizedDescription();
+ } else {
+ double outstandingSecurities = this.outstandingSecurities / this.splitRatio;
+ if (outstandingSecurities == this.getParsedTransaction().getSecurities()) {
+ return this.ptx.getNormalizedDescription();
+ } else {
+ return this.ptx.getNormalizedDescription() + "; " + NumberFormatFactory.getSecuritiesFormatter().format(outstandingSecurities) + " unsold";
+ }
+ }
+ }
+
+ public double getSplitAdjustedSecurities() {
+ if (this.splitRatio == 1f) {
+ return this.ptx.getSecurities();
+ } else {
+ return Math.round(this.ptx.getSecurities() * this.splitRatio);
+ }
+ }
+
+ public double getOutstandingSecurities() {
+ return this.outstandingSecurities;
+ }
+
+ public double getOutstandingValue() {
+ return this.outstandingValue;
+ }
+
+ public double decrementOutstandingSecurities(double securities) {
+ if (this.outstandingSecurities < securities)
+ throw new IllegalStateException();
+
+ this.outstandingSecurities -= securities;
+ if (this.outstandingSecurities == 0) {
+ double value = this.outstandingValue;
+ this.outstandingValue = 0.0;
+ return value;
+ }
+
+ double frac = 1.0;
+ double value = this.ptx.getTransaction().getAmount();
+ if (securities < this.getSplitAdjustedSecurities()) {
+ frac = 1.0 * securities / this.getSplitAdjustedSecurities();
+ value *= frac;
+ }
+
+ this.outstandingValue -= value;
+ return value;
+ }
+
+ public void recordSplit(float ratio) {
+ this.splitRatio *= ratio;
+ this.outstandingSecurities *= ratio;
+ }
+
+}
diff --git a/buxfer-cli/src/main/java/com/inteligr8/buxfer/WriterCLI.java b/buxfer-cli/src/main/java/com/inteligr8/buxfer/WriterCLI.java
new file mode 100644
index 0000000..232b6ea
--- /dev/null
+++ b/buxfer-cli/src/main/java/com/inteligr8/buxfer/WriterCLI.java
@@ -0,0 +1,152 @@
+package com.inteligr8.buxfer;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.DefaultParser;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.Options;
+import org.apache.commons.cli.ParseException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.inteligr8.buxfer.api.CommandApi;
+import com.inteligr8.buxfer.model.Transaction;
+import com.inteligr8.buxfer.model.TransactionsResponse;
+
+public class WriterCLI {
+
+ private static final Logger logger = LoggerFactory.getLogger(WriterCLI.class);
+
+ private enum Format {
+
+ Json,
+ Csv,
+ MyStockPortfolioCsv("my-stock-portfolio-csv");
+
+ private final String key;
+ private Format() {
+ this.key = this.toString().toLowerCase();
+ }
+
+ private Format(String key) {
+ this.key = key;
+ }
+
+ public static Format valueByKey(String key) {
+ for (Format format : Format.values())
+ if (format.key.equals(key))
+ return format;
+ return Format.valueOf(key);
+ }
+ }
+
+ public static void main(String[] args) throws ParseException, IOException, InterruptedException {
+ Options options = buildOptions();
+ String extraCommand = "[options] \"accountName\"";
+ List extraLines = Arrays.asList("accountName: A Buxfer account name");
+
+ CommandLine cli = new DefaultParser().parse(options, args);
+ if (cli.getArgList().isEmpty()) {
+ CLI.help(options, extraCommand, extraLines, "An account name is required");
+ return;
+ } else if (cli.hasOption("help")) {
+ CLI.help(options, extraCommand, extraLines);
+ return;
+ }
+
+ String buxferAccountName = determineBuxferAccountName(cli);
+ CommandApi api = findBuxferApi(cli);
+
+ File file = determineFile(cli);
+ FileOutputStream fostream = new FileOutputStream(file);
+ BufferedOutputStream bostream = new BufferedOutputStream(fostream);
+ try {
+ Observer observer = determineTransactionWriter(cli, bostream);
+
+ searchTransactions(api, buxferAccountName, observer);
+ } finally {
+ bostream.close();
+ }
+ }
+
+ private static void searchTransactions(CommandApi api, String buxferAccountName, Observer observer) throws IOException, InterruptedException {
+ long total = -1L;
+ int pages = 1;
+ for (int p = 1; p <= pages; p++) {
+ TransactionsResponse response = api.getTransactionsInAccount(buxferAccountName, null, null, null, 1).getResponse();
+ if (total < 0L) {
+ total = response.getTotalItems();
+ pages = (int)(response.getTotalItems() / 100L);
+ } else if (total != response.getTotalItems()) {
+ logger.error("Transaction count changed while processing");
+ return;
+ }
+
+ for (Transaction tx : response.getItems())
+ observer.observed(tx);
+ }
+ }
+
+ private static Options buildOptions() {
+ return new Options()
+ .addOption(new Option("u", "email", true, "A Buxfer email for authentication"))
+ .addOption(new Option("p", "password", true, "A Buxfer password for authentication"))
+ .addOption(new Option("t", "format", true, "The output format [json | csv | my-stock-portfolio-csv]"))
+ .addOption(new Option("f", "file", true, "The output filename"))
+ .addOption(new Option(null, "portfolio-name", true, "The portfolio name where applicable"));
+ }
+
+ private static String determineBuxferAccountName(CommandLine cli) {
+ return cli.getArgList().iterator().next();
+ }
+
+ private static File determineFile(CommandLine cli) {
+ Format format = Format.valueByKey(cli.getOptionValue("format", "json"));
+
+ String filename = cli.hasOption("file") ? cli.getOptionValue("file") : "portfolio";
+ if (filename.lastIndexOf('.') < 0) {
+ switch (format) {
+ case Json:
+ filename += ".json";
+ break;
+ default:
+ filename += ".csv";
+ }
+ }
+ return new File(filename);
+ }
+
+ private static Observer determineTransactionWriter(CommandLine cli, OutputStream ostream) {
+ Format format = Format.valueByKey(cli.getOptionValue("format", "json"));
+
+ switch (format) {
+ case Json:
+ return new JsonTransactionWriter(ostream);
+ case MyStockPortfolioCsv:
+ return new MyStockPortfolioCsvTransactionWriter(ostream);
+ default:
+ return new CsvTransactionWriter(ostream);
+ }
+ }
+
+ private static CommandApi findBuxferApi(CommandLine cli) {
+ BuxferClientConfiguration config = new BuxferClientConfiguration();
+ config.setBaseUrl("https://www.buxfer.com");
+
+ if (cli.hasOption("email"))
+ config.setAuthEmail(cli.getOptionValue("email"));
+ if (cli.hasOption("password"))
+ config.setAuthPassword(cli.getOptionValue("password"));
+
+ BuxferClientJerseyImpl client = new BuxferClientJerseyImpl(config);
+ return client.getApi().getCommandApi();
+ }
+
+}
diff --git a/buxfer-cli/src/test/java/com/inteligr8/buxfer/ConditionalIT.java b/buxfer-cli/src/test/java/com/inteligr8/buxfer/ConditionalIT.java
new file mode 100644
index 0000000..bd2ad6f
--- /dev/null
+++ b/buxfer-cli/src/test/java/com/inteligr8/buxfer/ConditionalIT.java
@@ -0,0 +1,35 @@
+package com.inteligr8.buxfer;
+
+import org.apache.http.HttpResponse;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.client.methods.RequestBuilder;
+import org.apache.http.impl.client.DefaultRedirectStrategy;
+import org.apache.http.impl.client.HttpClientBuilder;
+
+import com.inteligr8.rs.ClientConfiguration;
+
+public abstract class ConditionalIT {
+
+ public abstract ClientConfiguration getConfiguration();
+
+ public boolean hostExists() {
+ String uri = this.getConfiguration().getBaseUrl();
+
+ HttpUriRequest request = RequestBuilder.get()
+ .setUri(uri)
+ .build();
+
+ HttpClient client = HttpClientBuilder.create()
+ .setRedirectStrategy(DefaultRedirectStrategy.INSTANCE)
+ .build();
+
+ try {
+ HttpResponse response = client.execute(request);
+ return response.getStatusLine().getStatusCode() < 300;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+}
diff --git a/buxfer-cli/src/test/java/com/inteligr8/buxfer/ConnectionClientIT.java b/buxfer-cli/src/test/java/com/inteligr8/buxfer/ConnectionClientIT.java
new file mode 100644
index 0000000..14d1fb5
--- /dev/null
+++ b/buxfer-cli/src/test/java/com/inteligr8/buxfer/ConnectionClientIT.java
@@ -0,0 +1,176 @@
+package com.inteligr8.buxfer;
+
+import java.time.LocalDate;
+import java.util.Collection;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIf;
+
+import com.inteligr8.buxfer.api.CommandApi;
+import com.inteligr8.buxfer.model.Account;
+import com.inteligr8.buxfer.model.ArrayResponse;
+import com.inteligr8.buxfer.model.BaseResponse;
+import com.inteligr8.buxfer.model.BaseResponse.Status;
+import com.inteligr8.buxfer.model.Budget;
+import com.inteligr8.buxfer.model.Item;
+import com.inteligr8.buxfer.model.NamedItem;
+import com.inteligr8.buxfer.model.Reminder;
+import com.inteligr8.buxfer.model.Tag;
+import com.inteligr8.buxfer.model.Transaction;
+import com.inteligr8.buxfer.model.TransactionsResponse;
+
+public abstract class ConnectionClientIT extends ConditionalIT {
+
+ public abstract BuxferPublicRestApi getClient();
+
+ private CommandApi api;
+
+ public boolean fullTest() {
+ return false;
+ //return this.hostExists();
+ }
+
+ @BeforeEach
+ public void getApi() {
+ this.api = this.getClient().getCommandApi();
+ }
+
+ @Test
+ @EnabledIf("hostExists")
+ public void testTransactions() {
+ TransactionsResponse response = this.api.getTransactions(
+ LocalDate.of(2021, 12, 1),
+ LocalDate.of(2021, 12, 31),
+ null,
+ 1
+ ).getResponse();
+ this.assertArrayNotEmpty(response);
+ Assertions.assertTrue(response.getTotalItems() > 0L);
+ this.assertItems(response.getItems());
+ }
+
+ @Test
+ @EnabledIf("hostExists")
+ public void testAccounts() {
+ ArrayResponse extends Item> response = this.api.getAccounts().getResponse();
+ this.assertArrayNotEmpty(response);
+ this.assertItems(response.getItems());
+ }
+
+ @Test
+ @EnabledIf("hostExists")
+ public void testTags() {
+ ArrayResponse extends Item> response = this.api.getTags().getResponse();
+ this.assertArrayNotEmpty(response);
+ this.assertItems(response.getItems());
+ }
+
+ @Test
+ @EnabledIf("hostExists")
+ public void testBudgets() {
+ ArrayResponse extends Item> response = this.api.getBudgets().getResponse();
+ this.assertArrayNotEmpty(response);
+ this.assertItems(response.getItems());
+ }
+
+ @Test
+ @EnabledIf("hostExists")
+ public void testReminders() {
+ ArrayResponse extends Item> response = this.api.getReminders().getResponse();
+ this.assertArrayNotEmpty(response);
+ this.assertItems(response.getItems());
+ }
+
+ @Test
+ @EnabledIf("fullTest")
+ public void testGroups() {
+ ArrayResponse extends Item> response = this.api.getGroups().getResponse();
+ this.assertArrayEmpty(response);
+ }
+
+ @Test
+ @EnabledIf("fullTest")
+ public void testContacts() {
+ ArrayResponse extends Item> response = this.api.getContacts().getResponse();
+ this.assertArrayEmpty(response);
+ }
+
+ private void assertOk(BaseResponse response) {
+ Assertions.assertNotNull(response);
+ Assertions.assertEquals(Status.OK, response.getStatus());
+ Assertions.assertNull(response.getError());
+ }
+
+ private void assertArrayEmpty(ArrayResponse> response) {
+ this.assertOk(response);
+ Assertions.assertNotNull(response.getItems());
+ Assertions.assertTrue(response.getItems().isEmpty());
+ }
+
+ private void assertArrayNotEmpty(ArrayResponse> response) {
+ this.assertOk(response);
+ Assertions.assertNotNull(response.getItems());
+ Assertions.assertFalse(response.getItems().isEmpty());
+ }
+
+ private void assertItem(Item item) {
+ Assertions.assertTrue(item.getId() > 0);
+ }
+
+ private void assertNamedItem(NamedItem item) {
+ this.assertItem(item);
+ Assertions.assertNotNull(item.getName());
+ Assertions.assertNotEquals(0, item.getName().length());
+ }
+
+ private void assertSpecificItem(Item item) {
+ if (item instanceof Transaction) {
+ this.assertTransaction((Transaction)item);
+ } else if (item instanceof Budget) {
+ this.assertBudget((Budget)item);
+ } else if (item instanceof Reminder) {
+ this.assertReminder((Reminder)item);
+ } else if (item instanceof Tag) {
+ this.assertTag((Tag)item);
+ } else if (item instanceof Account) {
+ this.assertAccount((Account)item);
+ }
+ }
+
+ private void assertTransaction(Transaction item) {
+ this.assertItem(item);
+ Assertions.assertNotNull(item.getType());
+ Assertions.assertNotNull(item.getAmount());
+ Assertions.assertNotNull(item.getDate());
+ }
+
+ private void assertAccount(Account item) {
+ this.assertNamedItem(item);
+ Assertions.assertNotNull(item.getBank());
+ Assertions.assertNotEquals(0, item.getBank().length());
+ Assertions.assertNotNull(item.getBalance());
+ }
+
+ private void assertTag(Tag item) {
+ this.assertNamedItem(item);
+ }
+
+ private void assertBudget(Budget item) {
+ this.assertNamedItem(item);
+ }
+
+ private void assertReminder(Reminder item) {
+ this.assertNamedItem(item);
+ Assertions.assertNotNull(item.getStartDate());
+ Assertions.assertTrue(item.getStartDate().isAfter(LocalDate.of(2010, 1, 1)));
+ }
+
+ private void assertItems(Collection extends Item> items) {
+ for (Item item : items) {
+ this.assertSpecificItem(item);
+ }
+ }
+
+}
diff --git a/buxfer-cli/src/test/resources/log4j2.xml b/buxfer-cli/src/test/resources/log4j2.xml
new file mode 100644
index 0000000..7b674e2
--- /dev/null
+++ b/buxfer-cli/src/test/resources/log4j2.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/buxfer-public-rest-api/pom.xml b/buxfer-public-rest-api/pom.xml
index ddf9310..b1c11e9 100644
--- a/buxfer-public-rest-api/pom.xml
+++ b/buxfer-public-rest-api/pom.xml
@@ -2,16 +2,15 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0
- com.inteligr8.buxfer
buxfer-public-rest-api
- 1.0-SNAPSHOT
Buxfer ReST API for Java
-
- utf-8
- 8
- 8
-
+
+ com.inteligr8.buxfer
+ buxfer-public-rest-parent
+ 1.0-SNAPSHOT
+ ../
+
@@ -43,22 +42,4 @@
-
-
-
- inteligr8-public
- https://repos.inteligr8.com/nexus/repository/inteligr8-public
-
-
-
-
-
- inteligr8-releases
- https://repos.inteligr8.com/nexus/repository/inteligr8-public
-
-
- inteligr8-snapshots
- https://repos.inteligr8.com/nexus/repository/inteligr8-snapshots
-
-
diff --git a/buxfer-public-rest-api/src/main/java/com/inteligr8/buxfer/api/CommandApi.java b/buxfer-public-rest-api/src/main/java/com/inteligr8/buxfer/api/CommandApi.java
index ec75c05..46eea5c 100644
--- a/buxfer-public-rest-api/src/main/java/com/inteligr8/buxfer/api/CommandApi.java
+++ b/buxfer-public-rest-api/src/main/java/com/inteligr8/buxfer/api/CommandApi.java
@@ -3,10 +3,14 @@ package com.inteligr8.buxfer.api;
import java.time.LocalDate;
import java.time.YearMonth;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.FormParam;
import javax.ws.rs.GET;
+import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.MediaType;
import com.inteligr8.buxfer.model.AccountsResponse;
import com.inteligr8.buxfer.model.BudgetsResponse;
@@ -17,6 +21,7 @@ import com.inteligr8.buxfer.model.RemindersResponse;
import com.inteligr8.buxfer.model.Response;
import com.inteligr8.buxfer.model.TagsResponse;
import com.inteligr8.buxfer.model.Transaction.Status;
+import com.inteligr8.buxfer.model.TransactionResponse;
import com.inteligr8.buxfer.model.TransactionsResponse;
@Path("/api")
@@ -24,7 +29,7 @@ public interface CommandApi {
@GET
@Path("/transactions")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getTransactions(
@QueryParam("startDate") LocalDate startDate,
@QueryParam("endDate") LocalDate endDate,
@@ -33,7 +38,7 @@ public interface CommandApi {
@GET
@Path("/transactions")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getTransactionsInTag(
@QueryParam("tagName") String tagName,
@QueryParam("startDate") LocalDate startDate,
@@ -43,7 +48,7 @@ public interface CommandApi {
@GET
@Path("/transactions")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getTransactionsInAccount(
@QueryParam("accountName") String accountName,
@QueryParam("startDate") LocalDate startDate,
@@ -53,7 +58,7 @@ public interface CommandApi {
@GET
@Path("/transactions")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getTransactions(
@QueryParam("accountName") String accountName,
@QueryParam("tagName") String tagName,
@@ -67,7 +72,7 @@ public interface CommandApi {
@GET
@Path("/transactions")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getTransactions(
@QueryParam("accountName") String accountName,
@QueryParam("tagName") String tagName,
@@ -80,108 +85,184 @@ public interface CommandApi {
@GET
@Path("/transactions")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getTransactionsByIds(
- @QueryParam("accountId") String accountId,
- @QueryParam("tagId") String tagId,
+ @QueryParam("accountId") Long accountId,
+ @QueryParam("tagId") Long tagId,
@QueryParam("startDate") LocalDate startDate,
@QueryParam("endDate") LocalDate endDate,
- @QueryParam("budgetId") String budgetId,
- @QueryParam("contactId") String contactId,
- @QueryParam("groupId") String groupId,
+ @QueryParam("budgetId") Long budgetId,
+ @QueryParam("contactId") Long contactId,
+ @QueryParam("groupId") Long groupId,
@QueryParam("status") Status status,
@QueryParam("page") int page);
@GET
@Path("/transactions")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getTransactionsByIds(
- @QueryParam("accountId") String accountId,
- @QueryParam("tagId") String tagId,
+ @QueryParam("accountId") Long accountId,
+ @QueryParam("tagId") Long tagId,
@QueryParam("startDate") YearMonth month,
- @QueryParam("budgetId") String budgetId,
- @QueryParam("contactId") String contactId,
- @QueryParam("groupId") String groupId,
+ @QueryParam("budgetId") Long budgetId,
+ @QueryParam("contactId") Long contactId,
+ @QueryParam("groupId") Long groupId,
@QueryParam("status") Status status,
@QueryParam("page") int page);
@GET
@Path("/transactions")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getTransactions(
- @QueryParam("accountId") String accountId,
+ @QueryParam("accountId") Long accountId,
@QueryParam("accountName") String accountName,
- @QueryParam("tagId") String tagId,
+ @QueryParam("tagId") Long tagId,
@QueryParam("tagName") String tagName,
@QueryParam("startDate") LocalDate startDate,
@QueryParam("endDate") LocalDate endDate,
- @QueryParam("budgetId") String budgetId,
+ @QueryParam("budgetId") Long budgetId,
@QueryParam("budgetName") String budgetName,
- @QueryParam("contactId") String contactId,
+ @QueryParam("contactId") Long contactId,
@QueryParam("contactName") String contactName,
- @QueryParam("groupId") String groupId,
+ @QueryParam("groupId") Long groupId,
@QueryParam("groupName") String groupName,
@QueryParam("status") Status status,
@QueryParam("page") int page);
@GET
@Path("/transactions")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getTransactions(
- @QueryParam("accountId") String accountId,
+ @QueryParam("accountId") Long accountId,
@QueryParam("accountName") String accountName,
- @QueryParam("tagId") String tagId,
+ @QueryParam("tagId") Long tagId,
@QueryParam("tagName") String tagName,
@QueryParam("month") YearMonth month,
- @QueryParam("budgetId") String budgetId,
+ @QueryParam("budgetId") Long budgetId,
@QueryParam("budgetName") String budgetName,
- @QueryParam("contactId") String contactId,
+ @QueryParam("contactId") Long contactId,
@QueryParam("contactName") String contactName,
- @QueryParam("groupId") String groupId,
+ @QueryParam("groupId") Long groupId,
@QueryParam("groupName") String groupName,
@QueryParam("status") Status status,
@QueryParam("page") int page);
- @GET
- @Path("/transactions")
- @Produces({ "application/json" })
- public String getJson(
- @QueryParam("accountName") String accountName,
- @QueryParam("page") int page);
+ @POST
+ @Path("/transaction_add")
+ @Consumes({ MediaType.APPLICATION_FORM_URLENCODED })
+ @Produces({ MediaType.APPLICATION_JSON })
+ public Response addTransaction(
+ @FormParam("type") String type,
+ @FormParam("accountId") long accountId,
+ @FormParam("date") LocalDate date,
+ @FormParam("description") String description,
+ @FormParam("amount") double amount,
+ @FormParam("tags") String tags,
+ @FormParam("status") Status status);
+
+ @POST
+ @Path("/transaction_add")
+ @Consumes({ MediaType.APPLICATION_FORM_URLENCODED })
+ @Produces({ MediaType.APPLICATION_JSON })
+ public Response addTransaction(
+ @FormParam("type") String type,
+ @FormParam("fromAccountId") Long fromAccountId,
+ @FormParam("toAccountId") Long toAccountId,
+ @FormParam("date") LocalDate date,
+ @FormParam("description") String description,
+ @FormParam("amount") double amount,
+ @FormParam("tags") String tags,
+ @FormParam("status") Status status);
+
+ @POST
+ @Path("/transaction_edit")
+ @Consumes({ MediaType.APPLICATION_FORM_URLENCODED })
+ @Produces({ MediaType.APPLICATION_JSON })
+ public Response editTransaction(
+ @FormParam("id") long id,
+ @FormParam("description") String description,
+ @FormParam("tags") String tags,
+ @FormParam("status") Status status);
+
+ @POST
+ @Path("/transaction_edit")
+ @Consumes({ MediaType.APPLICATION_FORM_URLENCODED })
+ @Produces({ MediaType.APPLICATION_JSON })
+ public Response editTransaction(
+ @FormParam("id") long id,
+ @FormParam("type") String type,
+ @FormParam("description") String description,
+ @FormParam("tags") String tags,
+ @FormParam("status") Status status);
+
+ @POST
+ @Path("/transaction_edit")
+ @Consumes({ MediaType.APPLICATION_FORM_URLENCODED })
+ @Produces({ MediaType.APPLICATION_JSON })
+ public Response editTransaction(
+ @FormParam("id") long id,
+ @FormParam("type") String type,
+ @FormParam("accountId") Long accountId,
+ @FormParam("date") LocalDate date,
+ @FormParam("description") String description,
+ @FormParam("amount") Double amount,
+ @FormParam("tags") String tags,
+ @FormParam("status") Status status);
+
+ @POST
+ @Path("/transaction_edit")
+ @Consumes({ MediaType.APPLICATION_FORM_URLENCODED })
+ @Produces({ MediaType.APPLICATION_JSON })
+ public Response editTransaction(
+ @FormParam("id") long id,
+ @FormParam("type") String type,
+ @FormParam("fromAccountId") Long fromAccountId,
+ @FormParam("toAccountId") Long toAccountId,
+ @FormParam("date") LocalDate date,
+ @FormParam("description") String description,
+ @FormParam("amount") Double amount,
+ @FormParam("tags") String tags,
+ @FormParam("status") Status status);
+
+ @POST
+ @Path("/transaction_delete")
+ @Consumes({ MediaType.APPLICATION_FORM_URLENCODED })
+ public void deleteTransaction(
+ @FormParam("id") long id);
@GET
@Path("/accounts")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getAccounts();
@GET
@Path("/loans")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getLoans();
@GET
@Path("/tags")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getTags();
@GET
@Path("/budgets")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getBudgets();
@GET
@Path("/reminders")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getReminders();
@GET
@Path("/groups")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getGroups();
@GET
@Path("/contacts")
- @Produces({ "application/json" })
+ @Produces({ MediaType.APPLICATION_JSON })
public Response getContacts();
}
diff --git a/buxfer-public-rest-api/src/main/java/com/inteligr8/buxfer/model/ItemResponse.java b/buxfer-public-rest-api/src/main/java/com/inteligr8/buxfer/model/ItemResponse.java
new file mode 100644
index 0000000..dce71d5
--- /dev/null
+++ b/buxfer-public-rest-api/src/main/java/com/inteligr8/buxfer/model/ItemResponse.java
@@ -0,0 +1,22 @@
+package com.inteligr8.buxfer.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class ItemResponse extends BaseResponse {
+
+ @JsonProperty
+ private String id;
+
+
+
+ public String getId() {
+ return this.id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+}
diff --git a/buxfer-public-rest-api/src/main/java/com/inteligr8/buxfer/model/Transaction.java b/buxfer-public-rest-api/src/main/java/com/inteligr8/buxfer/model/Transaction.java
index 1b199e5..b533b17 100644
--- a/buxfer-public-rest-api/src/main/java/com/inteligr8/buxfer/model/Transaction.java
+++ b/buxfer-public-rest-api/src/main/java/com/inteligr8/buxfer/model/Transaction.java
@@ -1,11 +1,25 @@
package com.inteligr8.buxfer.model;
+import java.io.IOException;
import java.time.LocalDate;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonValue;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
/**
* {
@@ -47,7 +61,66 @@ public class Transaction extends Item {
Cleared
}
+ private static final Map incomingTypeMap = new HashMap<>(20);
+
+ @JsonDeserialize(using = Type.Deserializer.class)
+ @JsonSerialize(using = Type.Serializer.class)
public enum Type {
+ Income("income"),
+ Expense("expense"),
+ Refund("refund"),
+ Transfer("transfer"),
+ Buy("investment purchase", "investment_buy"),
+ Sell("investment sale", "investment_sell"),
+ Dividend("dividend", "investment_dividend"),
+ CapitalGain("capital gain", "capital_gain"),
+ SharedBill("sharedBill"),
+ PaidForFriend("paidForFriend"),
+ Loan("loan"),
+ Settlement("settlement");
+
+ private final String outgoingValue;
+
+ private Type(String value) {
+ this(value.toLowerCase(), value);
+ }
+
+ private Type(String incomingValue, String outgoingValue) {
+ this.outgoingValue = outgoingValue;
+ incomingTypeMap.put(incomingValue, this);
+ }
+
+ @JsonValue
+ public String getOutgoingValue() {
+ return this.outgoingValue;
+ }
+
+ @JsonCreator
+ public static Type fromIncomingValue(String value) {
+ return incomingTypeMap.get(value);
+ }
+
+ // FIXME the serializer is not getting called when expected; but deserializer is getting called and works
+ public static class Serializer extends JsonSerializer {
+ public Serializer() {}
+ @Override
+ public void serialize(Type value, JsonGenerator gen, SerializerProvider provider) throws IOException {
+ gen.writeString(value.outgoingValue);
+ }
+ }
+
+ public static class Deserializer extends JsonDeserializer {
+ public Deserializer() {}
+ @Override
+ public Type deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
+ String value = p.getText();
+ return Type.fromIncomingValue(value);
+ }
+ }
+
+ }
+
+ public enum IncomingType {
@JsonProperty("income")
Income,
@JsonProperty("expense")
@@ -62,12 +135,51 @@ public class Transaction extends Item {
Sell,
@JsonProperty("dividend")
Dividend,
+ @JsonProperty("capital gain")
+ CapitalGain,
@JsonProperty("sharedbill")
SharedBill,
@JsonProperty("paidforfriend")
PaidForFriend,
@JsonProperty("loan")
- Loan
+ Loan,
+ @JsonProperty("settlement")
+ Settlement;
+
+ public OutgoingType toOutgoingType() {
+ return OutgoingType.valueOf(this.toString());
+ }
+ }
+
+ public enum OutgoingType {
+ @JsonProperty("income")
+ Income,
+ @JsonProperty("expense")
+ Expense,
+ @JsonProperty("refund")
+ Refund,
+ @JsonProperty("transfer")
+ Transfer,
+ @JsonProperty("investment_buy")
+ Buy,
+ @JsonProperty("investment_sell")
+ Sell,
+ @JsonProperty("investment_dividend")
+ Dividend,
+ @JsonProperty("capital_gain")
+ CapitalGain,
+ @JsonProperty("sharedBill")
+ SharedBill,
+ @JsonProperty("paidForFriend")
+ PaidForFriend,
+ @JsonProperty("loan")
+ Loan,
+ @JsonProperty("settlement")
+ Settlement;
+
+ public IncomingType toIncomingType() {
+ return IncomingType.valueOf(this.toString());
+ }
}
@JsonProperty
diff --git a/buxfer-public-rest-api/src/main/java/com/inteligr8/buxfer/model/TransactionResponse.java b/buxfer-public-rest-api/src/main/java/com/inteligr8/buxfer/model/TransactionResponse.java
new file mode 100644
index 0000000..3c86ca2
--- /dev/null
+++ b/buxfer-public-rest-api/src/main/java/com/inteligr8/buxfer/model/TransactionResponse.java
@@ -0,0 +1,22 @@
+package com.inteligr8.buxfer.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class TransactionResponse {
+
+ @JsonProperty
+ private String id;
+
+
+
+ public String getId() {
+ return this.id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+}
diff --git a/buxfer-public-rest-client/pom.xml b/buxfer-public-rest-client/pom.xml
index 4d660c0..4dc6561 100644
--- a/buxfer-public-rest-client/pom.xml
+++ b/buxfer-public-rest-client/pom.xml
@@ -2,19 +2,19 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0
- com.inteligr8.buxfer
buxfer-public-rest-client
- 1.0-SNAPSHOT
Buxfer ReST Client for Java
+
+ com.inteligr8.buxfer
+ buxfer-public-rest-parent
+ 1.0-SNAPSHOT
+ ../
+
+
- utf-8
- 8
- 8
-
jersey
- 5.7.2
5.2.14.RELEASE
false
@@ -27,9 +27,9 @@
1.1-SNAPSHOT
- com.inteligr8.buxfer
+ ${project.parent.groupId}
buxfer-public-rest-api
- 1.0-SNAPSHOT
+ ${project.parent.version}
com.fasterxml.jackson.datatype
@@ -39,7 +39,6 @@
org.junit.jupiter
junit-jupiter-api
- ${junit.version}
test
@@ -89,28 +88,6 @@
${jaxrs.impl}
-
- maven-surefire-plugin
- 3.0.0-M5
-
-
- org.junit.jupiter
- junit-jupiter-engine
- ${junit.version}
-
-
-
-
- maven-failsafe-plugin
- 3.0.0-M5
-
-
- org.junit.jupiter
- junit-jupiter-engine
- ${junit.version}
-
-
-
@@ -126,31 +103,26 @@
jersey
- 2.34
org.glassfish.jersey.core
jersey-client
- ${jersey.version}
provided
org.glassfish.jersey.ext
jersey-proxy-client
- ${jersey.version}
test
org.glassfish.jersey.inject
jersey-hk2
- ${jersey.version}
test
org.glassfish.jersey.media
jersey-media-json-jackson
- ${jersey.version}
test
@@ -165,41 +137,14 @@
cxf
- 3.3.2
org.apache.cxf
cxf-rt-rs-client
- ${cxf.version}
provided
-
-
-
- inteligr8-public
- https://repos.inteligr8.com/nexus/repository/inteligr8-public
-
-
-
-
-
- inteligr8-public
- https://repos.inteligr8.com/nexus/repository/inteligr8-public
-
-
-
-
-
- inteligr8-releases
- https://repos.inteligr8.com/nexus/repository/inteligr8-public
-
-
- inteligr8-snapshots
- https://repos.inteligr8.com/nexus/repository/inteligr8-snapshots
-
-
diff --git a/buxfer-public-rest-client/src/main/java/com/inteligr8/buxfer/BuxferAuthorizationFilter.java b/buxfer-public-rest-client/src/main/java/com/inteligr8/buxfer/BuxferAuthorizationFilter.java
index 660110f..07cb9a5 100644
--- a/buxfer-public-rest-client/src/main/java/com/inteligr8/buxfer/BuxferAuthorizationFilter.java
+++ b/buxfer-public-rest-client/src/main/java/com/inteligr8/buxfer/BuxferAuthorizationFilter.java
@@ -2,6 +2,7 @@ package com.inteligr8.buxfer;
import java.io.IOException;
+import javax.ws.rs.NotAuthorizedException;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.client.ClientRequestContext;
import javax.ws.rs.client.Entity;
@@ -50,16 +51,22 @@ public class BuxferAuthorizationFilter implements AuthorizationFilter {
GenericType> responseType = new GenericType>() {};
- TokenResponse response = requestContext.getClient()
- .target(loginUri)
- .request()
- .accept(MediaType.APPLICATION_JSON)
- .post(Entity.form(form), responseType)
- .getResponse();
-
- if (!com.inteligr8.buxfer.model.BaseResponse.Status.OK.equals(response.getStatus()))
- throw new WebApplicationException(response.getError(), Status.UNAUTHORIZED.getStatusCode());
- this.token = response.getToken();
+ try {
+ TokenResponse response = requestContext.getClient()
+ .target(loginUri)
+ .request()
+ .accept(MediaType.APPLICATION_JSON)
+ .post(Entity.form(form), responseType)
+ .getResponse();
+
+ if (!com.inteligr8.buxfer.model.BaseResponse.Status.OK.equals(response.getStatus()))
+ throw new WebApplicationException(response.getError(), Status.UNAUTHORIZED.getStatusCode());
+ this.token = response.getToken();
+ } catch (NotAuthorizedException nae) {
+ throw nae;
+ } catch (WebApplicationException wae) {
+ throw new NotAuthorizedException("Indirect due to non-authorization failure: " + wae.getMessage(), wae);
+ }
}
}
diff --git a/buxfer-public-rest-client/src/main/java/com/inteligr8/buxfer/BuxferClientConfiguration.java b/buxfer-public-rest-client/src/main/java/com/inteligr8/buxfer/BuxferClientConfiguration.java
index af18a4f..202e407 100644
--- a/buxfer-public-rest-client/src/main/java/com/inteligr8/buxfer/BuxferClientConfiguration.java
+++ b/buxfer-public-rest-client/src/main/java/com/inteligr8/buxfer/BuxferClientConfiguration.java
@@ -20,7 +20,7 @@ import com.inteligr8.rs.ClientJerseyConfiguration;
@ComponentScan
public class BuxferClientConfiguration implements ClientCxfConfiguration, ClientJerseyConfiguration {
- @Value("${buxfer.service.baseUrl:http://localhost:8080/alfresco}")
+ @Value("${buxfer.service.baseUrl:https://www.buxfer.com}")
private String baseUrl;
@Value("${buxfer.service.security.auth.email}")
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..526a9d2
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,136 @@
+
+ 4.0.0
+ com.inteligr8.buxfer
+ buxfer-public-rest-parent
+ 1.0-SNAPSHOT
+ pom
+ Buxfer Public ReST Projects
+
+
+ https://bitbucket.org/inteligr8/buxfer-public-rest
+
+
+ Inteligr8
+ https://www.inteligr8.com
+
+
+
+ Brian Long
+ brian@inteligr8.com
+ https://twitter.com/brianmlong
+
+
+
+
+ utf-8
+ 8
+ 8
+
+ 2.17.0
+ 2.35
+ 3.3.2
+ 5.8.2
+
+
+
+ buxfer-public-rest-api
+ buxfer-public-rest-client
+ buxfer-cli
+
+
+
+
+
+ org.apache.logging.log4j
+ log4j-slf4j-impl
+ ${log4j.version}
+
+
+ org.glassfish.jersey.core
+ jersey-client
+ ${jersey.version}
+
+
+ org.glassfish.jersey.ext
+ jersey-proxy-client
+ ${jersey.version}
+
+
+ org.glassfish.jersey.inject
+ jersey-hk2
+ ${jersey.version}
+
+
+ org.glassfish.jersey.media
+ jersey-media-json-jackson
+ ${jersey.version}
+
+
+ org.apache.cxf
+ cxf-rt-rs-client
+ ${cxf.version}
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ ${junit.version}
+
+
+
+
+
+
+
+
+ maven-surefire-plugin
+ 3.0.0-M5
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ ${junit.version}
+
+
+
+
+ maven-failsafe-plugin
+ 3.0.0-M5
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ ${junit.version}
+
+
+
+
+
+
+
+
+
+ inteligr8-public
+ https://repos.inteligr8.com/nexus/repository/inteligr8-public
+
+
+
+
+
+ inteligr8-public
+ https://repos.inteligr8.com/nexus/repository/inteligr8-public
+
+
+
+
+
+ inteligr8-releases
+ https://repos.inteligr8.com/nexus/repository/inteligr8-public
+
+
+ inteligr8-snapshots
+ https://repos.inteligr8.com/nexus/repository/inteligr8-snapshots
+
+
+