]> git.ktnx.net Git - mobile-ledger-staging.git/blob - app/src/main/java/net/ktnx/mobileledger/async/RetrieveTransactionsTask.java
major rework of parsed transaction/descriptions/accounts storage
[mobile-ledger-staging.git] / app / src / main / java / net / ktnx / mobileledger / async / RetrieveTransactionsTask.java
1 /*
2  * Copyright © 2020 Damyan Ivanov.
3  * This file is part of MoLe.
4  * MoLe is free software: you can distribute it and/or modify it
5  * under the term of the GNU General Public License as published by
6  * the Free Software Foundation, either version 3 of the License, or
7  * (at your opinion), any later version.
8  *
9  * MoLe is distributed in the hope that it will be useful,
10  * but WITHOUT ANY WARRANTY; without even the implied warranty of
11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12  * GNU General Public License terms for details.
13  *
14  * You should have received a copy of the GNU General Public License
15  * along with MoLe. If not, see <https://www.gnu.org/licenses/>.
16  */
17
18 package net.ktnx.mobileledger.async;
19
20 import android.annotation.SuppressLint;
21 import android.database.sqlite.SQLiteDatabase;
22 import android.os.AsyncTask;
23 import android.os.OperationCanceledException;
24
25 import androidx.annotation.NonNull;
26
27 import net.ktnx.mobileledger.App;
28 import net.ktnx.mobileledger.err.HTTPException;
29 import net.ktnx.mobileledger.json.v1_15.AccountListParser;
30 import net.ktnx.mobileledger.json.v1_15.ParsedBalance;
31 import net.ktnx.mobileledger.json.v1_15.ParsedLedgerAccount;
32 import net.ktnx.mobileledger.json.v1_15.ParsedLedgerTransaction;
33 import net.ktnx.mobileledger.json.v1_15.TransactionListParser;
34 import net.ktnx.mobileledger.model.Data;
35 import net.ktnx.mobileledger.model.LedgerAccount;
36 import net.ktnx.mobileledger.model.LedgerTransaction;
37 import net.ktnx.mobileledger.model.LedgerTransactionAccount;
38 import net.ktnx.mobileledger.model.MobileLedgerProfile;
39 import net.ktnx.mobileledger.ui.MainModel;
40 import net.ktnx.mobileledger.utils.NetworkUtil;
41
42 import java.io.BufferedReader;
43 import java.io.IOException;
44 import java.io.InputStream;
45 import java.io.InputStreamReader;
46 import java.net.HttpURLConnection;
47 import java.net.MalformedURLException;
48 import java.net.URLDecoder;
49 import java.nio.charset.StandardCharsets;
50 import java.text.ParseException;
51 import java.util.ArrayList;
52 import java.util.Collections;
53 import java.util.HashMap;
54 import java.util.List;
55 import java.util.Locale;
56 import java.util.Objects;
57 import java.util.regex.Matcher;
58 import java.util.regex.Pattern;
59
60
61 public class RetrieveTransactionsTask extends
62         AsyncTask<Void, RetrieveTransactionsTask.Progress, RetrieveTransactionsTask.Result> {
63     private static final int MATCHING_TRANSACTIONS_LIMIT = 150;
64     private static final Pattern reComment = Pattern.compile("^\\s*;");
65     private static final Pattern reTransactionStart = Pattern.compile(
66             "<tr class=\"title\" " + "id=\"transaction-(\\d+)" + "\"><td class=\"date" +
67             "\"[^\"]*>([\\d.-]+)</td>");
68     private static final Pattern reTransactionDescription =
69             Pattern.compile("<tr class=\"posting\" title=\"(\\S+)\\s(.+)");
70     private static final Pattern reTransactionDetails = Pattern.compile(
71             "^\\s+" + "([!*]\\s+)?" + "(\\S[\\S\\s]+\\S)\\s\\s+" + "(?:([^\\d\\s+\\-]+)\\s*)?" +
72             "([-+]?\\d[\\d,.]*)" + "(?:\\s*([^\\d\\s+\\-]+)\\s*$)?");
73     private static final Pattern reEnd = Pattern.compile("\\bid=\"addmodal\"");
74     private static final Pattern reDecimalPoint = Pattern.compile("\\.\\d\\d?$");
75     private static final Pattern reDecimalComma = Pattern.compile(",\\d\\d?$");
76     // %3A is '='
77     private Pattern reAccountName = Pattern.compile("/register\\?q=inacct%3A([a-zA-Z0-9%]+)\"");
78     private Pattern reAccountValue = Pattern.compile(
79             "<span class=\"[^\"]*\\bamount\\b[^\"]*\">\\s*([-+]?[\\d.,]+)(?:\\s+(\\S+))?</span>");
80     private MainModel mainModel;
81     private MobileLedgerProfile profile;
82     private List<LedgerAccount> prevAccounts;
83     private int expectedPostingsCount = -1;
84     public RetrieveTransactionsTask(@NonNull MainModel mainModel,
85                                     @NonNull MobileLedgerProfile profile,
86                                     List<LedgerAccount> accounts) {
87         this.mainModel = mainModel;
88         this.profile = profile;
89         this.prevAccounts = accounts;
90     }
91     private static void L(String msg) {
92         //debug("transaction-parser", msg);
93     }
94     static LedgerTransactionAccount parseTransactionAccountLine(String line) {
95         Matcher m = reTransactionDetails.matcher(line);
96         if (m.find()) {
97             String postingStatus = m.group(1);
98             String acc_name = m.group(2);
99             String currencyPre = m.group(3);
100             String amount = Objects.requireNonNull(m.group(4));
101             String currencyPost = m.group(5);
102
103             String currency = null;
104             if ((currencyPre != null) && (currencyPre.length() > 0)) {
105                 if ((currencyPost != null) && (currencyPost.length() > 0))
106                     return null;
107                 currency = currencyPre;
108             }
109             else if ((currencyPost != null) && (currencyPost.length() > 0)) {
110                 currency = currencyPost;
111             }
112
113             amount = amount.replace(',', '.');
114
115             return new LedgerTransactionAccount(acc_name, Float.parseFloat(amount), currency, null);
116         }
117         else {
118             return null;
119         }
120     }
121     @Override
122     protected void onProgressUpdate(Progress... values) {
123         super.onProgressUpdate(values);
124         Data.backgroundTaskProgress.postValue(values[0]);
125     }
126     @Override
127     protected void onPostExecute(Result result) {
128         super.onPostExecute(result);
129         Progress progress = new Progress();
130         progress.setState(ProgressState.FINISHED);
131         progress.setError(result.error);
132         onProgressUpdate(progress);
133     }
134     @Override
135     protected void onCancelled() {
136         super.onCancelled();
137         Progress progress = new Progress();
138         progress.setState(ProgressState.FINISHED);
139         onProgressUpdate(progress);
140     }
141     private void retrieveTransactionListLegacy(List<LedgerAccount> accounts,
142                                                List<LedgerTransaction> transactions)
143             throws IOException, HTTPException {
144         Progress progress = Progress.indeterminate();
145         progress.setState(ProgressState.RUNNING);
146         progress.setTotal(expectedPostingsCount);
147         int maxTransactionId = -1;
148         HashMap<String, LedgerAccount> map = new HashMap<>();
149         LedgerAccount lastAccount = null;
150         ArrayList<LedgerAccount> syntheticAccounts = new ArrayList<>();
151
152         HttpURLConnection http = NetworkUtil.prepareConnection(profile, "journal");
153         http.setAllowUserInteraction(false);
154         publishProgress(progress);
155         if (http.getResponseCode() != 200)
156             throw new HTTPException(http.getResponseCode(), http.getResponseMessage());
157
158         try (InputStream resp = http.getInputStream()) {
159             if (http.getResponseCode() != 200)
160                 throw new IOException(String.format("HTTP error %d", http.getResponseCode()));
161
162             int matchedTransactionsCount = 0;
163
164             ParserState state = ParserState.EXPECTING_ACCOUNT;
165             String line;
166             BufferedReader buf =
167                     new BufferedReader(new InputStreamReader(resp, StandardCharsets.UTF_8));
168
169             int processedTransactionCount = 0;
170             int transactionId = 0;
171             LedgerTransaction transaction = null;
172             LINES:
173             while ((line = buf.readLine()) != null) {
174                 throwIfCancelled();
175                 Matcher m;
176                 m = reComment.matcher(line);
177                 if (m.find()) {
178                     // TODO: comments are ignored for now
179 //                            Log.v("transaction-parser", "Ignoring comment");
180                     continue;
181                 }
182                 //L(String.format("State is %d", updating));
183                 switch (state) {
184                     case EXPECTING_ACCOUNT:
185                         if (line.equals("<h2>General Journal</h2>")) {
186                             state = ParserState.EXPECTING_TRANSACTION;
187                             L("→ expecting transaction");
188                             continue;
189                         }
190                         m = reAccountName.matcher(line);
191                         if (m.find()) {
192                             String acct_encoded = m.group(1);
193                             String accName = URLDecoder.decode(acct_encoded, "UTF-8");
194                             accName = accName.replace("\"", "");
195                             L(String.format("found account: %s", accName));
196
197                             lastAccount = map.get(accName);
198                             if (lastAccount != null) {
199                                 L(String.format("ignoring duplicate account '%s'", accName));
200                                 continue;
201                             }
202                             String parentAccountName = LedgerAccount.extractParentName(accName);
203                             LedgerAccount parentAccount;
204                             if (parentAccountName != null) {
205                                 parentAccount = ensureAccountExists(parentAccountName, map,
206                                         syntheticAccounts);
207                             }
208                             else {
209                                 parentAccount = null;
210                             }
211                             lastAccount = new LedgerAccount(profile, accName, parentAccount);
212
213                             accounts.add(lastAccount);
214                             map.put(accName, lastAccount);
215
216                             state = ParserState.EXPECTING_ACCOUNT_AMOUNT;
217                             L("→ expecting account amount");
218                         }
219                         break;
220
221                     case EXPECTING_ACCOUNT_AMOUNT:
222                         m = reAccountValue.matcher(line);
223                         boolean match_found = false;
224                         while (m.find()) {
225                             throwIfCancelled();
226
227                             match_found = true;
228                             String value = Objects.requireNonNull(m.group(1));
229                             String currency = m.group(2);
230                             if (currency == null)
231                                 currency = "";
232
233                             {
234                                 Matcher tmpM = reDecimalComma.matcher(value);
235                                 if (tmpM.find()) {
236                                     value = value.replace(".", "");
237                                     value = value.replace(',', '.');
238                                 }
239
240                                 tmpM = reDecimalPoint.matcher(value);
241                                 if (tmpM.find()) {
242                                     value = value.replace(",", "");
243                                     value = value.replace(" ", "");
244                                 }
245                             }
246                             L("curr=" + currency + ", value=" + value);
247                             final float val = Float.parseFloat(value);
248                             lastAccount.addAmount(val, currency);
249                             for (LedgerAccount syn : syntheticAccounts) {
250                                 L(String.format(Locale.ENGLISH, "propagating %s %1.2f to %s",
251                                         currency, val, syn.getName()));
252                                 syn.addAmount(val, currency);
253                             }
254                         }
255
256                         if (match_found) {
257                             syntheticAccounts.clear();
258                             state = ParserState.EXPECTING_ACCOUNT;
259                             L("→ expecting account");
260                         }
261
262                         break;
263
264                     case EXPECTING_TRANSACTION:
265                         if (!line.isEmpty() && (line.charAt(0) == ' '))
266                             continue;
267                         m = reTransactionStart.matcher(line);
268                         if (m.find()) {
269                             transactionId = Integer.parseInt(Objects.requireNonNull(m.group(1)));
270                             state = ParserState.EXPECTING_TRANSACTION_DESCRIPTION;
271                             L(String.format(Locale.ENGLISH,
272                                     "found transaction %d → expecting description", transactionId));
273                             progress.setProgress(++processedTransactionCount);
274                             if (maxTransactionId < transactionId)
275                                 maxTransactionId = transactionId;
276                             if ((progress.isIndeterminate()) ||
277                                 (progress.getTotal() < transactionId))
278                                 progress.setTotal(transactionId);
279                             publishProgress(progress);
280                         }
281                         m = reEnd.matcher(line);
282                         if (m.find()) {
283                             L("--- transaction value complete ---");
284                             break LINES;
285                         }
286                         break;
287
288                     case EXPECTING_TRANSACTION_DESCRIPTION:
289                         if (!line.isEmpty() && (line.charAt(0) == ' '))
290                             continue;
291                         m = reTransactionDescription.matcher(line);
292                         if (m.find()) {
293                             if (transactionId == 0)
294                                 throw new TransactionParserException(
295                                         "Transaction Id is 0 while expecting description");
296
297                             String date = Objects.requireNonNull(m.group(1));
298                             try {
299                                 int equalsIndex = date.indexOf('=');
300                                 if (equalsIndex >= 0)
301                                     date = date.substring(equalsIndex + 1);
302                                 transaction =
303                                         new LedgerTransaction(transactionId, date, m.group(2));
304                             }
305                             catch (ParseException e) {
306                                 throw new TransactionParserException(
307                                         String.format("Error parsing date '%s'", date));
308                             }
309                             state = ParserState.EXPECTING_TRANSACTION_DETAILS;
310                             L(String.format(Locale.ENGLISH,
311                                     "transaction %d created for %s (%s) →" + " expecting details",
312                                     transactionId, date, m.group(2)));
313                         }
314                         break;
315
316                     case EXPECTING_TRANSACTION_DETAILS:
317                         if (line.isEmpty()) {
318                             // transaction data collected
319
320                             transaction.finishLoading();
321                             transactions.add(transaction);
322
323                             state = ParserState.EXPECTING_TRANSACTION;
324                             L(String.format("transaction %s parsed → expecting transaction",
325                                     transaction.getId()));
326
327 // sounds like a good idea, but transaction-1 may not be the first one chronologically
328 // for example, when you add the initial seeding transaction after entering some others
329 //                                            if (transactionId == 1) {
330 //                                                L("This was the initial transaction.
331 //                                                Terminating " +
332 //                                                  "parser");
333 //                                                break LINES;
334 //                                            }
335                         }
336                         else {
337                             LedgerTransactionAccount lta = parseTransactionAccountLine(line);
338                             if (lta != null) {
339                                 transaction.addAccount(lta);
340                                 L(String.format(Locale.ENGLISH, "%d: %s = %s", transaction.getId(),
341                                         lta.getAccountName(), lta.getAmount()));
342                             }
343                             else
344                                 throw new IllegalStateException(
345                                         String.format("Can't parse transaction %d details: %s",
346                                                 transactionId, line));
347                         }
348                         break;
349                     default:
350                         throw new RuntimeException(
351                                 String.format("Unknown parser updating %s", state.name()));
352                 }
353             }
354
355             throwIfCancelled();
356         }
357     }
358     private @NonNull
359     LedgerAccount ensureAccountExists(String accountName, HashMap<String, LedgerAccount> map,
360                                       ArrayList<LedgerAccount> createdAccounts) {
361         LedgerAccount acc = map.get(accountName);
362
363         if (acc != null)
364             return acc;
365
366         String parentName = LedgerAccount.extractParentName(accountName);
367         LedgerAccount parentAccount;
368         if (parentName != null) {
369             parentAccount = ensureAccountExists(parentName, map, createdAccounts);
370         }
371         else {
372             parentAccount = null;
373         }
374
375         acc = new LedgerAccount(profile, accountName, parentAccount);
376         createdAccounts.add(acc);
377         return acc;
378     }
379     private List<LedgerAccount> retrieveAccountList() throws IOException, HTTPException {
380         HttpURLConnection http = NetworkUtil.prepareConnection(profile, "accounts");
381         http.setAllowUserInteraction(false);
382         switch (http.getResponseCode()) {
383             case 200:
384                 break;
385             case 404:
386                 return null;
387             default:
388                 throw new HTTPException(http.getResponseCode(), http.getResponseMessage());
389         }
390         publishProgress(Progress.indeterminate());
391         SQLiteDatabase db = App.getDatabase();
392         ArrayList<LedgerAccount> list = new ArrayList<>();
393         HashMap<String, LedgerAccount> map = new HashMap<>();
394         HashMap<String, LedgerAccount> currentMap = new HashMap<>();
395         for (LedgerAccount acc : prevAccounts)
396             currentMap.put(acc.getName(), acc);
397         throwIfCancelled();
398         try (InputStream resp = http.getInputStream()) {
399             throwIfCancelled();
400             if (http.getResponseCode() != 200)
401                 throw new IOException(String.format("HTTP error %d", http.getResponseCode()));
402
403             AccountListParser parser = new AccountListParser(resp);
404             expectedPostingsCount = 0;
405
406             while (true) {
407                 throwIfCancelled();
408                 ParsedLedgerAccount parsedAccount = parser.nextAccount();
409                 if (parsedAccount == null) {
410                     break;
411                 }
412                 expectedPostingsCount += parsedAccount.getAnumpostings();
413                 final String accName = parsedAccount.getAname();
414                 LedgerAccount acc = map.get(accName);
415                 if (acc != null)
416                     throw new RuntimeException(
417                             String.format("Account '%s' already present", acc.getName()));
418                 String parentName = LedgerAccount.extractParentName(accName);
419                 ArrayList<LedgerAccount> createdParents = new ArrayList<>();
420                 LedgerAccount parent;
421                 if (parentName == null) {
422                     parent = null;
423                 }
424                 else {
425                     parent = ensureAccountExists(parentName, map, createdParents);
426                     parent.setHasSubAccounts(true);
427                 }
428                 acc = new LedgerAccount(profile, accName, parent);
429                 list.add(acc);
430                 map.put(accName, acc);
431
432                 String lastCurrency = null;
433                 float lastCurrencyAmount = 0;
434                 for (ParsedBalance b : parsedAccount.getAibalance()) {
435                     throwIfCancelled();
436                     final String currency = b.getAcommodity();
437                     final float amount = b.getAquantity()
438                                           .asFloat();
439                     if (currency.equals(lastCurrency)) {
440                         lastCurrencyAmount += amount;
441                     }
442                     else {
443                         if (lastCurrency != null) {
444                             acc.addAmount(lastCurrencyAmount, lastCurrency);
445                         }
446                         lastCurrency = currency;
447                         lastCurrencyAmount = amount;
448                     }
449                 }
450                 if (lastCurrency != null) {
451                     acc.addAmount(lastCurrencyAmount, lastCurrency);
452                 }
453                 for (LedgerAccount p : createdParents)
454                     acc.propagateAmountsTo(p);
455             }
456             throwIfCancelled();
457         }
458
459         // the current account tree may have changed, update the new-to be tree to match
460         for (LedgerAccount acc : list) {
461             LedgerAccount prevData = currentMap.get(acc.getName());
462             if (prevData != null) {
463                 acc.setExpanded(prevData.isExpanded());
464                 acc.setAmountsExpanded(prevData.amountsExpanded());
465             }
466         }
467
468         return list;
469     }
470     private List<LedgerTransaction> retrieveTransactionList()
471             throws IOException, ParseException, HTTPException {
472         Progress progress = new Progress();
473         progress.setTotal(expectedPostingsCount);
474
475         HttpURLConnection http = NetworkUtil.prepareConnection(profile, "transactions");
476         http.setAllowUserInteraction(false);
477         publishProgress(progress);
478         switch (http.getResponseCode()) {
479             case 200:
480                 break;
481             case 404:
482                 return null;
483             default:
484                 throw new HTTPException(http.getResponseCode(), http.getResponseMessage());
485         }
486         ArrayList<LedgerTransaction> trList = new ArrayList<>();
487         try (InputStream resp = http.getInputStream()) {
488             throwIfCancelled();
489
490             TransactionListParser parser = new TransactionListParser(resp);
491
492             int processedPostings = 0;
493
494             while (true) {
495                 throwIfCancelled();
496                 ParsedLedgerTransaction parsedTransaction = parser.nextTransaction();
497                 throwIfCancelled();
498                 if (parsedTransaction == null)
499                     break;
500
501                 LedgerTransaction transaction = parsedTransaction.asLedgerTransaction();
502                 trList.add(transaction);
503
504                 progress.setProgress(processedPostings += transaction.getAccounts()
505                                                                      .size());
506 //                Logger.debug("trParser",
507 //                        String.format(Locale.US, "Parsed transaction %d - %s", transaction
508 //                        .getId(),
509 //                                transaction.getDescription()));
510 //                for (LedgerTransactionAccount acc : transaction.getAccounts()) {
511 //                    Logger.debug("trParser",
512 //                            String.format(Locale.US, "  %s", acc.getAccountName()));
513 //                }
514                 publishProgress(progress);
515             }
516
517             throwIfCancelled();
518         }
519
520         // json interface returns transactions if file order and the rest of the machinery
521         // expects them in reverse chronological order
522         Collections.sort(trList, (o1, o2) -> {
523             int res = o2.getDate()
524                         .compareTo(o1.getDate());
525             if (res != 0)
526                 return res;
527             return Integer.compare(o2.getId(), o1.getId());
528         });
529         return trList;
530     }
531
532     @SuppressLint("DefaultLocale")
533     @Override
534     protected Result doInBackground(Void... params) {
535         Data.backgroundTaskStarted();
536         List<LedgerAccount> accounts;
537         List<LedgerTransaction> transactions;
538         try {
539             accounts = retrieveAccountList();
540             if (accounts == null)
541                 transactions = null;
542             else
543                 transactions = retrieveTransactionList();
544             if (accounts == null || transactions == null) {
545                 accounts = new ArrayList<>();
546                 transactions = new ArrayList<>();
547                 retrieveTransactionListLegacy(accounts, transactions);
548             }
549             mainModel.setAndStoreAccountAndTransactionListFromWeb(accounts, transactions);
550
551             return new Result(accounts, transactions);
552         }
553         catch (MalformedURLException e) {
554             e.printStackTrace();
555             return new Result("Invalid server URL");
556         }
557         catch (HTTPException e) {
558             e.printStackTrace();
559             return new Result(String.format("HTTP error %d: %s", e.getResponseCode(),
560                     e.getResponseMessage()));
561         }
562         catch (IOException e) {
563             e.printStackTrace();
564             return new Result(e.getLocalizedMessage());
565         }
566         catch (ParseException e) {
567             e.printStackTrace();
568             return new Result("Network error");
569         }
570         catch (OperationCanceledException e) {
571             e.printStackTrace();
572             return new Result("Operation cancelled");
573         }
574         finally {
575             Data.backgroundTaskFinished();
576         }
577     }
578     private void throwIfCancelled() {
579         if (isCancelled())
580             throw new OperationCanceledException(null);
581     }
582     private enum ParserState {
583         EXPECTING_ACCOUNT, EXPECTING_ACCOUNT_AMOUNT, EXPECTING_TRANSACTION,
584         EXPECTING_TRANSACTION_DESCRIPTION, EXPECTING_TRANSACTION_DETAILS
585     }
586
587     public enum ProgressState {STARTING, RUNNING, FINISHED}
588
589     public static class Progress {
590         private int progress;
591         private int total;
592         private ProgressState state = ProgressState.RUNNING;
593         private String error = null;
594         private boolean indeterminate;
595         Progress() {
596             indeterminate = true;
597         }
598         Progress(int progress, int total) {
599             this.indeterminate = false;
600             this.progress = progress;
601             this.total = total;
602         }
603         public static Progress indeterminate() {
604             return new Progress();
605         }
606         public static Progress finished(String error) {
607             Progress p = new Progress();
608             p.setState(ProgressState.FINISHED);
609             p.setError(error);
610             return p;
611         }
612         public int getProgress() {
613             ensureState(ProgressState.RUNNING);
614             return progress;
615         }
616         protected void setProgress(int progress) {
617             this.progress = progress;
618             this.state = ProgressState.RUNNING;
619         }
620         public int getTotal() {
621             ensureState(ProgressState.RUNNING);
622             return total;
623         }
624         protected void setTotal(int total) {
625             this.total = total;
626             state = ProgressState.RUNNING;
627             indeterminate = total == -1;
628         }
629         private void ensureState(ProgressState wanted) {
630             if (state != wanted)
631                 throw new IllegalStateException(
632                         String.format("Bad state: %s, expected %s", state, wanted));
633         }
634         public ProgressState getState() {
635             return state;
636         }
637         public void setState(ProgressState state) {
638             this.state = state;
639         }
640         public String getError() {
641             ensureState(ProgressState.FINISHED);
642             return error;
643         }
644         public void setError(String error) {
645             this.error = error;
646             state = ProgressState.FINISHED;
647         }
648         public boolean isIndeterminate() {
649             return indeterminate;
650         }
651         public void setIndeterminate(boolean indeterminate) {
652             this.indeterminate = indeterminate;
653         }
654     }
655
656     private static class TransactionParserException extends IllegalStateException {
657         TransactionParserException(String message) {
658             super(message);
659         }
660     }
661
662     public static class Result {
663         public String error;
664         public List<LedgerAccount> accounts;
665         public List<LedgerTransaction> transactions;
666         Result(String error) {
667             this.error = error;
668         }
669         Result(List<LedgerAccount> accounts, List<LedgerTransaction> transactions) {
670             this.accounts = accounts;
671             this.transactions = transactions;
672         }
673     }
674 }