refactored/tested

This commit is contained in:
Brian Long 2022-01-04 15:38:30 -05:00
parent c987bc725f
commit 7ac5c69eff
39 changed files with 2670 additions and 143 deletions

3
buxfer-cli/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Personal
buxfer-personal.properties

110
buxfer-cli/pom.xml Normal file
View File

@ -0,0 +1,110 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
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">
<modelVersion>4.0.0</modelVersion>
<artifactId>buxfer-cli</artifactId>
<name>Buxfer CLI</name>
<parent>
<groupId>com.inteligr8.buxfer</groupId>
<artifactId>buxfer-public-rest-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<relativePath>../</relativePath>
</parent>
<properties>
<jaxrs.impl>jersey</jaxrs.impl>
<spring.version>5.2.14.RELEASE</spring.version>
</properties>
<dependencies>
<dependency>
<groupId>commons-cli</groupId>
<artifactId>commons-cli</artifactId>
<version>1.5.0</version>
</dependency>
<dependency>
<groupId>${project.parent.groupId}</groupId>
<artifactId>buxfer-public-rest-client</artifactId>
<version>${project.parent.version}</version>
<classifier>jersey</classifier>
</dependency>
<dependency>
<groupId>com.inteligr8.polygon</groupId>
<artifactId>polygon-public-rest-client</artifactId>
<version>1.0-SNAPSHOT</version>
<classifier>jersey</classifier>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.core</groupId>
<artifactId>jersey-client</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.ext</groupId>
<artifactId>jersey-proxy-client</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.inject</groupId>
<artifactId>jersey-hk2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-json-jackson</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>${spring.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.9</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- This plugin causes the packaging of a JAR with embedded dependencies -->
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.3.0</version>
<executions>
<execution>
<id>main-jar</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
<configuration>
<archive>
<manifest>
<mainClass>com.inteligr8.buxfer.CLI</mainClass>
</manifest>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%msg%n"/>
</Console>
</Appenders>
<Loggers>
<Logger name="com.inteligr8.buxfer" level="info" additivity="false">
<AppenderRef ref="Console" />
</Logger>
<Root level="warn">
<AppenderRef ref="Console" />
</Root>
</Loggers>
</Configuration>

View File

@ -0,0 +1,9 @@
package com.inteligr8.buxfer;
import com.inteligr8.buxfer.model.Transaction;
public interface BuxferTransactionParser {
ParsedTransaction parse(Transaction buxferTx) throws InterruptedException;
}

View File

@ -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<String> extraLines, String message) {
System.err.println(message);
help(options, extraCommand, extraLines);
}
static void help(Options options, String extraCommand, List<String> 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);
}
}

View File

@ -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<Transaction> {
private final OutputStream ostream;
public CsvTransactionWriter(OutputStream ostream) {
this.ostream = ostream;
}
@Override
public int observed(Transaction tx) throws IOException {
return 0;
}
}

View File

@ -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<String, Double> unsoldShares = new HashMap<>();
private static final Map<String, List<TransactionWrapper>> unsoldBuys = new HashMap<>();
public static void main(String[] args) throws ParseException, IOException, InterruptedException {
Options options = buildOptions();
String extraCommand = "[options] \"accountName\"";
List<String> 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<Transaction> observer = new Observer<Transaction>() {
@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<TransactionWrapper> 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<TransactionWrapper> txws = unsoldBuys.get(txw.getParsedTransaction().getKey());
if (txws == null)
unsoldBuys.put(txw.getParsedTransaction().getKey(), txws = new LinkedList<TransactionWrapper>());
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<TransactionWrapper> buyTxws = unsoldBuys.get(txw.getParsedTransaction().getKey());
ListIterator<TransactionWrapper> i = buyTxws.listIterator();
//ListIterator<TransactionWrapper> 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<Transaction> 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<Transaction>() {
@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();
}
}

View File

@ -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<String> 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<BuxferTransactionParser> parsers = Arrays.asList(
TdAmeritradeParser.getInstance(),
SofiInvestParser.getInstance(papi));
Observer<Transaction> observer = new Observer<Transaction>() {
@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<Transaction> 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();
}
}

View File

@ -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<Transaction> {
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;
}
}

View File

@ -0,0 +1,11 @@
package com.inteligr8.buxfer;
import java.io.OutputStream;
public class MyStockPortfolioCsvTransactionWriter extends CsvTransactionWriter {
public MyStockPortfolioCsvTransactionWriter(OutputStream ostream) {
super(ostream);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,39 @@
package com.inteligr8.buxfer;
public class NumericRange<T extends Number> {
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;
}
}

View File

@ -0,0 +1,9 @@
package com.inteligr8.buxfer;
import java.io.IOException;
public interface Observer<T> {
int observed(T t) throws IOException, InterruptedException;
}

View File

@ -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;
}
}

View File

@ -0,0 +1,7 @@
package com.inteligr8.buxfer;
public interface PartiallyReconciledTransaction {
Double getUnsoldSecurities();
}

View File

@ -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);
}
}

View File

@ -0,0 +1,163 @@
package com.inteligr8.buxfer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.inteligr8.buxfer.model.Transaction;
import com.inteligr8.buxfer.model.Transaction.Type;
import com.inteligr8.polygon.PolygonPublicRestApi;
import com.inteligr8.polygon.model.StockDateSummary;
public class SofiInvestTransaction implements ParsedTransaction {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private final Pattern descriptionFormat =
Pattern.compile("(Buy|Sell|Dividend) ([A-Za-z]+)");
private static long nextExecutionTime = 0L;
private final long polygonServiceThrottleTime = 20L * 1000L;
private final PolygonPublicRestApi papi;
private final Transaction tx;
private final Type type;
private final NumericRange<? extends Number> securities;
private final String symbol;
private final String description;
private final NumericRange<Double> perSecurityPrice;
public SofiInvestTransaction(Transaction tx, PolygonPublicRestApi papi) throws InterruptedException {
this.papi = papi;
this.tx = tx;
Matcher matcher = this.descriptionFormat.matcher(tx.getDescription());
if (!matcher.find())
throw new IllegalArgumentException();
String buysell = matcher.group(1);
this.symbol = matcher.group(2).toUpperCase();
this.type = this.determineTransactionType(buysell);
if (Type.Dividend.equals(this.type)) {
this.description = "Dividend " + this.symbol;
this.securities = null;
this.perSecurityPrice = null;
} else {
this.throttle();
this.securities = this.estimateSecurites();
this.perSecurityPrice = this.estimatePrice(securities);
String action = Type.Buy.equals(type) ? "Bought" : "Sold";
this.description = new StringBuilder()
.append(action).append(' ')
.append(this.securities == null ? "NaN" : NumberFormatFactory.getSecuritiesFormatter().format(this.securities)).append(' ')
.append(this.symbol).append(" @ ")
.append(this.perSecurityPrice == null ? "NaN" : NumberFormatFactory.getPriceFormatter().format(this.perSecurityPrice)).toString();
}
}
private Type determineTransactionType(String buysell) {
buysell = buysell.toLowerCase();
if (buysell.charAt(0) == 'b') {
return Type.Buy;
} else if (buysell.charAt(0) == 's') {
return Type.Sell;
} else if (buysell.charAt(0) == 'd') {
return Type.Dividend;
} else {
throw new IllegalArgumentException();
}
}
private synchronized void throttle() throws InterruptedException {
// Polygon.IO throttling
if (SofiInvestTransaction.nextExecutionTime > System.currentTimeMillis()) {
long waitTime = SofiInvestTransaction.nextExecutionTime - System.currentTimeMillis();
logger.debug("Throttled for Polygon.IO service for {} ms", waitTime);
Thread.sleep(waitTime);
}
SofiInvestTransaction.nextExecutionTime = System.currentTimeMillis() + this.polygonServiceThrottleTime;
}
protected NumericRange<? extends Number> estimateSecurites() {
this.logger.debug("Searching for price activity for {} on {}", symbol, this.tx.getDate());
StockDateSummary summary = this.papi.getStocksApi().getStockSummaryOnDate(this.symbol, this.tx.getDate(), false);
if (summary.getLow() == null || summary.getHigh() == null)
return null;
double maxFracShares = this.tx.getAmount() / summary.getLow();
double minFracShares = this.tx.getAmount() / summary.getHigh();
int maxShares = (int)Math.floor(maxFracShares);
int minShares = (int)Math.ceil(minFracShares);
if (minShares == maxShares) {
logger.debug("Found the highly likely shares: {}", minShares);
return new NumericRange<Integer>(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<Integer>(minShares, maxShares);
} else if (maxFracShares - maxShares < 0.05) {
logger.debug("Found the highly likely shares (w/ commission): {}", maxShares);
return new NumericRange<Integer>(maxShares);
} else {
logger.info("Found a fractional range of possible shares; needs manual update: {} => {}", this.tx.getDate(), this.tx.getDescription());
return new NumericRange<Double>(minFracShares, maxFracShares);
}
}
protected NumericRange<Double> estimatePrice(NumericRange<? extends Number> securities) {
if (securities == null)
return null;
if (securities.isPoint())
return new NumericRange<Double>(Math.round(this.tx.getAmount().doubleValue() * 100 / securities.getFrom().doubleValue()) / 100.0);
return new NumericRange<Double>(
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();
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<String> 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<Transaction> observer = determineTransactionWriter(cli, bostream);
searchTransactions(api, buxferAccountName, observer);
} finally {
bostream.close();
}
}
private static void searchTransactions(CommandApi api, String buxferAccountName, Observer<Transaction> 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<Transaction> 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();
}
}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,176 @@
package com.inteligr8.buxfer;
import java.time.LocalDate;
import java.util.Collection;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIf;
import com.inteligr8.buxfer.api.CommandApi;
import com.inteligr8.buxfer.model.Account;
import com.inteligr8.buxfer.model.ArrayResponse;
import com.inteligr8.buxfer.model.BaseResponse;
import com.inteligr8.buxfer.model.BaseResponse.Status;
import com.inteligr8.buxfer.model.Budget;
import com.inteligr8.buxfer.model.Item;
import com.inteligr8.buxfer.model.NamedItem;
import com.inteligr8.buxfer.model.Reminder;
import com.inteligr8.buxfer.model.Tag;
import com.inteligr8.buxfer.model.Transaction;
import com.inteligr8.buxfer.model.TransactionsResponse;
public abstract class ConnectionClientIT extends ConditionalIT {
public abstract BuxferPublicRestApi getClient();
private CommandApi api;
public boolean fullTest() {
return false;
//return this.hostExists();
}
@BeforeEach
public void getApi() {
this.api = this.getClient().getCommandApi();
}
@Test
@EnabledIf("hostExists")
public void testTransactions() {
TransactionsResponse response = this.api.getTransactions(
LocalDate.of(2021, 12, 1),
LocalDate.of(2021, 12, 31),
null,
1
).getResponse();
this.assertArrayNotEmpty(response);
Assertions.assertTrue(response.getTotalItems() > 0L);
this.assertItems(response.getItems());
}
@Test
@EnabledIf("hostExists")
public void testAccounts() {
ArrayResponse<? extends Item> response = this.api.getAccounts().getResponse();
this.assertArrayNotEmpty(response);
this.assertItems(response.getItems());
}
@Test
@EnabledIf("hostExists")
public void testTags() {
ArrayResponse<? extends Item> response = this.api.getTags().getResponse();
this.assertArrayNotEmpty(response);
this.assertItems(response.getItems());
}
@Test
@EnabledIf("hostExists")
public void testBudgets() {
ArrayResponse<? extends Item> response = this.api.getBudgets().getResponse();
this.assertArrayNotEmpty(response);
this.assertItems(response.getItems());
}
@Test
@EnabledIf("hostExists")
public void testReminders() {
ArrayResponse<? extends Item> response = this.api.getReminders().getResponse();
this.assertArrayNotEmpty(response);
this.assertItems(response.getItems());
}
@Test
@EnabledIf("fullTest")
public void testGroups() {
ArrayResponse<? extends Item> response = this.api.getGroups().getResponse();
this.assertArrayEmpty(response);
}
@Test
@EnabledIf("fullTest")
public void testContacts() {
ArrayResponse<? extends Item> response = this.api.getContacts().getResponse();
this.assertArrayEmpty(response);
}
private void assertOk(BaseResponse response) {
Assertions.assertNotNull(response);
Assertions.assertEquals(Status.OK, response.getStatus());
Assertions.assertNull(response.getError());
}
private void assertArrayEmpty(ArrayResponse<?> response) {
this.assertOk(response);
Assertions.assertNotNull(response.getItems());
Assertions.assertTrue(response.getItems().isEmpty());
}
private void assertArrayNotEmpty(ArrayResponse<?> response) {
this.assertOk(response);
Assertions.assertNotNull(response.getItems());
Assertions.assertFalse(response.getItems().isEmpty());
}
private void assertItem(Item item) {
Assertions.assertTrue(item.getId() > 0);
}
private void assertNamedItem(NamedItem item) {
this.assertItem(item);
Assertions.assertNotNull(item.getName());
Assertions.assertNotEquals(0, item.getName().length());
}
private void assertSpecificItem(Item item) {
if (item instanceof Transaction) {
this.assertTransaction((Transaction)item);
} else if (item instanceof Budget) {
this.assertBudget((Budget)item);
} else if (item instanceof Reminder) {
this.assertReminder((Reminder)item);
} else if (item instanceof Tag) {
this.assertTag((Tag)item);
} else if (item instanceof Account) {
this.assertAccount((Account)item);
}
}
private void assertTransaction(Transaction item) {
this.assertItem(item);
Assertions.assertNotNull(item.getType());
Assertions.assertNotNull(item.getAmount());
Assertions.assertNotNull(item.getDate());
}
private void assertAccount(Account item) {
this.assertNamedItem(item);
Assertions.assertNotNull(item.getBank());
Assertions.assertNotEquals(0, item.getBank().length());
Assertions.assertNotNull(item.getBalance());
}
private void assertTag(Tag item) {
this.assertNamedItem(item);
}
private void assertBudget(Budget item) {
this.assertNamedItem(item);
}
private void assertReminder(Reminder item) {
this.assertNamedItem(item);
Assertions.assertNotNull(item.getStartDate());
Assertions.assertTrue(item.getStartDate().isAfter(LocalDate.of(2010, 1, 1)));
}
private void assertItems(Collection<? extends Item> items) {
for (Item item : items) {
this.assertSpecificItem(item);
}
}
}

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%msg%n"/>
</Console>
</Appenders>
<Loggers>
<Logger name="com.inteligr8.buxfer" level="debug" additivity="false">
<AppenderRef ref="Console" />
</Logger>
<Logger name="jaxrs.request" level="trace" additivity="false">
<AppenderRef ref="Console" />
</Logger>
<Root level="warn">
<AppenderRef ref="Console" />
</Root>
</Loggers>
</Configuration>

View File

@ -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">
<modelVersion>4.0.0</modelVersion>
<groupId>com.inteligr8.buxfer</groupId>
<artifactId>buxfer-public-rest-api</artifactId>
<version>1.0-SNAPSHOT</version>
<name>Buxfer ReST API for Java</name>
<properties>
<project.build.sourceEncoding>utf-8</project.build.sourceEncoding>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<parent>
<groupId>com.inteligr8.buxfer</groupId>
<artifactId>buxfer-public-rest-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<relativePath>../</relativePath>
</parent>
<dependencies>
<dependency>
@ -43,22 +42,4 @@
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>inteligr8-public</id>
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-public</url>
</repository>
</repositories>
<distributionManagement>
<repository>
<id>inteligr8-releases</id>
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-public</url>
</repository>
<snapshotRepository>
<id>inteligr8-snapshots</id>
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-snapshots</url>
</snapshotRepository>
</distributionManagement>
</project>

View File

@ -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<TransactionsResponse> 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<TransactionsResponse> 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<TransactionsResponse> 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<TransactionsResponse> 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<TransactionsResponse> 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<TransactionsResponse> 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<TransactionsResponse> 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<TransactionsResponse> 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<TransactionsResponse> 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<TransactionResponse> 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<TransactionResponse> 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<TransactionResponse> 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<TransactionResponse> 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<TransactionResponse> 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<TransactionResponse> 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<AccountsResponse> getAccounts();
@GET
@Path("/loans")
@Produces({ "application/json" })
@Produces({ MediaType.APPLICATION_JSON })
public Response<LoansResponse> getLoans();
@GET
@Path("/tags")
@Produces({ "application/json" })
@Produces({ MediaType.APPLICATION_JSON })
public Response<TagsResponse> getTags();
@GET
@Path("/budgets")
@Produces({ "application/json" })
@Produces({ MediaType.APPLICATION_JSON })
public Response<BudgetsResponse> getBudgets();
@GET
@Path("/reminders")
@Produces({ "application/json" })
@Produces({ MediaType.APPLICATION_JSON })
public Response<RemindersResponse> getReminders();
@GET
@Path("/groups")
@Produces({ "application/json" })
@Produces({ MediaType.APPLICATION_JSON })
public Response<GroupsResponse> getGroups();
@GET
@Path("/contacts")
@Produces({ "application/json" })
@Produces({ MediaType.APPLICATION_JSON })
public Response<ContactsResponse> getContacts();
}

View File

@ -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;
}
}

View File

@ -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<String, Type> 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<Type> {
public Serializer() {}
@Override
public void serialize(Type value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeString(value.outgoingValue);
}
}
public static class Deserializer extends JsonDeserializer<Type> {
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

View File

@ -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;
}
}

View File

@ -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">
<modelVersion>4.0.0</modelVersion>
<groupId>com.inteligr8.buxfer</groupId>
<artifactId>buxfer-public-rest-client</artifactId>
<version>1.0-SNAPSHOT</version>
<name>Buxfer ReST Client for Java</name>
<parent>
<groupId>com.inteligr8.buxfer</groupId>
<artifactId>buxfer-public-rest-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<relativePath>../</relativePath>
</parent>
<properties>
<project.build.sourceEncoding>utf-8</project.build.sourceEncoding>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<jaxrs.impl>jersey</jaxrs.impl>
<junit.version>5.7.2</junit.version>
<spring.version>5.2.14.RELEASE</spring.version>
<api.model.disabled>false</api.model.disabled>
@ -27,9 +27,9 @@
<version>1.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.inteligr8.buxfer</groupId>
<groupId>${project.parent.groupId}</groupId>
<artifactId>buxfer-public-rest-api</artifactId>
<version>1.0-SNAPSHOT</version>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
@ -39,7 +39,6 @@
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
@ -89,28 +88,6 @@
<classifier>${jaxrs.impl}</classifier>
</configuration>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M5</version>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.0.0-M5</version>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
@ -126,31 +103,26 @@
</activation>
<properties>
<jaxrs.impl>jersey</jaxrs.impl>
<jersey.version>2.34</jersey.version>
</properties>
<dependencies>
<dependency>
<groupId>org.glassfish.jersey.core</groupId>
<artifactId>jersey-client</artifactId>
<version>${jersey.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.ext</groupId>
<artifactId>jersey-proxy-client</artifactId>
<version>${jersey.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.inject</groupId>
<artifactId>jersey-hk2</artifactId>
<version>${jersey.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-json-jackson</artifactId>
<version>${jersey.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
@ -165,41 +137,14 @@
</activation>
<properties>
<jaxrs.impl>cxf</jaxrs.impl>
<cxf.version>3.3.2</cxf.version>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.cxf</groupId>
<artifactId>cxf-rt-rs-client</artifactId>
<version>${cxf.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
</profile>
</profiles>
<repositories>
<repository>
<id>inteligr8-public</id>
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-public</url>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>inteligr8-public</id>
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-public</url>
</pluginRepository>
</pluginRepositories>
<distributionManagement>
<repository>
<id>inteligr8-releases</id>
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-public</url>
</repository>
<snapshotRepository>
<id>inteligr8-snapshots</id>
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-snapshots</url>
</snapshotRepository>
</distributionManagement>
</project>

View File

@ -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<Response<TokenResponse>> responseType = new GenericType<Response<TokenResponse>>() {};
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);
}
}
}

View File

@ -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}")

136
pom.xml Normal file
View File

@ -0,0 +1,136 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
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">
<modelVersion>4.0.0</modelVersion>
<groupId>com.inteligr8.buxfer</groupId>
<artifactId>buxfer-public-rest-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>Buxfer Public ReST Projects</name>
<scm>
<url>https://bitbucket.org/inteligr8/buxfer-public-rest</url>
</scm>
<organization>
<name>Inteligr8</name>
<url>https://www.inteligr8.com</url>
</organization>
<developers>
<developer>
<name>Brian Long</name>
<email>brian@inteligr8.com</email>
<url>https://twitter.com/brianmlong</url>
</developer>
</developers>
<properties>
<project.build.sourceEncoding>utf-8</project.build.sourceEncoding>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<log4j.version>2.17.0</log4j.version>
<jersey.version>2.35</jersey.version>
<cxf.version>3.3.2</cxf.version>
<junit.version>5.8.2</junit.version>
</properties>
<modules>
<module>buxfer-public-rest-api</module>
<module>buxfer-public-rest-client</module>
<module>buxfer-cli</module>
</modules>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
<version>${log4j.version}</version>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.core</groupId>
<artifactId>jersey-client</artifactId>
<version>${jersey.version}</version>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.ext</groupId>
<artifactId>jersey-proxy-client</artifactId>
<version>${jersey.version}</version>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.inject</groupId>
<artifactId>jersey-hk2</artifactId>
<version>${jersey.version}</version>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-json-jackson</artifactId>
<version>${jersey.version}</version>
</dependency>
<dependency>
<groupId>org.apache.cxf</groupId>
<artifactId>cxf-rt-rs-client</artifactId>
<version>${cxf.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M5</version>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.0.0-M5</version>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</pluginManagement>
</build>
<repositories>
<repository>
<id>inteligr8-public</id>
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-public</url>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>inteligr8-public</id>
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-public</url>
</pluginRepository>
</pluginRepositories>
<distributionManagement>
<repository>
<id>inteligr8-releases</id>
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-public</url>
</repository>
<snapshotRepository>
<id>inteligr8-snapshots</id>
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-snapshots</url>
</snapshotRepository>
</distributionManagement>
</project>