├── .gitignore ├── README.md ├── pom.xml └── src └── main └── java └── com └── github └── gclfames5 ├── Main.java ├── config └── YAMLConfiguration.java ├── log └── Logger.java ├── sw ├── SplitwiseExpense.java └── SplitwiseHandler.java └── ynab └── YNABHandler.java /.gitignore: -------------------------------------------------------------------------------- 1 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 2 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 3 | 4 | # User-specific stuff 5 | .idea/**/workspace.xml 6 | .idea/**/tasks.xml 7 | .idea/**/usage.statistics.xml 8 | .idea/**/dictionaries 9 | .idea/**/shelf 10 | 11 | # Generated files 12 | .idea/**/contentModel.xml 13 | 14 | # Sensitive or high-churn files 15 | .idea/**/dataSources/ 16 | .idea/**/dataSources.ids 17 | .idea/**/dataSources.local.xml 18 | .idea/**/sqlDataSources.xml 19 | .idea/**/dynamic.xml 20 | .idea/**/uiDesigner.xml 21 | .idea/**/dbnavigator.xml 22 | 23 | .idea/ 24 | target/ 25 | *.iml 26 | 27 | # Gradle 28 | .idea/**/gradle.xml 29 | .idea/**/libraries 30 | 31 | # Gradle and Maven with auto-import 32 | # When using Gradle or Maven with auto-import, you should exclude module files, 33 | # since they will be recreated, and may cause churn. Uncomment if using 34 | # auto-import. 35 | # .idea/modules.xml 36 | # .idea/*.iml 37 | # .idea/modules 38 | # *.iml 39 | # *.ipr 40 | 41 | # CMake 42 | cmake-build-*/ 43 | 44 | # Mongo Explorer plugin 45 | .idea/**/mongoSettings.xml 46 | 47 | # File-based project format 48 | *.iws 49 | 50 | # IntelliJ 51 | out/ 52 | 53 | # mpeltonen/sbt-idea plugin 54 | .idea_modules/ 55 | 56 | # JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Cursive Clojure plugin 60 | .idea/replstate.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | # Editor-based Rest Client 69 | .idea/httpRequests 70 | 71 | # Android studio 3.1+ serialized cache file 72 | .idea/caches/build_file_checksums.ser 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Splitwise Integration for YNAB 2 | ==== 3 | Splitwise poses an interesting problem for YNAB, where a single settle up transaction can represent a lot of other transactions rolled into one. This makes it extremely hard to budget Splitwise transactions using YNAB. This integration solves that problem by importing Splitwise transactions into YNAB, allowing you to categorize the transactions within YNAB. 4 | 5 | A step by step tutorial can be found [on the wiki](https://github.com/gcflames5/ynab-splitwise-integration/wiki/Step-By-Step-Tutorial). 6 | 7 | Download pre-compiled versions [here](https://github.com/gcflames5/ynab-splitwise-integration/tags). 8 | 9 | Word of Caution 10 | ---- 11 | This integration treats your Splitwise balance as an actual account balance to help categorize expenses, but be carefull about treating these items as reimbursed before they actually are. If you are not mindful, you may be relying on money that has not yet been returned to you. 12 | 13 | Using the Integration 14 | ---- 15 | 16 | ### How I Use This Tool 17 | The tool creates transactions in YNAB for every transaction in Splitwise. I categorize each Splitwise transaction into a budget category in order to track actual spending. Then, the settle up transactions (both on the Splitwise side and the actual payment method used to fulfill the settle up) are categorized into "To Be Budgeted," which effectively ignores them in the budget but keeps the account balances correct. Here's an example to illustrate the methodology: 18 | 19 | 1. I lend Alice $25.00 for "Eating Out" (total charge: $50.00 on credit card) 20 | 2. Bob lends me $45.00 for "Household Goods" 21 | 3. I use Splitwise's debt simplification feature to pay Bob $20.00 directly 22 | 23 | Here's what those transactions would look like in Splitwise: 24 | 25 | | Account | Transaction | Category | Inflow | Outflow | 26 | | ----------- | ------------------------ | --------------- | ------ | -------- | 27 | | Splitwise | Eating Out (w/ Alice) | Eating Out | $25 | | 28 | | Credit Card | Eating Out (w/ Alice) | Eating Out | | $50 | 29 | | Splitwise | Household Goods (w/ Bob) | Household Goods | | $45 | 30 | | Splitwise | Settle up | To Be Budgeted | $20 | | 31 | | Venmo | Settle up | To Be Budgeted | | $20 | 32 | 33 | The two settle up transactions cancel out (while still correclty updating their accounts' balances) but don't affect our budget. The Splitwise and Credit Card transactions total $25 spent on Eating Out, which is the $50 from the credit card minus the $25 reimbursement from Alice, keeping our budget correct. 34 | 35 | ### Configuration 36 | 37 | Before you begin, you will need to register an application with the Splitwise API and obtain an access token from YNAB. First [click this link](https://secure.splitwise.com/apps) to register your application with Splitwise. After registering, keep the "Consumer Key" and "Consumer Secret" on hand for later. Next, we need an access token fron YNAB. Once in YNAB, click "My Account" and scroll down to the developer settings, or [click here](https://app.youneedabudget.com/settings/developer) to go directly there. After registering for an access token, copy it down for the next step. 38 | 39 | Create a file called `config.yml` in the same directory as the .jar file with the following contents: 40 | 41 | ```yaml 42 | splitwise: 43 | consumer_key: # Splitwise Consumer Key 44 | consumer_secret: # Splitwise Consumer Secret 45 | oauth_token_file: splitwise_token.oauth # File to store oauth token after authorization 46 | last_transaction_date: never # Automatically updated by the program, last splitwie transaction parsed 47 | ynab: 48 | access_token: # Personal Access Token for YNAB 49 | budget_name: My Budget # Name of the YNAB Budget 50 | account_name: Splitwise # Name of the account where Splitwise transactions will be added 51 | ``` 52 | 53 | Replace and with the two values you obtained from Splitwise. Replace with the token you obtained from YNAB. 54 | 55 | #### Authorizing 56 | 57 | In order to authorize the Splitwise app, you'll need to click the authorization URL. After authorizing, press "Show out of band data" and copy the verifier into the console where prompted. 58 | 59 | ### Running the Integration 60 | Running the integration will transfer any new transactions from Splitwise to YNAB and then terminate. I suggest using cron or something like it to run the jar at regular intervals. 61 | 62 | After fetching the latest jar from the releases page, you can run the jar with the following command: `java -jar ynab-splitwise.jar`, ensure that java is installed on your machine. It will assume that your config.yml file is located in the same directory as the jar, if it is not, specify the full path of the config file as the first command line argument. 63 | 64 | ### Dependencies 65 | Most dependencies will be imported via Maven and specified in the project's `pom.xml`. However, you will need to manually install the following repositories in your local maven repository by following the installation steps in their README.mds. 66 | - [Java YNAB SDK](https://github.com/gcflames5/ynab-sdk) 67 | - [Java Splitwise API](https://github.com/gcflames5/splitwise-java) 68 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | net 8 | njay 9 | 1.0 10 | jar 11 | 12 | 13 | 14 | 15 | ynab-splitwise 16 | 17 | 18 | 19 | org.apache.maven.plugins 20 | maven-compiler-plugin 21 | 22 | 1.8 23 | 1.8 24 | 25 | 26 | 27 | 28 | org.apache.maven.plugins 29 | maven-assembly-plugin 30 | 3.1.0 31 | 32 | 33 | 34 | jar-with-dependencies 35 | 36 | 37 | 38 | 39 | com.github.gclfames5.Main 40 | 41 | 42 | 43 | 44 | 45 | 46 | make-assembly 47 | 48 | package 49 | 50 | single 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | com.github.gcflames5 63 | splitwise 64 | 1.1 65 | 66 | 67 | org.yaml 68 | snakeyaml 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | org.json 77 | json 78 | 20180813 79 | 80 | 81 | org.yaml 82 | snakeyaml 83 | 84 | 85 | 86 | 87 | 88 | ynab.sdk 89 | ynab-sdk 90 | 0.0.1-SNAPSHOT 91 | compile 92 | 93 | 94 | org.yaml 95 | snakeyaml 96 | 97 | 98 | 99 | 100 | 101 | 102 | org.yaml 103 | snakeyaml 104 | 1.25 105 | 106 | 107 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /src/main/java/com/github/gclfames5/Main.java: -------------------------------------------------------------------------------- 1 | package com.github.gclfames5; 2 | 3 | import com.github.gclfames5.config.YAMLConfiguration; 4 | import com.github.gclfames5.log.Logger; 5 | import com.github.gclfames5.sw.SplitwiseExpense; 6 | import com.github.gclfames5.sw.SplitwiseHandler; 7 | import com.github.gclfames5.ynab.YNABHandler; 8 | import ynab.client.model.SaveTransaction; 9 | import ynab.client.model.TransactionDetail; 10 | 11 | import java.io.File; 12 | import java.io.FileNotFoundException; 13 | import java.io.IOException; 14 | import java.math.BigDecimal; 15 | import java.net.URISyntaxException; 16 | import java.util.ArrayList; 17 | import java.util.Date; 18 | import java.util.List; 19 | 20 | public class Main { 21 | 22 | public static String LOGFILE_PATH = "logfile.txt"; 23 | public static String configPath = ""; 24 | 25 | public static void main(String[] args) { 26 | 27 | if (args.length <= 0) { 28 | System.out.println("Configuration not specified via command line. Defaulting to ./config.yml. To specify a custom path, add path as a command line argument."); 29 | try { 30 | File jarDirectory = new File(Main.class.getProtectionDomain().getCodeSource().getLocation().toURI()); 31 | configPath = new File(jarDirectory.getParentFile(), "config.yml").getAbsolutePath(); 32 | System.out.println("Configuration path: " + configPath); 33 | } catch (URISyntaxException e) { 34 | e.printStackTrace(); 35 | System.err.println("Failed to find working directory! See error for more details."); 36 | System.exit(0); 37 | } 38 | 39 | } else { 40 | System.out.println("Configuration specified via command line: " + args[0]); 41 | configPath = args[0]; 42 | } 43 | 44 | // Setup the log 45 | Logger.initLog(LOGFILE_PATH); 46 | 47 | // Read Config 48 | System.out.println("Reading configuration..."); 49 | File f = new File(configPath); 50 | 51 | YAMLConfiguration config = new YAMLConfiguration(configPath); 52 | try { 53 | config.openConfig(); 54 | } catch (FileNotFoundException e) { 55 | System.out.println("No com.github.gclfames5.config file found!"); 56 | e.printStackTrace(); 57 | } 58 | 59 | // Authenticate Splitwise 60 | Logger.log("Authenticating Splitwise...", true); 61 | SplitwiseHandler sw = new SplitwiseHandler(config); 62 | sw.authenticate(true); 63 | Logger.log(String.format("Fetching spltiwise transactions since %s", config.getSplitwiseLastTransactionDate().toString()), true); 64 | List expensesToProcess = sw.getAllExpenses(0, config.getSplitwiseLastTransactionDate()); 65 | 66 | // Authenticate YNAB 67 | Logger.log("Authenticating YNAB...", true); 68 | YNABHandler ynab = new YNABHandler(config); 69 | ynab.authenticate(); 70 | 71 | // Search through all updated Spltiwise Transactions 72 | // If any have been updated (or deleted) determine the earliest 73 | // "created_on" date to determine how far back we have to search in 74 | // YNAB in order to fund the corresponding transaction 75 | 76 | List newSplitwiseExpenses = new ArrayList<>(); 77 | List updatedSplitwiseExpenses = new ArrayList<>(); 78 | List deletedSplitwiseExpenses = new ArrayList<>(); 79 | Date oldestCreationDate = new Date(); 80 | 81 | // Sort Expenses into "new" and "updated" categories while also finding oldest "created_at" date 82 | for (SplitwiseExpense expense : expensesToProcess) { 83 | if (expense.created_at.before(oldestCreationDate)) 84 | oldestCreationDate = expense.created_at; 85 | 86 | // If deleted, add to deleted expenses 87 | if (expense.deleted_at != null) { 88 | deletedSplitwiseExpenses.add(expense); 89 | continue; 90 | } 91 | 92 | // If "updated_on" and "created_at" match, then treat it as "new" 93 | if (expense.updated_on.equals(expense.created_at)) { 94 | newSplitwiseExpenses.add(expense); 95 | } else { 96 | updatedSplitwiseExpenses.add(expense); 97 | } 98 | } 99 | 100 | // Add all new transactions to YNAB 101 | Logger.log(String.format("Number of new expenses found: %d", newSplitwiseExpenses.size())); 102 | for (SplitwiseExpense newExpense : newSplitwiseExpenses) { 103 | Logger.log(String.format("New transaction found: %s", newExpense.toString()), true); 104 | newExpense.description += String.format(", sw_uuid:%d", newExpense.id); 105 | Logger.log(String.format("Uploading new expense to YNAB: %s", newExpense.toString()), true); 106 | ynab.addTransaction(newExpense.cost, newExpense.description, newExpense.created_at); 107 | } 108 | 109 | 110 | // For all transactions that seem to be updated: 111 | // - Try to match them with a YNAB expense and perform update 112 | // - Otherwise, treat as a new transaction 113 | Logger.log(String.format("Number of updated expenses found: %d", updatedSplitwiseExpenses.size())); 114 | if (updatedSplitwiseExpenses.size() > 0) { 115 | // Get the minimum number of transactions from YNAB to search through UUIDs 116 | List ynabTransactions = ynab.getTransactionsSince(new Date()); 117 | splitwiseLoop: 118 | for (SplitwiseExpense updatedExpense : updatedSplitwiseExpenses) { 119 | // Search for corresponding expense in YNAB 120 | ynabLoop: 121 | for (TransactionDetail ynabTransaction : ynabTransactions) { 122 | // Ignore any transactions without memos, these can't have been 123 | // generated by us anyway 124 | if (ynabTransaction == null || ynabTransaction.getMemo() == null) { 125 | Logger.log(String.format("Saw null transaction or null memo while searching for updated transactions")); 126 | continue; 127 | } 128 | 129 | String[] search = ynabTransaction.getMemo().split("sw_uuid:"); 130 | 131 | Logger.log(String.format("Searching transaction: %s with memo: %s", ynabTransaction.toString(), ynabTransaction.getMemo())); 132 | // Transactions may exist that have not been tagged with the uuid 133 | // ignore those entries 134 | if (search.length > 1) { 135 | int uuid = Integer.valueOf(search[1]); 136 | // Check if this uuid matches the splitwise expense we're looking at 137 | // If it matches, update this transaction 138 | if (uuid == updatedExpense.id) { 139 | Logger.log(String.format("Matched updated transaction: %s with uuid %s", 140 | updatedExpense.toString(), ynabTransaction.getAccountId().toString()), true); 141 | SaveTransaction saveTransaction = new SaveTransaction(); 142 | saveTransaction.accountId(ynabTransaction.getAccountId()) 143 | .amount(new BigDecimal(updatedExpense.cost * 1000)) 144 | .approved(ynabTransaction.isApproved()); 145 | if (updatedExpense.deleted_at != null) { 146 | Logger.log(String.format("Expense was deleted! Deleted at %s", updatedExpense.deleted_at)); 147 | saveTransaction.amount(new BigDecimal(0)); 148 | } 149 | // Don't bother buffering, can't bulk update 150 | Logger.log(String.format("Uploading updated expense to YNAB: %s", updatedExpense.toString()), true); 151 | ynab.updateTransaction(ynabTransaction.getId(), saveTransaction); 152 | continue splitwiseLoop; 153 | } 154 | } 155 | } 156 | 157 | // If we're here, we weren't able to find a corresponding transaction in YNAB 158 | // it's possible that the transaction was created & updated before this program 159 | // was run, add it to YNAB as a new transaction 160 | Logger.log(String.format("Uploading new & updated expense to YNAB: %s", updatedExpense.toString()), true); 161 | ynab.addTransaction(updatedExpense.cost, updatedExpense.description, updatedExpense.created_at); 162 | } 163 | } 164 | 165 | config.setSplitwiseLastTransactionDate(new Date()); // TODO: potential for issues here 166 | 167 | Logger.log("Writing config...", true); 168 | try { 169 | config.writeConfig(); 170 | } catch (IOException e) { 171 | e.printStackTrace(); 172 | } 173 | 174 | System.out.println("Exiting..."); 175 | System.exit(0); 176 | } 177 | 178 | } 179 | -------------------------------------------------------------------------------- /src/main/java/com/github/gclfames5/config/YAMLConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.github.gclfames5.config; 2 | 3 | import org.yaml.snakeyaml.DumperOptions; 4 | import org.yaml.snakeyaml.Yaml; 5 | 6 | import java.io.*; 7 | import java.text.ParseException; 8 | import java.text.SimpleDateFormat; 9 | import java.util.Date; 10 | import java.util.HashMap; 11 | import java.util.Map; 12 | 13 | public class YAMLConfiguration { 14 | 15 | public static void main(String[] args) throws IOException { 16 | YAMLConfiguration yamlConfiguration = new YAMLConfiguration("com.github.gclfames5.config.yml"); 17 | yamlConfiguration.openConfig(); 18 | yamlConfiguration.setSplitwiseLastTransactionDate(new Date()); 19 | yamlConfiguration.writeConfig(); 20 | } 21 | 22 | private File yaml_file; 23 | private Yaml yaml; 24 | private Map ynab_config; 25 | private Map splitwie_config; 26 | 27 | public YAMLConfiguration(String filepath) { 28 | DumperOptions options = new DumperOptions(); 29 | options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); 30 | options.setPrettyFlow(true); 31 | 32 | this.yaml_file = new File(filepath); 33 | this.yaml = new Yaml(options); 34 | } 35 | 36 | public void openConfig() throws FileNotFoundException { 37 | InputStream inputStream = new FileInputStream(this.yaml_file); 38 | Iterable yaml_contents = this.yaml.loadAll(inputStream); 39 | Map root = (Map) yaml_contents.iterator().next(); 40 | this.ynab_config = (Map) root.get("ynab"); 41 | this.splitwie_config = (Map) root.get("splitwise"); 42 | } 43 | 44 | public String getYNABAccessToken() { 45 | String token = (String) this.ynab_config.get("access_token"); 46 | if (token.equalsIgnoreCase("your_access_token")) { 47 | return ""; 48 | } 49 | return token; 50 | } 51 | 52 | public String getYNABBudgetName() { 53 | return (String) this.ynab_config.get("budget_name"); 54 | } 55 | 56 | public String getYNABAccountName() { 57 | return (String) this.ynab_config.get("account_name"); 58 | } 59 | 60 | public String getSplitiwiseOauthTokenFile() { 61 | return (String) this.splitwie_config.get("oauth_token_file"); 62 | } 63 | 64 | public String getSplitwiseConsumerKey() { 65 | return (String) this.splitwie_config.get("consumer_key"); 66 | } 67 | 68 | public String getSplitwiseConsumerSecret() { 69 | return (String) this.splitwie_config.get("consumer_secret"); 70 | } 71 | 72 | public Date getSplitwiseLastTransactionDate() { 73 | String dateString = (String) this.splitwie_config.get("last_transaction_date"); 74 | if (dateString.equalsIgnoreCase("never")) { 75 | return new Date(0); 76 | } 77 | try { 78 | return new SimpleDateFormat("dd-MMM-yyyy HH:mm:ss").parse(dateString); 79 | } catch (ParseException e) { 80 | e.printStackTrace(); 81 | return new Date(0); 82 | } 83 | } 84 | 85 | public void setSplitwiseLastTransactionDate(Date date) { 86 | SimpleDateFormat dateFormat = new SimpleDateFormat("dd-MMM-yyyy HH:mm:ss"); 87 | this.splitwie_config.put("last_transaction_date", dateFormat.format(date)); 88 | } 89 | 90 | public void writeConfig() throws IOException { 91 | Map> map = new HashMap<>(); 92 | Map ynab = new HashMap<>(); 93 | Map splitwise = new HashMap<>(); 94 | 95 | ynab.put("access_token", getYNABAccessToken()); 96 | ynab.put("budget_name", getYNABBudgetName()); 97 | ynab.put("account_name", getYNABAccountName()); 98 | 99 | SimpleDateFormat dateFormat = new SimpleDateFormat("dd-MMM-yyyy HH:mm:ss"); 100 | 101 | splitwise.put("oauth_token_file", getSplitiwiseOauthTokenFile()); 102 | splitwise.put("last_transaction_date", dateFormat.format(getSplitwiseLastTransactionDate())); 103 | splitwise.put("consumer_key", getSplitwiseConsumerKey()); 104 | splitwise.put("consumer_secret", getSplitwiseConsumerSecret()); 105 | 106 | map.put("ynab", ynab); 107 | map.put("splitwise", splitwise); 108 | 109 | this.yaml.dump(map, new FileWriter(this.yaml_file)); 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /src/main/java/com/github/gclfames5/log/Logger.java: -------------------------------------------------------------------------------- 1 | package com.github.gclfames5.log; 2 | 3 | import java.io.*; 4 | 5 | public class Logger { 6 | 7 | private static File file; 8 | private static FileWriter writer; 9 | 10 | public static void initLog(String path) { 11 | file = new File(path); 12 | try { 13 | writer = new FileWriter(file); 14 | } catch (IOException e) { 15 | e.printStackTrace(); 16 | } 17 | } 18 | 19 | public static boolean isInitialized() { 20 | return file != null && writer != null; 21 | } 22 | 23 | public static void log(String s) { 24 | if (!isInitialized()) 25 | throw new RuntimeException("Attempted to write to uninitialized log!"); 26 | 27 | try { 28 | writer.append(s + "\n"); 29 | writer.flush(); 30 | } catch (IOException e) { 31 | e.printStackTrace(); 32 | } 33 | } 34 | 35 | public static void log(String s, boolean sysOut) { 36 | log(s); 37 | 38 | if (sysOut) 39 | System.out.println(s); 40 | } 41 | 42 | public static void log(Exception e) { 43 | StringWriter sw = new StringWriter(); 44 | PrintWriter pw = new PrintWriter(sw); 45 | e.printStackTrace(pw); 46 | e.printStackTrace(); 47 | 48 | log(sw.toString()); 49 | } 50 | 51 | public static void finishLog() { 52 | try { 53 | writer.close(); 54 | } catch (IOException e) { 55 | e.printStackTrace(); 56 | } finally { 57 | writer = null; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/com/github/gclfames5/sw/SplitwiseExpense.java: -------------------------------------------------------------------------------- 1 | package com.github.gclfames5.sw; 2 | 3 | import com.github.gclfames5.log.Logger; 4 | import org.json.JSONArray; 5 | import org.json.JSONObject; 6 | 7 | import java.text.ParseException; 8 | import java.text.SimpleDateFormat; 9 | import java.util.Date; 10 | 11 | public class SplitwiseExpense { 12 | 13 | public String description; 14 | public boolean paid; 15 | public int id, group_id; 16 | public double cost; 17 | public Date created_at, updated_on, deleted_at; 18 | 19 | public SplitwiseExpense(int id, int group_id, String description, boolean paid, double cost, Date created_at, Date updated_on, Date deleted_at) { 20 | this.id = id; 21 | this.group_id = group_id; 22 | this.description = description; 23 | this.paid = paid; 24 | this.cost = cost; 25 | this.created_at = created_at; 26 | this.updated_on = updated_on; 27 | this.deleted_at = deleted_at; 28 | } 29 | 30 | public static SplitwiseExpense parseJSON(JSONObject obj, long userID) { 31 | int id = obj.getInt("id"); 32 | //int group_id = obj.getBigInteger("group_id").intValue(); 33 | String desc = obj.getString("description"); 34 | boolean paid = obj.getBoolean("payment"); 35 | SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); 36 | Date created_at = null; 37 | Date updated_on = null; 38 | Date deleted_at = null; 39 | try { 40 | created_at = simpleDateFormat.parse(obj.getString("created_at").replaceAll("T", " ").replaceAll("Z", " ").trim()); 41 | updated_on = simpleDateFormat.parse(obj.getString("updated_at").replaceAll("T", " ").replaceAll("Z", " ").trim()); 42 | 43 | if (obj.get("deleted_at") instanceof String) { 44 | deleted_at = simpleDateFormat.parse(obj.getString("deleted_at").replaceAll("T", " ").replaceAll("Z", " ").trim()); 45 | } 46 | } catch (ParseException e) { 47 | Logger.log(e); 48 | } 49 | 50 | double cost = 0; 51 | 52 | if (!obj.get("deleted_by").toString().equalsIgnoreCase("null")) { 53 | return null; 54 | } 55 | 56 | JSONArray repayments = obj.getJSONArray("repayments"); 57 | for (Object repaymentObj : repayments) { 58 | JSONObject JSONRepaymentObj = (JSONObject) repaymentObj; 59 | long toUserID = JSONRepaymentObj.getLong("to"); 60 | long fromUSerID = JSONRepaymentObj.getLong("from"); 61 | 62 | // Check if user is involved in the transaction at all 63 | if (toUserID != userID && fromUSerID != userID) { 64 | continue; 65 | } 66 | 67 | // User is involved, decide whether they are giving or receiving money 68 | double amount = JSONRepaymentObj.getDouble("amount"); 69 | if (toUserID == userID) { 70 | cost += amount; 71 | } else { 72 | cost -= amount; 73 | } 74 | } 75 | 76 | if (cost == 0) { 77 | return null; 78 | } 79 | 80 | SplitwiseExpense expense = new SplitwiseExpense(id, 0, desc, paid, cost, created_at, updated_on, deleted_at); 81 | Logger.log(String.format("Parsed splitwise expense: %s", expense.toString())); 82 | return expense; 83 | } 84 | 85 | @Override 86 | public String toString() { 87 | return String.format("Expense: [desc: %s, cost: %f, date: %s]", this.description, this.cost, this.created_at); 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/com/github/gclfames5/sw/SplitwiseHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.gclfames5.sw; 2 | 3 | import com.github.gclfames5.log.Logger; 4 | import com.github.scribejava.core.model.OAuth1AccessToken; 5 | import org.json.JSONArray; 6 | import org.json.JSONObject; 7 | import splitwise.Splitwise; 8 | import com.github.gclfames5.config.YAMLConfiguration; 9 | 10 | import java.io.*; 11 | import java.util.*; 12 | 13 | public class SplitwiseHandler { 14 | 15 | public static void main(String[] args) throws FileNotFoundException { 16 | YAMLConfiguration configuration = new YAMLConfiguration("com.github.gclfames5.config.yml"); 17 | configuration.openConfig(); 18 | SplitwiseHandler sw = new SplitwiseHandler(configuration); 19 | sw.authenticate(true); 20 | List newExpenses = sw.getAllExpenses(0, null); 21 | System.out.println(newExpenses); 22 | } 23 | 24 | private Splitwise splitwise; 25 | private String consumerSecret, consumerKey, oauthTokenFilePath; 26 | private Date lastTransactionDate; 27 | private long userID; 28 | 29 | public SplitwiseHandler(YAMLConfiguration config) { 30 | this.consumerKey = config.getSplitwiseConsumerKey(); 31 | this.consumerSecret = config.getSplitwiseConsumerSecret(); 32 | this.lastTransactionDate = config.getSplitwiseLastTransactionDate(); 33 | this.oauthTokenFilePath = config.getSplitiwiseOauthTokenFile(); 34 | 35 | this.splitwise = new Splitwise(this.consumerKey, this.consumerSecret); 36 | } 37 | 38 | public void authenticate(boolean first_try) { 39 | // Check if authentication file exists 40 | File oauth_token_file = new File(this.oauthTokenFilePath); 41 | if (!oauth_token_file.exists() && first_try) { 42 | doNewAuthorization(); 43 | authenticate(false); 44 | } 45 | 46 | // OAuth Token should now exist 47 | try { 48 | OAuth1AccessToken accessToken = readAccessToken(); 49 | splitwise.util.setAccessToken(accessToken.getToken(), accessToken.getTokenSecret(), accessToken.getRawResponse()); 50 | 51 | // Test login 52 | String currentUserJSON = splitwise.getCurrentUser(); 53 | JSONObject JSONUser = (JSONObject) new JSONObject(currentUserJSON).get("user"); 54 | 55 | this.userID = JSONUser.getLong("id"); 56 | 57 | } catch (Exception e) { 58 | Logger.log(e); 59 | 60 | Logger.log("Login failed! Starting authorization process...", true); 61 | 62 | // Try again once 63 | if (first_try) { 64 | oauth_token_file.delete(); 65 | doNewAuthorization(); 66 | authenticate(false); 67 | } 68 | } 69 | } 70 | 71 | public void doNewAuthorization() { 72 | try { 73 | String authURL = splitwise.getAuthorizationUrl(); 74 | System.out.printf("Please click the following URL to begin authorization: %s\n", authURL); 75 | 76 | Scanner scan = new Scanner(System.in); 77 | 78 | System.out.println("Please paste your oauth verifier here:"); 79 | String oauth_verifier = scan.nextLine().replaceAll("\n", "").trim(); 80 | 81 | splitwise.util.setAccessToken(oauth_verifier); 82 | 83 | OAuth1AccessToken accessToken = (OAuth1AccessToken) splitwise.util.getAccessToken(); 84 | 85 | writeAccessToken(accessToken); 86 | }catch (Exception e) { 87 | Logger.log(e); 88 | } 89 | } 90 | 91 | public List getAllExpenses(int limit, Date updated_after) { 92 | String expensesJSON = null; 93 | try { 94 | if (updated_after != null) { 95 | expensesJSON = splitwise.getExpenses(limit, updated_after); 96 | } else { 97 | expensesJSON = splitwise.getExpenses(limit); 98 | } 99 | } catch (Exception e) { 100 | Logger.log(e); 101 | } 102 | 103 | List expenses = new ArrayList(); 104 | JSONObject obj = new JSONObject(expensesJSON); 105 | JSONArray expenseArray = (JSONArray) obj.get("expenses"); 106 | 107 | for (Object expenseObj : expenseArray) { 108 | JSONObject jsonExpenseObj = (JSONObject) expenseObj; 109 | SplitwiseExpense expense = SplitwiseExpense.parseJSON(jsonExpenseObj, this.userID); 110 | if (expense != null) { 111 | expenses.add(expense); 112 | } 113 | } 114 | 115 | return expenses; 116 | } 117 | 118 | /* Token Utilities */ 119 | 120 | private void writeAccessToken(OAuth1AccessToken token) throws IOException { 121 | String token_string = toString(token); 122 | File f = new File(this.oauthTokenFilePath); 123 | if (f.exists()) f.delete(); 124 | PrintWriter out = new PrintWriter(this.oauthTokenFilePath); 125 | out.print(token_string); 126 | out.close(); 127 | } 128 | 129 | private OAuth1AccessToken readAccessToken() throws IOException, ClassNotFoundException { 130 | BufferedReader br = new BufferedReader(new FileReader(this.oauthTokenFilePath)); 131 | String token_string = br.readLine(); 132 | return (OAuth1AccessToken) fromString(token_string); 133 | } 134 | 135 | /** Read the object from Base64 string. */ 136 | private Object fromString( String s ) throws IOException, 137 | ClassNotFoundException { 138 | byte [] data = Base64.getDecoder().decode( s ); 139 | ObjectInputStream ois = new ObjectInputStream( 140 | new ByteArrayInputStream( data ) ); 141 | Object o = ois.readObject(); 142 | ois.close(); 143 | return o; 144 | } 145 | 146 | /** Write the object to a Base64 string. */ 147 | private String toString(Serializable o) throws IOException { 148 | ByteArrayOutputStream baos = new ByteArrayOutputStream(); 149 | ObjectOutputStream oos = new ObjectOutputStream(baos); 150 | oos.writeObject(o); 151 | oos.close(); 152 | return Base64.getEncoder().encodeToString(baos.toByteArray()); 153 | } 154 | 155 | } 156 | 157 | -------------------------------------------------------------------------------- /src/main/java/com/github/gclfames5/ynab/YNABHandler.java: -------------------------------------------------------------------------------- 1 | package com.github.gclfames5.ynab; 2 | 3 | import com.github.gclfames5.config.YAMLConfiguration; 4 | import com.github.gclfames5.log.Logger; 5 | import org.threeten.bp.DateTimeUtils; 6 | import org.threeten.bp.LocalDate; 7 | import ynab.client.api.AccountsApi; 8 | import ynab.client.api.BudgetsApi; 9 | import ynab.client.api.TransactionsApi; 10 | import ynab.client.invoker.ApiClient; 11 | import ynab.client.invoker.ApiException; 12 | import ynab.client.invoker.Configuration; 13 | import ynab.client.invoker.auth.ApiKeyAuth; 14 | import ynab.client.model.*; 15 | 16 | import java.math.BigDecimal; 17 | import java.text.SimpleDateFormat; 18 | import java.util.ArrayList; 19 | import java.util.Date; 20 | import java.util.List; 21 | import java.util.UUID; 22 | 23 | public class YNABHandler { 24 | 25 | private String budgetName; 26 | private String accountName; 27 | private String accessToken; 28 | 29 | private ApiClient defaultClient; 30 | private BudgetsApi budgetsApi; 31 | private TransactionsApi transactionsApi; 32 | private AccountsApi accountsApi; 33 | 34 | private UUID budgetUUID, accountUUID; 35 | 36 | public YNABHandler(YAMLConfiguration config) { 37 | this.budgetName = config.getYNABBudgetName(); 38 | this.accountName = config.getYNABAccountName(); 39 | this.accessToken = config.getYNABAccessToken(); 40 | 41 | this.defaultClient = Configuration.getDefaultApiClient(); 42 | } 43 | 44 | public void authenticate() { 45 | // Configure API key authorization: bearer 46 | ApiKeyAuth bearer = (ApiKeyAuth) defaultClient.getAuthentication("bearer"); 47 | bearer.setApiKey(this.accessToken); 48 | bearer.setApiKeyPrefix("Bearer"); 49 | 50 | // Initialize API objects 51 | this.budgetsApi = new BudgetsApi(); 52 | this.transactionsApi = new TransactionsApi(); 53 | this.accountsApi = new AccountsApi(); 54 | 55 | // Find correct budget 56 | try { 57 | BudgetSummaryResponse budgetSummaryResponse = budgetsApi.getBudgets(); 58 | for (BudgetSummary budgetSummary : budgetSummaryResponse.getData().getBudgets()) { 59 | if (budgetSummary.getName().equalsIgnoreCase(budgetName)) { 60 | this.budgetUUID = budgetSummary.getId(); 61 | break; 62 | } 63 | } 64 | if (this.budgetUUID == null) throw new RuntimeException("No'" + budgetName + "' budget found in YNAB!"); 65 | 66 | AccountsResponse accountsResponse = accountsApi.getAccounts(budgetUUID); 67 | for (Account acct : accountsResponse.getData().getAccounts()) { 68 | if (acct.getName().equalsIgnoreCase("Splitwise")) { 69 | this.accountUUID = acct.getId(); 70 | break; 71 | } 72 | } 73 | if (this.accountUUID == null) throw new RuntimeException("No'" + accountName + "' account found in YNAB!"); 74 | 75 | } catch (ApiException e) { 76 | Logger.log("Exception raised when authenticating!"); 77 | Logger.log(e); 78 | } 79 | 80 | } 81 | 82 | public void addTransaction(double amount, String description, Date date) { 83 | try { 84 | SaveTransaction transaction = new SaveTransaction(); 85 | transaction.setAccountId(this.accountUUID); 86 | transaction.setAmount(new BigDecimal(amount * 1000)); 87 | transaction.setApproved(false); 88 | SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd"); 89 | transaction.setDate(LocalDate.parse(dateFormat.format(date))); 90 | transaction.setMemo(description); 91 | transactionsApi.createTransaction(this.budgetUUID, new SaveTransactionWrapper().transaction(transaction)); 92 | } catch (ApiException e) { 93 | Logger.log(e); 94 | } 95 | } 96 | 97 | public List getTransactionsSince(Date since) { 98 | try { 99 | LocalDate localDate = DateTimeUtils.toLocalDate(new java.sql.Date(since.getTime())); 100 | TransactionsResponse transactionsResponse 101 | = this.transactionsApi.getTransactionsByAccount(this.budgetUUID, this.accountUUID, localDate); 102 | return transactionsResponse.getData().getTransactions(); 103 | } catch (ApiException e) { 104 | Logger.log(e); 105 | } 106 | return null; 107 | } 108 | 109 | public void updateTransaction(UUID transactionUUID, SaveTransaction saveTransaction) { 110 | try { 111 | this.transactionsApi.updateTransaction(budgetUUID, transactionUUID, new SaveTransactionWrapper().transaction(saveTransaction)); 112 | } catch (ApiException e) { 113 | Logger.log(e); 114 | } 115 | } 116 | 117 | 118 | } 119 | --------------------------------------------------------------------------------