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 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 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 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 response = this.api.getAccounts().getResponse(); + this.assertArrayNotEmpty(response); + this.assertItems(response.getItems()); + } + + @Test + @EnabledIf("hostExists") + public void testTags() { + ArrayResponse response = this.api.getTags().getResponse(); + this.assertArrayNotEmpty(response); + this.assertItems(response.getItems()); + } + + @Test + @EnabledIf("hostExists") + public void testBudgets() { + ArrayResponse response = this.api.getBudgets().getResponse(); + this.assertArrayNotEmpty(response); + this.assertItems(response.getItems()); + } + + @Test + @EnabledIf("hostExists") + public void testReminders() { + ArrayResponse response = this.api.getReminders().getResponse(); + this.assertArrayNotEmpty(response); + this.assertItems(response.getItems()); + } + + @Test + @EnabledIf("fullTest") + public void testGroups() { + ArrayResponse response = this.api.getGroups().getResponse(); + this.assertArrayEmpty(response); + } + + @Test + @EnabledIf("fullTest") + public void testContacts() { + ArrayResponse 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 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 + + +