Compare commits

...

184 Commits

Author SHA1 Message Date
bel
045ab30dfa install script
All checks were successful
cicd / ci (push) Successful in 2m6s
2026-01-03 11:21:47 -07:00
Bel LaPointe
b1cf639f39 un-break shared
All checks were successful
cicd / ci (push) Successful in 3m5s
2025-10-31 07:28:26 -06:00
Bel LaPointe
cc546d9b2d fix
All checks were successful
cicd / ci (push) Successful in 1m14s
2025-10-26 13:11:09 -06:00
Bel LaPointe
0066a267a6 bal prints percents
All checks were successful
cicd / ci (push) Successful in 2m40s
2025-10-26 09:27:07 -06:00
Bel LaPointe
f58053ebe9 5% raise neat
All checks were successful
cicd / ci (push) Successful in 1m55s
2025-10-15 14:55:00 -06:00
Bel LaPointe
b30b1811ea shared flags
All checks were successful
cicd / ci (push) Successful in 1m53s
2025-09-04 22:18:04 -06:00
Bel LaPointe
3fb1ee4ea3 qt got a turtle rock promo~
All checks were successful
cicd / ci (push) Successful in 2m13s
2025-08-28 20:56:35 -06:00
Bel LaPointe
7a790fa31e group date
All checks were successful
cicd / ci (push) Successful in 1m51s
2025-08-23 08:25:08 -06:00
Bel LaPointe
774de58bf7 not u
All checks were successful
cicd / ci (push) Successful in 1m3s
2025-06-16 18:18:04 -06:00
Bel LaPointe
6aa549cb02 bpis predictFixedGrowth fixed
All checks were successful
cicd / ci (push) Successful in 1m15s
2025-06-16 18:12:11 -06:00
Bel LaPointe
fe7c3a9682 oop 3500 saved per mo not 3600
All checks were successful
cicd / ci (push) Successful in 2m45s
2025-06-16 17:58:38 -06:00
Bel LaPointe
36dcf70dbe from Asset to Bel:Asset
All checks were successful
cicd / ci (push) Successful in 1m1s
2025-06-16 17:57:06 -06:00
Bel LaPointe
989bc3c2ff update prediction default
All checks were successful
cicd / ci (push) Successful in 2m19s
2025-06-16 17:55:16 -06:00
Bel LaPointe
f22d7d958b WILDCARD
All checks were successful
cicd / ci (push) Successful in 1m28s
2025-05-25 13:04:27 -06:00
Bel LaPointe
6a4e10ee2b trigger on self 2025-05-25 13:04:06 -06:00
Bel LaPointe
09da985455 build on build/ 2025-05-25 13:03:36 -06:00
Bel LaPointe
f52832ee82 docker build 2025-05-25 13:02:59 -06:00
bel
0cafba0571 bel pay raise
Some checks failed
cicd / ci (push) Failing after 1m30s
2025-05-24 08:46:51 -06:00
Bel LaPointe
1a397dbf45 mmmm giving out security info wasnt great so rotate bank passwords
Some checks failed
cicd / ci (push) Failing after 15s
2025-05-23 23:36:02 -06:00
Bel LaPointe
b10283d752 multi-acc rate limited lookin pretty good~
Some checks failed
cicd / ci (push) Failing after 14s
2025-05-23 23:17:54 -06:00
Bel LaPointe
284613b5bc fix https
Some checks failed
cicd / ci (push) Failing after 14s
2025-05-23 23:01:15 -06:00
Bel LaPointe
929d15c5b7 teller accepts multi token in tokens.txt
Some checks failed
cicd / ci (push) Failing after 17s
2025-05-23 23:00:09 -06:00
Bel LaPointe
5e61378d63 resolved chase since dawn of time yay 2025-05-23 22:38:52 -06:00
Bel LaPointe
c948a32458 fix onedayoff checks day after
Some checks failed
cicd / ci (push) Failing after 15s
2025-05-23 22:24:05 -06:00
Bel LaPointe
35e2e40ce6 oooo onedayoff ok
Some checks failed
cicd / ci (push) Failing after 15s
2025-05-23 22:15:10 -06:00
Bel LaPointe
847cd736b6 at least round both wrong
Some checks failed
cicd / ci (push) Failing after 15s
2025-05-23 22:02:27 -06:00
Bel LaPointe
9cf8cb0736 rec ok with negatives/positives
Some checks failed
cicd / ci (push) Failing after 21s
2025-05-23 21:59:19 -06:00
Bel LaPointe
4997264f4c fix cache test 2025-05-23 21:56:36 -06:00
Bel LaPointe
f69a850bd8 got a silly ui that can yield a test token 2025-05-23 21:38:07 -06:00
Bel LaPointe
5a3d5e5610 stub teller.Init 2025-05-23 21:12:27 -06:00
Bel LaPointe
c38e8529af ready to get a real token 2025-05-23 21:09:26 -06:00
Bel LaPointe
7a946b7604 go run ./ cli rec to pull all from teller
Some checks failed
cicd / ci (push) Failing after 18s
2025-05-23 21:01:09 -06:00
Bel LaPointe
e0fa44eef7 test bank.cache
Some checks failed
cicd / ci (push) Failing after 17s
2025-05-23 20:32:49 -06:00
Bel LaPointe
6440b07d14 impl teller.Client returns bank.*
Some checks failed
cicd / ci (push) Failing after 14s
2025-05-23 20:15:59 -06:00
Bel LaPointe
bae33f8c60 try /transactions too
Some checks failed
cicd / ci (push) Failing after 19s
2025-05-23 19:44:24 -06:00
Bel LaPointe
daa446cc02 teller seems k so long as they accept 2025-05-23 19:41:39 -06:00
Bel LaPointe
daac3907f4 teller certs
Some checks failed
cicd / ci (push) Failing after 1m7s
2025-05-23 19:23:52 -06:00
Bel LaPointe
83305227db no more ofx 2025-05-23 19:20:52 -06:00
Bel LaPointe
f5d82fc6aa ofx died 3y ago 2025-05-23 18:59:39 -06:00
Bel LaPointe
e581b7835c DRAW
Some checks failed
cicd / ci (push) Failing after 23s
2025-05-07 15:54:58 -06:00
Bel LaPointe
19f4b614d3 insta..sh
Some checks failed
cicd / ci (push) Failing after 17s
2025-05-07 15:35:05 -06:00
Bel LaPointe
58462fb5a4 cli graph graphs
Some checks failed
cicd / ci (push) Failing after 51s
2025-05-07 15:34:31 -06:00
Bel LaPointe
2f8dba4e23 accept --cpi=FILE --cpiy=YEAR to translate money to target year
All checks were successful
cicd / ci (push) Successful in 1m30s
2025-05-07 09:55:16 -06:00
Bel LaPointe
0d91cd63db helpers 2025-05-07 09:40:53 -06:00
Bel LaPointe
9681050a46 non usd 3 decimal places
All checks were successful
cicd / ci (push) Successful in 48s
2025-04-11 09:29:13 -06:00
Bel LaPointe
0b4d78796e minor refactor but i think no problem
All checks were successful
cicd / ci (push) Successful in 1m28s
2025-04-11 09:22:41 -06:00
bel
1805a087e0 cli depth doesnt require mindepth
All checks were successful
cicd / ci (push) Successful in 45s
2025-04-06 11:27:54 -06:00
bel
fabd26e8e9 reg prettier print same col w
All checks were successful
cicd / ci (push) Successful in 54s
2025-04-06 11:08:38 -06:00
bel
9646356d7f reg -c prints DATE FULL:ACC DELTA (BAL)
All checks were successful
cicd / ci (push) Successful in 46s
2025-04-06 11:03:53 -06:00
bel
84bbda1031 fix and
All checks were successful
cicd / ci (push) Successful in 51s
2025-04-06 10:40:44 -06:00
bel
9b4accafe3 neater bal print 2025-04-06 10:38:54 -06:00
bel
9352ca82de WAY faster doing cumulative balances over summing [0,i] every reg loop
All checks were successful
cicd / ci (push) Successful in 1m39s
2025-04-06 10:05:42 -06:00
bel
058874b566 buffering writes did NOT save any time
All checks were successful
cicd / ci (push) Successful in 1m37s
2025-04-05 23:07:56 -06:00
bel
8ade122378 ana-ledger-cli shared FILES
All checks were successful
cicd / ci (push) Successful in 55s
2025-04-03 23:36:31 -06:00
bel
fe98fa67f3 printing normalize appends factor and real delta
All checks were successful
cicd / ci (push) Successful in 58s
2025-04-03 23:20:50 -06:00
bel
5cc9b141b9 normalize
All checks were successful
cicd / ci (push) Successful in 1m0s
2025-04-03 23:04:08 -06:00
bel
266af7353a cli accepts -with PATTERN to filter transactions by all attendees
All checks were successful
cicd / ci (push) Successful in 1m31s
2025-04-03 22:32:56 -06:00
Bel LaPointe
dd20f066a3 WIP cmd/cli$ go run ../ cli $(printf " -f %s" $HOME/Sync/Core/ledger/eras/2022-/*.txt) -n -w ^Housey --depth 1 -usd bal
All checks were successful
cicd / ci (push) Successful in 1m32s
2025-04-03 21:39:36 -06:00
Bel LaPointe
697fc16b52 each delta has .with and LikeWith to find X related to Y 2025-04-03 17:28:57 -06:00
Bel LaPointe
8621619433 cli bal nonzero
All checks were successful
cicd / ci (push) Successful in 54s
2025-04-03 12:28:00 -06:00
Bel LaPointe
768ce8e92e cumulative bal in reg includes bpis
All checks were successful
cicd / ci (push) Successful in 59s
2025-04-03 12:25:39 -06:00
Bel LaPointe
757afa603e hide zeros only in bal 2025-04-03 12:11:32 -06:00
Bel LaPointe
d093db1a2b cli supports bal again!
All checks were successful
cicd / ci (push) Successful in 1m35s
2025-04-03 11:55:19 -06:00
Bel LaPointe
30a0414dcd evalulate acc = VALUE in date, filename, lineno order 2025-04-03 11:55:06 -06:00
Bel LaPointe
1f9919a172 deltas try to remember filename, lineno 2025-04-03 11:54:29 -06:00
Bel LaPointe
fb1ddc72c3 ledger.Balances accept Like, Group kinda 2025-04-03 11:53:33 -06:00
Bel LaPointe
0eaeeec359 cli accepts -n normalie
All checks were successful
cicd / ci (push) Successful in 1m50s
2025-04-03 10:24:49 -06:00
Bel LaPointe
188b31aa0c move me and qt salaries to ana.NewDefaultNormalizer 2025-04-03 10:15:05 -06:00
Bel LaPointe
af1b23731a gui CREATE button
All checks were successful
cicd / ci (push) Successful in 1m3s
2025-02-21 16:24:41 -07:00
Bel LaPointe
fcd7dd208c impl /api/create 2025-02-21 15:55:12 -07:00
Bel LaPointe
9bbdeeae9c oop no extra loop
All checks were successful
cicd / ci (push) Successful in 52s
2025-02-21 15:38:03 -07:00
Bel LaPointe
4d01a5e481 impl normalize in HTTP
All checks were successful
cicd / ci (push) Successful in 2m18s
2025-02-21 15:31:09 -07:00
Bel LaPointe
5cc7b7ec55 impl ana.Normalizer 2025-02-21 15:06:44 -07:00
Bel LaPointe
ed0ce83556 impl cli reg depth
All checks were successful
cicd / ci (push) Successful in 1m24s
2025-02-21 14:47:30 -07:00
Bel LaPointe
a1631019f0 tood 2025-02-21 14:37:00 -07:00
Bel LaPointe
d7edc1ff1d nicer but my fsm is wrong if i want to bundle ands first
All checks were successful
cicd / ci (push) Successful in 3m29s
2024-12-13 10:49:19 -07:00
Bel LaPointe
012024ac66 i need fsm 2024-12-13 09:48:24 -07:00
Bel LaPointe
a5b1e653ae go run ./.. cli --period 2024 $(printf " -f %s" $HOME/Sync/Core/ledger/eras/2022-/*) reg [^K]:[^:]*:Retirement$ 2024-12-12 23:03:17 -07:00
Bel LaPointe
37f6f47375 pretty print vs cumulative
All checks were successful
cicd / ci (push) Successful in 1m52s
2024-12-12 22:56:29 -07:00
Bel LaPointe
1a517eb8f2 temp 2024-12-12 22:53:40 -07:00
Bel LaPointe
c3f7800dec can get retirement contributions with grepping 2024-12-12 22:44:38 -07:00
Bel LaPointe
e25d52b141 prettier printing 2024-12-12 22:33:21 -07:00
Bel LaPointe
7a828f8463 reg only prints accounts matching LIKES and not NOTLIKEs 2024-12-12 22:29:30 -07:00
Bel LaPointe
117c300533 reg prints balance per xaction now 2024-12-12 22:28:40 -07:00
Bel LaPointe
4d484b8aa4 reg prints balance per xaction now 2024-12-12 22:27:44 -07:00
Bel LaPointe
4831914251 o my register is a balance series but not a transaction series try again
All checks were successful
cicd / ci (push) Successful in 1m52s
2024-12-12 22:18:38 -07:00
Bel LaPointe
eb3af4b54f ledger balances helpers like Sub, Sum, Invert 2024-12-12 22:17:08 -07:00
Bel LaPointe
849a8696f5 a reg 2024-12-12 22:10:16 -07:00
Bel LaPointe
1a2c88687f progress i guess 2024-12-12 21:59:51 -07:00
Bel LaPointe
889dc48d6c oo more tood 2024-12-12 21:53:18 -07:00
Bel LaPointe
881162357b no debug log 2024-12-12 21:50:18 -07:00
Bel LaPointe
62e65c47df support --period 2024-12-12 21:50:03 -07:00
Bel LaPointe
8ab5a0edf5 prints bal
All checks were successful
cicd / ci (push) Successful in 2m1s
2024-12-12 21:37:02 -07:00
Bel LaPointe
d1b3da3fa3 wip
Some checks failed
cicd / ci (push) Failing after 1m54s
2024-12-12 21:16:02 -07:00
Bel LaPointe
e4d60a9e73 stub cli
All checks were successful
cicd / ci (push) Successful in 1m1s
2024-12-12 11:37:16 -07:00
Bel LaPointe
23bd6e29c9 handle single-line ; comments
All checks were successful
cicd / ci (push) Successful in 1m51s
2024-12-12 11:34:00 -07:00
Bel LaPointe
5587429922 resize
All checks were successful
cicd / ci (push) Successful in 2m6s
2024-07-22 11:40:00 -06:00
Bel LaPointe
e3c1af4f5d update title
All checks were successful
cicd / ci (push) Successful in 50s
2024-07-22 07:16:25 -06:00
Bel LaPointe
ce19c74cb0 oop
All checks were successful
cicd / ci (push) Successful in 1m36s
2024-07-22 07:13:12 -06:00
Bel LaPointe
16bbeb8749 pie trims common prefix and sorts slice names 2024-07-22 07:12:36 -06:00
bel
3211dbd73f home links to pies
All checks were successful
cicd / ci (push) Successful in 1m18s
2024-07-21 17:20:45 -06:00
bel
6df7171057 /api/trends has PIES
All checks were successful
cicd / ci (push) Successful in 1m36s
2024-07-21 17:18:56 -06:00
bel
6be00d6423 /api/trends has 1 pie of median 2024-07-21 17:12:52 -06:00
bel
b8c78fe55e /api/trends prints pie of each of last N months 2024-07-21 17:03:44 -06:00
bel
18a19e52f5 build defaults to ts 2024-07-21 15:44:03 -06:00
bel
bb11214180 always highlight all inbox transactions
All checks were successful
cicd / ci (push) Successful in 50s
2024-07-21 08:43:09 -06:00
bel
6c66b6debb highlight original on amend... 2024-07-21 08:36:16 -06:00
bel
518909b7f7 transactions.html can edit though needs some kinda user feedback of the changed row or something...
All checks were successful
cicd / ci (push) Successful in 1m28s
2024-07-21 08:31:37 -06:00
bel
d69de4da11 ui ready enough for amending 2024-07-21 08:23:34 -06:00
bel
b8323f731f TODO clicking on transactions switches them to a stub state that will be a form that submits amendments BUT ASSERTS ONLY 1 DELTA MODIFIED BEFORE SENDING and sends the first
All checks were successful
cicd / ci (push) Successful in 56s
2024-07-20 21:32:59 -06:00
bel
912c3a2659 anywidth
All checks were successful
cicd / ci (push) Successful in 1m12s
2024-07-20 21:19:42 -06:00
bel
6026d54062 fill a dummy form 2024-07-20 21:16:54 -06:00
bel
b6cf4656c8 dummy mvp is ew 2024-07-20 21:12:49 -06:00
bel
45c4d7b684 transactions.html omits non-payee currency and value table cells
All checks were successful
cicd / ci (push) Successful in 1m3s
2024-07-20 20:45:31 -06:00
bel
59205fba4c ledger.Delta.Payee = true 2024-07-20 20:25:37 -06:00
bel
1d18cb50c5 ledger.Transaction.Payee()
All checks were successful
cicd / ci (push) Successful in 1m21s
2024-07-20 20:10:35 -06:00
bel
bd85dcc86a register cleaner 2024-07-20 19:56:29 -06:00
Bel LaPointe
2ce78d2b42 got transactions into rows in transactions.html, now gotta fix column with for description then go for editable
All checks were successful
cicd / ci (push) Successful in 55s
2024-07-20 11:40:06 -06:00
Bel LaPointe
8886442e89 /api/transactions returns .transactions [][]Delta
All checks were successful
cicd / ci (push) Successful in 1m0s
2024-07-20 11:30:42 -06:00
Bel LaPointe
1e50274681 todo 2024-07-20 11:29:20 -06:00
Bel LaPointe
80ca2ea61e make ledger.Deltas.Transactions()
All checks were successful
cicd / ci (push) Successful in 1m8s
2024-07-20 11:29:10 -06:00
Bel LaPointe
e8f42c7a5d Fix /api/bal t route to router.APIReg
All checks were successful
cicd / ci (push) Successful in 54s
2024-07-20 10:45:59 -06:00
Bel LaPointe
ae44534fc3 api/transactions pretty 2024-07-20 10:43:57 -06:00
bel
cd36e12b68 DEBUG=true go run . http -http :38080 -group-date=^....-.. -group-name=^[^:]*:[^:]* -bpi=./http/bpi.dat ./http/macro.d/ 2024-07-20 10:19:24 -06:00
bel
9a0eb89f54 WIP api transactions doesnt return net house debt hrmmmm
All checks were successful
cicd / ci (push) Successful in 1m39s
2024-07-20 10:18:58 -06:00
bel
fe8d80ffc8 balances.NotLike 2024-07-20 10:05:17 -06:00
bel
f41386b3b4 del code related to last n lines 2024-07-20 09:55:03 -06:00
bel
2141e030ef no more last n lines 2024-07-20 09:53:41 -06:00
bel
ee6ce95c0a ui balance filters and groups to houseyMcHouseface 2024-07-20 09:48:46 -06:00
bel
6d174b031b balances.Group 2024-07-20 09:46:19 -06:00
bel
b6c6e83443 Refactoring http/main into http router
All checks were successful
cicd / ci (push) Successful in 1m39s
2024-07-20 09:27:26 -06:00
bel
ddc95c9054 delta.Transaction export field 2024-07-20 09:27:08 -06:00
bel
666965042a http chart to src/view 2024-07-20 09:07:06 -06:00
Bel LaPointe
f6b8b92bff impl and test src.ledger.Files.Amend(old, new Delta)
All checks were successful
cicd / ci (push) Successful in 1m15s
2024-07-14 14:24:29 -06:00
bel
3e001b8ddd test start
All checks were successful
cicd / ci (push) Successful in 1m28s
2024-07-14 08:42:31 -06:00
bel
682dc9880e todo
All checks were successful
cicd / ci (push) Successful in 1m21s
2024-07-13 23:35:52 -06:00
bel
4e72d25dce todo 2024-07-13 23:29:42 -06:00
bel
0d456f4d73 move files.Add to inbox.txt 2024-07-13 23:23:32 -06:00
bel
9b1ad420fe allow updates as invert + inbox, inserts as inbox 2024-07-13 22:46:59 -06:00
bel
e633ee07ab .src.ledger.Delta.transaction = filename/idx
All checks were successful
cicd / ci (push) Successful in 1m27s
2024-07-13 12:35:10 -06:00
Bel LaPointe
bdcd9fe26a hide because i dont want to finish it
All checks were successful
cicd / ci (push) Successful in 1m15s
2023-11-11 18:43:38 -07:00
Bel LaPointe
2051fa1cdc rename because ledger-ui doesnt like symlinks
All checks were successful
cicd / ci (push) Successful in 50s
2023-11-07 07:00:43 -07:00
Bel LaPointe
f7098def0e use bpi to create monopoly money and weigh contributions to house and update staging for it
All checks were successful
cicd / ci (push) Successful in 1m40s
2023-11-07 06:36:15 -07:00
bel
8fd1a4fedb add helper buttons to Stage Zach/Bel Charge/Payment
All checks were successful
cicd / ci (push) Successful in 46s
2023-10-29 10:37:03 -06:00
bel
566b3ad571 ledger.Files.?etLastNLines trims empty lines 2023-10-29 10:36:45 -06:00
bel
f078a1e6a1 todo, rename cicd to ci, rm more stale images 2023-10-29 10:19:40 -06:00
bel
8ae90619d2 display nice
All checks were successful
cicd / cicd (push) Successful in 58s
2023-10-29 10:15:56 -06:00
bel
89a62ec30c todo 2023-10-29 10:11:52 -06:00
bel
83c8f63fe3 revert
All checks were successful
cicd / cicd (push) Successful in 44s
2023-10-29 10:10:35 -06:00
bel
aaeb3f1930 manual test to confirm symlinks are edited 2023-10-29 10:10:05 -06:00
bel
6604c26204 symlink 2023-10-29 10:09:31 -06:00
bel
ba6d6483e0 i think files.TempLastNLines are symlink friendly... 2023-10-29 10:09:14 -06:00
bel
58cafcfaa3 trim /public from http 2023-10-29 09:52:57 -06:00
bel
b006333035 one more open
All checks were successful
cicd / cicd (push) Successful in 47s
2023-10-29 09:39:26 -06:00
bel
b2d233bb82 editor works and is gross but works
All checks were successful
cicd / cicd (push) Has been cancelled
2023-10-29 09:38:30 -06:00
bel
253bff9f1d cp 2023-10-29 09:37:02 -06:00
bel
09f3baee4d here we go 2023-10-29 09:36:16 -06:00
bel
4de3b4a822 at least it 501s nicely 2023-10-29 09:33:53 -06:00
bel
2f21a23a33 stub 2023-10-29 09:29:27 -06:00
bel
227de17951 wip 2023-10-29 09:21:06 -06:00
bel
a623dcc195 impl ledger.Files.TempGet/SetLastNLines 2023-10-29 09:14:10 -06:00
bel
569e50b162 iwp 2023-10-29 09:13:19 -06:00
bel
622c173264 wip 2023-10-29 09:08:44 -06:00
bel
ce5bf71f6b wip 2023-10-29 09:05:39 -06:00
bel
c75bd74823 todo
Some checks failed
cicd / cicd (push) Failing after 39s
2023-10-28 10:54:23 -06:00
bel
93e5a77e04 auto-compute TODAY-6 months
All checks were successful
cicd / cicd (push) Successful in 43s
2023-10-28 10:49:43 -06:00
bel
cdad3092e1 todo 2023-10-28 10:41:04 -06:00
bel
432b2df131 embed static files
All checks were successful
cicd / cicd (push) Successful in 44s
2023-10-28 10:38:28 -06:00
bel
cb6e23a498 wider transactions.html
All checks were successful
cicd / cicd (push) Successful in 42s
2023-10-28 10:31:10 -06:00
bel
f7a9bd7bb7 only try rmi and push before 2023-10-28 10:26:29 -06:00
bel
476c44f5cd accept dir for http args
All checks were successful
cicd / cicd (push) Successful in 12s
2023-10-28 10:24:29 -06:00
bel
4b2af6b85e todo 2023-10-28 10:10:09 -06:00
bel
0baf3ccc8f move cmd into cmd/http as subcommand
All checks were successful
cicd / cicd (push) Successful in 44s
2023-10-28 09:52:39 -06:00
bel
8b7d0e84c0 trim 2023-10-28 09:48:21 -06:00
bel
4ec42ad877 only gitea when cmd or src changes 2023-10-28 09:48:07 -06:00
bel
fbbcaa7f78 double
Some checks failed
cicd / cicd (push) Failing after 9s
2023-10-28 09:45:37 -06:00
bel
f0411fe6db lets go lazy
Some checks failed
cicd / cicd (push) Failing after 8s
2023-10-28 09:45:13 -06:00
bel
910aa78faf todo 2023-10-28 09:44:58 -06:00
bel
8f91316d27 go mod vendor
All checks were successful
cicd / cicd (push) Successful in 42s
2023-10-28 09:43:26 -06:00
bel
45c9f51dc6 gitea build
All checks were successful
cicd / cicd (push) Successful in 1m1s
2023-10-28 09:41:52 -06:00
bel
500e428b23 /build docker 2023-10-28 09:41:39 -06:00
bel
15e3c6f69b denest cmd 2023-10-28 09:36:39 -06:00
bel
ea9e9c6b70 rename clitest to http 2023-10-28 09:32:53 -06:00
bel
ea13bf7e4a mv /ana, /ledger to /src/ 2023-10-28 09:29:39 -06:00
535 changed files with 244121 additions and 1241 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
**/*.sw*
cmd/cmd
/.git

View File

@@ -3,14 +3,19 @@ on:
push:
branches:
- main
paths:
- 'cmd/**'
- 'src/**'
- 'build/**'
- '.gitea/**'
jobs:
cicd:
name: cicd
ci:
name: ci
runs-on: dind
steps:
- name: checkout
uses: actions/checkout@v3
- name: cicd
- name: ci
run: |
ls
bash ./build/build.sh

2
.gitignore vendored
View File

@@ -1,2 +1,2 @@
**/*.sw*
cmd/clitest/clitest
cmd/cmd

11
build/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM golang:1.23.9-alpine3.21 as builder
COPY ./ /go/src/ana-ledger
WORKDIR /go/src/ana-ledger
RUN cd ./cmd; go build -o /go/bin/ana-ledger
FROM alpine:3.18.4
COPY --from=builder /go/bin/ana-ledger /bin/
CMD []
ENTRYPOINT ["/bin/ana-ledger"]

14
build/build.sh Normal file
View File

@@ -0,0 +1,14 @@
#! /bin/bash
set -e
img=registry-app.inhome.blapointe.com:5001/bel/ana-ledger:${1:-$(date +%Y%m%d%H%M)}
cd "$(dirname "$(dirname "$(realpath "$BASH_SOURCE")")")"
was=$(docker inspect $img | jq -r .[0].Id | sed 's/^sha256://')
docker build -f ./build/Dockerfile -t $img .
now=$(docker inspect $img | jq -r .[0].Id | sed 's/^sha256://')
docker push $img
if [ -n "$was" ] && [ "$was" != "$now" ] && docker inspect "$was" &> /dev/null; then
docker rmi "$was" || true
docker rmi $(docker ps | grep ${img%:*} | grep '<none>' | awk '{print $3}') || true
fi

21
cmd/cli/config.go Normal file
View File

@@ -0,0 +1,21 @@
package cli
type Config struct {
Files FileList
BPI string
CPI string
CPIYear int
Query struct {
Period Period
Sort string
NoRounding bool
Depth int
With string
NoExchanging bool
Normalize bool
USDOnly bool
}
Compact bool
GroupDate string
NoPercent bool
}

1
cmd/cli/cpi.dat Symbolic link
View File

@@ -0,0 +1 @@
../../../../../../Sync/Core/ledger/cpi.dat

90
cmd/cli/flag.go Normal file
View File

@@ -0,0 +1,90 @@
package cli
import (
"fmt"
"os"
"strings"
"time"
)
type FlagStringArray []string
func (array *FlagStringArray) String() string {
return strings.Join(*array, ", ")
}
func (array *FlagStringArray) Set(s string) error {
*array = append(*array, s)
return nil
}
type FileList FlagStringArray
func (fileList FileList) Strings() []string {
return ([]string)(fileList)
}
func (fileList *FileList) String() string {
return (*FlagStringArray)(fileList).String()
}
func (fileList *FileList) Set(s string) error {
if _, err := os.Stat(s); os.IsNotExist(err) {
return fmt.Errorf("file does not exist: %s", s)
}
return (*FlagStringArray)(fileList).Set(s)
}
type Period struct {
Start time.Time
Stop time.Time
}
func (period Period) Empty() bool {
return period.Stop.Sub(period.Start) == 0
}
func (period *Period) String() string {
return fmt.Sprintf("%s..%s", period.Start, period.Stop)
}
func (period *Period) Set(s string) error {
ss := strings.Split(s, "..")
if err := period.setStartStop(ss[0]); err != nil {
return err
}
if len(ss) != 2 {
} else if err := period.setStop(ss[1]); err != nil {
return err
}
return nil
}
func (period *Period) setStartStop(s string) error {
stop, err := period.setT(s, &period.Start)
period.Stop = stop
return err
}
func (period *Period) setStop(s string) error {
_, err := period.setT(s, &period.Stop)
return err
}
func (*Period) setT(s string, t *time.Time) (time.Time, error) {
if result, err := time.Parse("2006", s); err == nil {
*t = result
return result.AddDate(1, 0, 0).Add(-1 * time.Minute), nil
}
if result, err := time.Parse("2006-01", s); err == nil {
*t = result
return result.AddDate(0, 1, 0).Add(-1 * time.Minute), nil
}
if result, err := time.Parse("2006-01-02", s); err == nil {
*t = result
return result.AddDate(0, 0, 1).Add(-1 * time.Minute), nil
}
return time.Time{}, fmt.Errorf("unimplemented format: %s", s)
}

443
cmd/cli/main.go Normal file
View File

@@ -0,0 +1,443 @@
package cli
import (
"bufio"
"context"
"flag"
"fmt"
"io"
"math"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/ana"
"gogs.inhome.blapointe.com/ana-ledger/src/bank"
"gogs.inhome.blapointe.com/ana-ledger/src/bank/cache"
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
"github.com/guptarohit/asciigraph"
"golang.org/x/crypto/ssh/terminal"
)
func Main() {
var config Config
fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
fs.Var(&config.Files, "f", "paths to files")
fs.Var(&config.Query.Period, "period", "period can be YYYY, YYYY-mm, YYYY-mm-dd, x..y")
fs.StringVar(&config.Query.Sort, "S", "", "sort ie date")
fs.BoolVar(&config.Query.NoRounding, "no-rounding", false, "no rounding")
fs.BoolVar(&config.Compact, "c", false, "reg entries oneline")
fs.StringVar(&config.Query.With, "w", "", "regexp for transactions")
fs.IntVar(&config.Query.Depth, "depth", 0, "depth grouping")
fs.BoolVar(&config.Query.Normalize, "n", false, "normalize with default normalizer")
fs.BoolVar(&config.Query.USDOnly, "usd", false, "filter to usd")
fs.BoolVar(&config.Query.NoExchanging, "no-exchanging", true, "omit currency exchanges")
fs.StringVar(&config.BPI, "bpi", "", "path to bpi")
fs.StringVar(&config.CPI, "cpi", "", "path to cpi")
fs.StringVar(&config.GroupDate, "group-date", "^....-..-..", "date grouping")
fs.IntVar(&config.CPIYear, "cpiy", 0, "use cpi to convert usd to this year's value")
fs.BoolVar(&config.NoPercent, "no-percent", false, "compute percent")
if err := fs.Parse(os.Args[1:]); err != nil {
panic(err)
}
files := config.Files.Strings()
if len(files) == 0 {
panic("must specify at least one file")
}
ledgerFiles, err := ledger.NewFiles(files[0], files[1:]...)
if err != nil {
panic(err)
}
positional := fs.Args()
if len(positional) == 0 || len(positional[0]) < 3 {
panic("positional arguments required, ie bal|reg PATTERN MATCHING")
}
cmd := positional[0]
q, err := BuildQuery(config, positional[1:])
if err != nil {
panic(err)
}
deltas, err := ledgerFiles.Deltas()
if err != nil {
panic(err)
}
if period := config.Query.Period; !period.Empty() {
after := period.Start.Format("2006-01-02")
before := period.Stop.Format("2006-01-02")
deltas = deltas.Like(
ledger.LikeAfter(after),
ledger.LikeBefore(before),
)
}
pattern := ".*"
if depth := config.Query.Depth; depth > 0 {
patterns := []string{}
pattern = ""
for i := 0; i < depth; i++ {
pattern += "[^:]*:"
patterns = append([]string{strings.Trim(pattern, ":")}, patterns...)
}
pattern = strings.Join(patterns, "|")
}
group := ledger.GroupName(pattern)
bpis := make(ledger.BPIs)
if config.BPI != "" {
b, err := ledger.NewBPIs(config.BPI)
if err != nil {
panic(err)
}
bpis = b
}
cpiNormalizer := ana.NewNormalizer()
if config.CPI != "" && config.CPIYear > 0 {
c, err := ledger.NewBPIs(config.CPI)
if err != nil {
panic(err)
}
cpi := c["CPI"]
cpiy := cpi.Lookup(fmt.Sprintf("%d-06-01", config.CPIYear))
if cpiy == nil {
panic(fmt.Errorf("no cpi for year %d", config.CPIYear))
}
for date, value := range cpi {
cpiNormalizer = cpiNormalizer.With(".*", date, value/(*cpiy))
}
}
if config.Query.Normalize {
deltas = ana.NewDefaultNormalizer().Normalize(deltas)
}
if config.Query.With != "" {
deltas = deltas.Like(ledger.LikeWith(config.Query.With))
}
w := bufio.NewWriter(os.Stdout)
defer w.Flush()
maxAccW := 0
for _, delta := range deltas.Like(q).Group(group) {
if accW := len(delta.Name); accW > maxAccW {
maxAccW = accW
}
}
switch cmd[:3] {
case "bal": // balances
balances := deltas.Group(ledger.GroupDate(config.GroupDate)).Balances().
WithBPIs(bpis).
KindaLike(q).
KindaGroup(group).
Nonzero().
Normalize(cpiNormalizer, "9")
cumulatives := make(ledger.Balances)
cumulativesFormat := "%s%.2f"
if !config.NoPercent {
var sum float64
for key := range balances {
if _, ok := cumulatives[key]; !ok {
cumulatives[key] = make(ledger.Balance)
}
for currency, val := range balances[key] {
if currency == ledger.USD {
cumulatives[key][currency] = val
sum += val
} else {
cumulatives[key][currency] = 0
}
}
}
for key := range cumulatives {
cumulatives[key][ledger.USD] = 100 * cumulatives[key][ledger.USD] / sum
}
cumulativesFormat = "%.0f%%"
}
FPrintBalances(w, "", balances, cumulatives, config.Query.USDOnly, config.Query.Normalize, time.Now().Format("2006-01-02"), false, maxAccW, cumulativesFormat)
case "gra": // graph
dateGrouping := "^[0-9]{4}-[0-9]{2}"
if period := config.Query.Period; !period.Empty() {
day := time.Hour * 24
year := day * 365
r := period.Stop.Sub(period.Start)
if r > 10*year {
dateGrouping = "^[0-9]{4}"
} else if r > 5*year {
} else if r > year {
dateGrouping = "^[0-9]{4}-[0-9]{2}-[0-9]"
} else {
dateGrouping = "^[0-9]{4}-[0-9]{2}-[0-9]{2}"
}
}
deltas = deltas.Group(ledger.GroupDate(dateGrouping))
transactions := deltas.Transactions()
cumulative := make(ledger.Balances)
data := map[string][]float64{}
pushData := func() {
soFar := 0
for _, v := range data {
soFar = len(v)
}
for k, balance := range cumulative {
for curr, v := range balance {
if curr != ledger.USD {
continue
}
if _, ok := data[k]; !ok {
data[k] = make([]float64, soFar)
}
data[k] = append(data[k], v)
}
}
}
for i, transaction := range transactions {
if i > 0 && transactions[i-1][0].Date != transaction[0].Date {
pushData()
}
balances := ledger.Deltas(transaction).
Like(q).
Group(group).
Balances().
WithBPIsAt(bpis, transaction[0].Date).
Nonzero().
Normalize(cpiNormalizer, transaction[0].Date)
cumulative.PushAll(balances)
}
pushData()
labels := []string{}
for k := range data {
labels = append(labels, k)
}
slices.Sort(labels)
points := [][]float64{}
for _, k := range labels {
points = append(points, data[k])
}
for i := range points {
for j := range points[i] {
points[i][j] /= 1000.0
}
}
options := []asciigraph.Option{asciigraph.Precision(0)}
if _, h, err := terminal.GetSize(0); err == nil && h > 4 {
options = append(options, asciigraph.Height(h-4))
}
if len(labels) < 256 {
seriesColors := make([]asciigraph.AnsiColor, len(labels))
for i := range seriesColors {
seriesColors[i] = asciigraph.AnsiColor(i)
}
options = append(options, asciigraph.SeriesLegends(labels...))
options = append(options, asciigraph.SeriesColors(seriesColors...))
}
fmt.Println(asciigraph.PlotMany(points, options...))
case "rec": // reconcile via teller // DEAD
panic("dead and bad")
byDate := map[string]ledger.Deltas{}
for _, delta := range deltas {
delta := delta
byDate[delta.Date] = append(byDate[delta.Date], delta)
}
ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
defer can()
teller, err := teller.New()
if err != nil {
panic(err)
}
client := cache.New(teller)
accounts, err := client.Accounts(ctx)
if err != nil {
panic(err)
}
inDay := func(date string, transaction bank.Transaction) bool {
return slices.ContainsFunc(byDate[date], func(d ledger.Delta) bool {
v := fmt.Sprintf("%.2f", d.Value)
nv := fmt.Sprintf("%.2f", -1.0*d.Value)
a := fmt.Sprintf("%.2f", transaction.Amount)
return v == a || nv == a
})
}
for _, acc := range accounts {
transactions, err := client.Transactions(ctx, acc)
if err != nil {
panic(err)
}
for _, transaction := range transactions {
if inDay(transaction.Date, transaction) {
continue
}
msg := "missing"
ts, err := time.ParseInLocation("2006-01-02", transaction.Date, time.Local)
if err != nil {
panic(err)
}
dayBefore := ts.Add(-24 * time.Hour).Format("2006-01-02")
dayAfter := ts.Add(24 * time.Hour).Format("2006-01-02")
if inDay(dayBefore, transaction) || inDay(dayAfter, transaction) {
msg = "1dayoff"
}
prefix := " "
if transaction.Status != "posted" {
prefix = "! "
}
fmt.Printf("[%s] %s $%7.2f %s%s (%s)\n", msg, transaction.Date, transaction.Amount, prefix, transaction.Details.CounterParty.Name, transaction.Description)
}
}
case "reg": // reg
deltas = deltas.Group(ledger.GroupDate(config.GroupDate))
transactions := deltas.Transactions()
cumulative := make(ledger.Balances)
for _, transaction := range transactions {
balances := ledger.Deltas(transaction).Like(q).Group(group).Balances().WithBPIsAt(bpis, transaction[0].Date).Nonzero().Normalize(cpiNormalizer, transaction[0].Date)
shouldPrint := false
shouldPrint = shouldPrint || len(balances) > 2
if config.Query.NoExchanging {
shouldPrint = shouldPrint || len(balances) > 1
for _, v := range balances {
shouldPrint = shouldPrint || len(v) == 1
}
} else {
shouldPrint = shouldPrint || len(balances) > 0
}
if shouldPrint {
cumulative.PushAll(balances)
cumulative = cumulative.Nonzero()
FPrintBalancesFor(transaction[0].Description, w, "\t\t", balances, cumulative, config.Query.USDOnly, config.Query.Normalize, transaction[0].Date, config.Compact, maxAccW, "%s%.2f")
}
}
default:
panic("unknown command " + positional[0])
}
}
func FPrintBalancesFor(description string, w io.Writer, linePrefix string, balances, cumulatives ledger.Balances, usdOnly, normalized bool, date string, compact bool, keyW int, cumulativeFormat string) {
if compact {
FPrintBalances(w, date+"\t", balances, cumulatives, usdOnly, normalized, date, compact, keyW, cumulativeFormat)
} else {
fmt.Fprintf(w, "%s\t%s\n", date, description)
FPrintBalances(w, linePrefix, balances, cumulatives, usdOnly, normalized, date, compact, keyW, cumulativeFormat)
}
}
func FPrintBalances(w io.Writer, linePrefix string, balances, cumulatives ledger.Balances, usdOnly, normalized bool, date string, fullKey bool, max int, cumulativeFormat string) {
maxes := map[ledger.Currency]float64{}
keys := []string{}
for k, v := range balances {
keys = append(keys, k)
for k2, v2 := range v {
if math.Abs(v2) > math.Abs(maxes[k2]) {
maxes[k2] = math.Abs(v2)
}
}
}
slices.Sort(keys)
normalizer := ana.NewDefaultNormalizer()
cumulativeFormat = strings.ReplaceAll(cumulativeFormat, "%", "%%")
format := fmt.Sprintf("%s%%-%ds\t%%s%%.2f ("+cumulativeFormat+")\n", linePrefix, max)
if normalized {
format = fmt.Sprintf("%s%%-%ds\t%%s%%.2f (%%s%%.2f (%%.2f @%%.2f ("+cumulativeFormat+")))\n", linePrefix, max)
}
for i, key := range keys {
printableKey := key
if fullKey {
} else if i == 0 {
} else {
commonPrefixLen := func() int {
j := 0
n := len(keys[i])
if n2 := len(keys[i-1]); n2 < n {
n = n2
}
for j = 0; j < n; j++ {
if keys[i-1][j] != keys[i][j] {
break
}
}
for keys[i][j] != ':' && j > 0 {
j -= 1
}
return j
}()
printableKey = strings.Repeat(" ", commonPrefixLen) + keys[i][commonPrefixLen:]
}
currencies := []ledger.Currency{}
for currency := range balances[key] {
currencies = append(currencies, currency)
}
slices.Sort(currencies)
for _, currency := range currencies {
printableCurrency := currency
format := format
if printableCurrency != "$" {
printableCurrency += " "
format = strings.ReplaceAll(format, "%.2f", "%.3f")
}
if usdOnly && printableCurrency != "$" {
continue
}
cumulative := balances[key][currency]
if balance, ok := cumulatives[key]; !ok {
} else if value, ok := balance[currency]; !ok {
} else {
cumulative = value
}
printingPercents := strings.Contains(cumulativeFormat, "%%%%")
if !printingPercents {
if !normalized {
fmt.Fprintf(w, format,
printableKey,
printableCurrency, balances[key][currency],
printableCurrency, cumulative,
)
} else {
factor := normalizer.NormalizeFactor(key, date)
trailingMax := maxes[currency] - math.Abs(balances[key][currency])
fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], printableCurrency, cumulative, cumulative*factor, factor, printableCurrency, factor*trailingMax)
}
} else {
if !normalized {
fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], cumulative)
} else {
factor := normalizer.NormalizeFactor(key, date)
trailingMax := maxes[currency] - math.Abs(balances[key][currency])
fmt.Fprintf(w, format, printableKey, printableCurrency, balances[key][currency], printableCurrency, cumulative, cumulative*factor, factor, factor*trailingMax)
}
}
}
}
}

100
cmd/cli/query.go Normal file
View File

@@ -0,0 +1,100 @@
package cli
import (
"fmt"
"log"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
type Query struct{}
func BuildQuery(config Config, args args) (ledger.Like, error) {
var result ledger.Like
var err error
func() {
defer func() {
if err := recover(); err != nil {
err = fmt.Errorf("panicked: %v", err)
}
}()
result, err = buildQuery(config, args)
}()
return result, err
}
func buildQuery(config Config, args args) (ledger.Like, error) {
likeName := func(s string) ledger.Like {
return ledger.LikeName(s)
}
andLike := func(a, b ledger.Like) ledger.Like {
return (ledger.Likes{a, b}).All
}
orLike := func(a, b ledger.Like) ledger.Like {
return (ledger.Likes{a, b}).Any
}
notLike := func(a ledger.Like) ledger.Like {
return ledger.LikeNot(a)
}
if !args.more() {
return likeName(""), nil
}
like := likeName("ajskfdlsjfkdasj")
for args.more() {
switch args.peek() {
case "and":
args.pop()
var and ledger.Like
switch args.peek() {
case "not":
args.pop()
log.Println("and not", args.peek())
and = notLike(likeName(args.pop()))
default:
log.Println("and ", args.peek())
and = likeName(args.pop())
}
like = andLike(like, and)
case "not":
args.pop()
log.Println("or not ", args.peek())
like = orLike(like, notLike(
likeName(args.pop()),
))
default:
log.Println("or ", args.peek())
like = orLike(like,
likeName(args.pop()),
)
}
}
return like, nil
}
type args []string
func (args *args) pop() string {
if !args.more() {
panic("expected more arguments")
}
first := ""
if len(*args) > 0 {
first = (*args)[0]
*args = (*args)[1:]
}
return first
}
func (args *args) peek() string {
if len(*args) == 0 {
return ""
}
return (*args)[0]
}
func (args *args) more() bool {
return len(*args) > 0
}

12
cmd/cli/run.sh Normal file
View File

@@ -0,0 +1,12 @@
#! /bin/bash
cmd="bal"
case "$1" in
bal|reg|gra|graph|rec )
cmd="$1"
shift
;;
esac
cd "$(dirname "$(realpath "$BASH_SOURCE")")"
go run ../ cli "$@" $(printf " -f %s" $HOME/Sync/Core/ledger/eras/2022-/*.txt) "$cmd" :AssetAccount:

View File

@@ -1,473 +0,0 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"maps"
"net/http"
"os"
"slices"
"sort"
"strconv"
"strings"
"time"
"github.com/go-echarts/go-echarts/v2/charts"
"github.com/go-echarts/go-echarts/v2/opts"
"gogs.inhome.blapointe.com/ana-ledger/ana"
"gogs.inhome.blapointe.com/ana-ledger/ledger"
)
func main() {
foo := flag.String("foo", "bal", "bal or reg")
likeName := flag.String("like-name", ".", "regexp to match")
likeBefore := flag.String("like-before", "9", "date str to compare")
likeAfter := flag.String("like-after", "0", "date str to compare")
likeLedger := flag.Bool("like-ledger", false, "limit data to these -like-* rather than zoom to these -like-*")
groupName := flag.String("group-name", ".*", "grouping to apply to names")
groupDate := flag.String("group-date", ".*", "grouping to apply to dates")
bpiPath := flag.String("bpi", "/dev/null", "bpi file")
jsonOutput := flag.Bool("json", false, "json output")
httpOutput := flag.String("http", "", "http output listen address, like :8080")
flag.Parse()
if flag.NArg() < 1 {
panic(fmt.Errorf("positional arguments for files required"))
}
f, err := ledger.NewFiles(flag.Args()[0], flag.Args()[1:]...)
if err != nil {
panic(err)
}
bpis := make(ledger.BPIs)
if *bpiPath != "" {
bpis, err = ledger.NewBPIs(*bpiPath)
if err != nil {
panic(err)
}
}
if *httpOutput != "" {
foo := func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api") {
http.FileServer(http.Dir("./public")).ServeHTTP(w, r)
return
}
switch r.URL.Path {
case "/api/transactions":
reqF := f
if queryF := r.URL.Query().Get("f"); queryF != "" {
reqF, err = ledger.NewFiles(queryF)
if err != nil {
panic(err)
}
}
deltas, err := reqF.Deltas()
if err != nil {
panic(err)
}
json.NewEncoder(w).Encode(map[string]any{
"deltas": deltas.Like(ledger.LikeAfter(time.Now().Add(-1 * time.Hour * 24 * 365 / 2).Format("2006-01"))),
"balances": deltas.Balances().Like("^AssetAccount:").WithBPIs(bpis),
})
return
}
deltas, err := f.Deltas()
if err != nil {
panic(err)
}
deltas = deltas.Group(ledger.GroupName(*groupName), ledger.GroupDate(*groupDate))
like := ledger.Likes{
ledger.LikeName(*likeName),
ledger.LikeBefore(*likeBefore),
ledger.LikeAfter(*likeAfter),
}
foolike := make(ledger.Likes, 0)
for _, v := range r.URL.Query()["likeName"] {
foolike = append(foolike, ledger.LikeName(v))
}
for _, v := range r.URL.Query()["likeAfter"] {
foolike = append(foolike, ledger.LikeAfter(v))
}
for _, v := range r.URL.Query()["likeBefore"] {
foolike = append(foolike, ledger.LikeBefore(v))
}
if len(foolike) == 0 {
foolike = like
}
deltas = deltas.Like(foolike...)
// MODIFIERS
for i, whatIf := range r.URL.Query()["whatIf"] {
fields := strings.Fields(whatIf)
date := "2001-01"
name := fields[0]
currency := ledger.Currency(fields[1])
value, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
panic(err)
}
deltas = append(deltas, ledger.Delta{
Date: date,
Name: name,
Value: value,
Currency: currency,
Description: fmt.Sprintf("?whatIf[%d]", i),
})
}
register := deltas.Register()
predicted := make(ledger.Register)
bpis := maps.Clone(bpis)
if predictionMonths, err := strconv.ParseInt(r.URL.Query().Get("predictionMonths"), 10, 16); err == nil && predictionMonths > 0 {
window := time.Hour * 24.0 * 365.0 / 12.0 * time.Duration(predictionMonths)
// TODO whatif
prediction := make(ana.Prediction, 0)
for _, spec := range r.URL.Query()["prediction"] {
idx := strings.Index(spec, "=")
k := spec[:idx]
fields := strings.Fields(spec[idx+1:])
switch k {
case "interest":
apy, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
panic(err)
}
prediction = append(prediction, ana.NewInterestPredictor(fields[0], fields[1], apy))
case "autoContributions":
prediction = append(prediction, ana.NewAutoContributionPredictor(register))
case "contributions":
name := fields[0]
currency := ledger.Currency(fields[1])
value, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
panic(err)
}
prediction = append(prediction, ana.NewContributionPredictor(ledger.Balances{name: ledger.Balance{currency: value}}))
default:
panic(k)
}
}
predicted = prediction.Predict(register, window)
for _, currencyRate := range r.URL.Query()["predictFixedGrowth"] {
currency := strings.Split(currencyRate, "=")[0]
rate, err := strconv.ParseFloat(strings.Split(currencyRate, "=")[1], 64)
if err != nil {
panic(err)
}
bpis, err = ana.BPIsWithFixedGrowthPrediction(bpis, window, currency, rate)
if err != nil {
panic(err)
}
}
}
if r.URL.Query().Get("bpi") == "true" {
register = register.WithBPIs(bpis)
predicted = predicted.WithBPIs(bpis)
}
if zoomStart, err := time.ParseInLocation("2006-01", r.URL.Query().Get("zoomStart"), time.Local); err == nil {
register = register.Between(zoomStart, time.Now().Add(time.Hour*24*365*100))
predicted = predicted.Between(zoomStart, time.Now().Add(time.Hour*24*365*100))
}
// /MODIFIERS
dates := register.Dates()
names := register.Names()
for _, date := range predicted.Dates() {
found := false
for i := range dates {
found = found || dates[i] == date
}
if !found {
dates = append(dates, date)
}
}
for _, name := range predicted.Names() {
found := false
for i := range names {
found = found || names[i] == name
}
if !found {
names = append(names, name)
}
}
instant := map[string]string{}
toChart := func(cumulative bool, display string, reg ledger.Register) Chart {
nameCurrencyDateValue := map[string]map[ledger.Currency]map[string]float64{}
for date, balances := range reg {
for name, balance := range balances {
for currency, value := range balance {
if _, ok := nameCurrencyDateValue[name]; !ok {
nameCurrencyDateValue[name] = make(map[ledger.Currency]map[string]float64)
}
if _, ok := nameCurrencyDateValue[name][currency]; !ok {
nameCurrencyDateValue[name][currency] = make(map[string]float64)
}
nameCurrencyDateValue[name][currency][date] += value
}
}
}
chart := NewChart("line")
if v := display; v != "" {
chart = NewChart(v)
}
chart.AddX(dates)
if cumulative {
for _, name := range names {
currencyDateValue := nameCurrencyDateValue[name]
for currency, dateValue := range currencyDateValue {
series := make([]int, len(dates))
for i := range dates {
var lastValue float64
for j := range dates[:i+1] {
if newLastValue, ok := dateValue[dates[j]]; ok {
lastValue = newLastValue
}
}
series[i] = int(lastValue)
}
key := fmt.Sprintf("%s (%s)", name, currency)
for i := range dates {
if !(reg.Dates()[0] <= dates[i] && dates[i] <= reg.Dates()[len(reg.Dates())-1]) {
series[i] = 0
} else {
instant[key] = fmt.Sprintf("@%s %v", dates[i], series[i])
}
}
if slices.Min(series) != 0 || slices.Max(series) != 0 {
chart.AddY(key, series)
}
}
}
} else {
for _, name := range names {
currencyDateValue := nameCurrencyDateValue[name]
for currency, dateValue := range currencyDateValue {
series := make([]int, len(dates))
for i := range dates {
var prevValue float64
var lastValue float64
for j := range dates[:i+1] {
if newLastValue, ok := dateValue[dates[j]]; ok {
prevValue = lastValue
lastValue = newLastValue
}
}
series[i] = int(lastValue - prevValue)
}
for i := range series { // TODO no prior so no delta
if series[i] != 0 {
series[i] = 0
break
}
}
key := fmt.Sprintf("%s (%s)", name, currency)
for i := range dates {
if !(reg.Dates()[0] <= dates[i] && dates[i] <= reg.Dates()[len(reg.Dates())-1]) {
series[i] = 0
} else {
instant[key] = fmt.Sprintf("@%s %v", dates[i], series[i])
}
}
if slices.Min(series) != 0 || slices.Max(series) != 0 {
chart.AddY(key, series)
}
}
}
}
return chart
}
primary := toChart(r.URL.Path == "/api/bal", r.URL.Query().Get("chart"), register)
if len(predicted) > 0 {
primary.Overlap(toChart(r.URL.Path == "/api/bal", "line", predicted))
}
if err := primary.Render(w); err != nil {
panic(err)
}
for k, v := range instant {
fmt.Fprintf(w, "<br>\n%s = %s", k, v)
}
}
log.Println("listening on", *httpOutput)
if err := http.ListenAndServe(*httpOutput, http.HandlerFunc(foo)); err != nil {
panic(err)
}
} else {
deltas, err := f.Deltas()
if err != nil {
panic(err)
}
deltas = deltas.Group(ledger.GroupName(*groupName), ledger.GroupDate(*groupDate))
like := ledger.Likes{ledger.LikeName(*likeName)}
if *likeLedger {
like = append(like, ledger.LikeBefore(*likeBefore))
like = append(like, ledger.LikeAfter(*likeAfter))
deltas = deltas.Like(like...)
} else {
deltas = deltas.Like(like...)
like = append(like, ledger.LikeBefore(*likeBefore))
like = append(like, ledger.LikeAfter(*likeAfter))
}
jsonResult := []any{}
switch *foo {
case "reg":
sort.Slice(deltas, func(i, j int) bool {
return deltas[i].Debug() < deltas[j].Debug()
})
register := deltas.Register()
for i := range deltas {
if like.All(deltas[i]) {
if !*jsonOutput {
fmt.Printf("%s (%+v)\n", deltas[i].Debug(), register[deltas[i].Date][deltas[i].Name].Debug())
} else {
jsonResult = append(jsonResult, map[string]any{
"name": deltas[i].Name,
"delta": deltas[i],
"balance": register[deltas[i].Date][deltas[i].Name],
})
}
}
}
case "bal":
deltas = deltas.Like(like...)
for k, v := range deltas.Balances() {
results := []string{}
for subk, subv := range v {
results = append(results, fmt.Sprintf("%s %.2f", subk, subv))
}
if len(results) > 0 {
if !*jsonOutput {
fmt.Printf("%s\t%s\n", k, strings.Join(results, " + "))
} else {
jsonResult = append(jsonResult, map[string]any{
"name": k,
"balance": v,
})
}
}
}
default:
panic(fmt.Errorf("not impl %q", *foo))
}
if *jsonOutput {
json.NewEncoder(os.Stdout).Encode(jsonResult)
}
}
}
type Chart interface {
AddX(interface{})
AddY(string, []int)
Render(io.Writer) error
Overlap(Chart)
}
func NewChart(name string) Chart {
switch name {
case "line":
return NewLine()
case "bar":
return NewBar()
case "stack":
return NewStack()
default:
panic("bad chart name " + name)
}
}
type Line struct {
*charts.Line
}
func NewLine() Line {
return Line{Line: charts.NewLine()}
}
func (line Line) AddX(v interface{}) {
line.SetXAxis(v)
}
func (line Line) AddY(name string, v []int) {
y := make([]opts.LineData, len(v))
for i := range y {
y[i].Value = v[i]
}
line.AddSeries(name, y).
SetSeriesOptions(charts.WithBarChartOpts(opts.BarChart{
Stack: "stackB",
}))
}
func (line Line) Overlap(other Chart) {
overlapper, ok := other.(charts.Overlaper)
if !ok {
panic(fmt.Sprintf("cannot overlap %T", other))
}
line.Line.Overlap(overlapper)
}
type Bar struct {
*charts.Bar
}
func NewBar() Bar {
return Bar{Bar: charts.NewBar()}
}
func (bar Bar) AddX(v interface{}) {
bar.SetXAxis(v)
}
func (bar Bar) AddY(name string, v []int) {
y := make([]opts.BarData, len(v))
for i := range v {
y[i].Value = v[i]
}
bar.AddSeries(name, y)
}
func (bar Bar) Overlap(other Chart) {
overlapper, ok := other.(charts.Overlaper)
if !ok {
panic(fmt.Sprintf("cannot overlap %T", other))
}
bar.Bar.Overlap(overlapper)
}
type Stack struct {
Bar
}
func NewStack() Stack {
bar := NewBar()
bar.SetSeriesOptions(charts.WithBarChartOpts(opts.BarChart{Stack: "x"}))
return Stack{Bar: bar}
}
func (stack Stack) AddY(name string, v []int) {
y := make([]opts.BarData, len(v))
for i := range v {
y[i].Value = v[i]
}
stack.AddSeries(name, y).
SetSeriesOptions(charts.WithBarChartOpts(opts.BarChart{
Stack: "stackA",
}))
}

View File

@@ -1 +0,0 @@
../../../../../../Sync/Core/tmp/moolah.dat

View File

@@ -1,16 +0,0 @@
<html style="height: calc(100% - 4em);">
<header>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css">
</header>
<body style="height: 100%;">
<h1>Moolah2 Hub</h1>
<ul style="line-height: 3em;">
<li><a href="/transactions.html">Transactions on Shared Chase</a></li>
<li><a href="/explore.html">Explore Bel's Money</a></li>
<li><a href="/api/bal?x=y&mode=bal&likeName=AssetAccount&chart=stack&predictionMonths=120&bpi=true&zoomStart=2023-06&prediction=interest=AssetAccount:Cash%20\$%200.02&prediction=contributions=AssetAccount:Bonds%20$%201875&prediction=interest=AssetAccount:Monthly%20\$%200.03&prediction=contributions=AssetAccount:Monthly%20$%202500&predictFixedGrowth=VBTLX=0.02&predictFixedGrowth=GLD=0.02&predictFixedGrowth=FXAIX=0.03&predictFixedGrowth=FSPSX=0.03&whatIf=AssetAccount:Cash%20$%20-.10000&=">Project Bel's Net Worth</a></li>
<li><a href="/api/reg?x=y&mode=reg&likeName=Withdrawal:&chart=stack&predictionMonths=3&bpi=false&zoomStart=2023-01&prediction=autoContributions=&predictFixedGrowth=VBTLX=0&whatIf=AssetAccount:Cash%20$%20-.10000&=">Expect Bel's Expenses</a></li>
</ul>
</body>
<footer>
</footer>
</html>

View File

@@ -1,85 +0,0 @@
<html>
<header>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css">
<script>
function http(method, remote, callback, body) {
var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == XMLHttpRequest.DONE) {
callback(xmlhttp.responseText, xmlhttp.status)
}
};
xmlhttp.open(method, remote, true);
if (typeof body == "undefined") {
body = null
}
xmlhttp.send(body);
}
function callback(responseBody, responseStatus) {
}
var f = String(window.location).split("/transactions.html")[1]
if (!f) {
f = "/moolah.dat"
}
f = "." + f
function init() {
load(f)
}
function load(f) {
http("GET", "/api/transactions?f="+f, (body, status) => {
var d = JSON.parse(body)
loadBalances(d.balances)
loadDeltas(d.deltas)
})
}
function loadBalances(balances) {
var result = `<table>`
for (var k in balances) {
result += `<tr style="display: flex; flex-direction: row; width: 100%; justify-content: space-between;"><td>${k}</td><td>${Math.floor(balances[k]["$"])}</td></tr>`
}
result += `</table>`
document.getElementById("bal").innerHTML = result
}
function loadDeltas(deltas) {
console.log(deltas[0])
for (var i = 0; i < deltas.length/2; i++) {
tmp = deltas[i]
deltas[i] = deltas[deltas.length-1-i]
deltas[deltas.length-1-i] = tmp
}
console.log(deltas[0])
var result = `<table>`
for (var k of deltas) {
result += `<tr>`
result += ` <td>${k.Date}</td>`
result += ` <td>${k.Description}</td>`
result += ` <td>${k.Name}</td>`
result += ` <td style="text-align: right">${k.Currency}</td>`
result += ` <td style="text-align: right">${k.Value}</td>`
result += `</tr>`
}
result += `</table>`
document.getElementById("reg").innerHTML = result
}
</script>
</header>
<body onload="init();">
<h2>Moolah2</h2>
<details>
<summary>Balance</summary>
<div id="bal">
</div>
<details>
<summary><i>Look at this graph</i></summary>
<iframe style="background: white; width: 100%;" src="/api/reg?x=y&mode=reg&likeName=Withdrawal:[0123]&chart=stack&predictionMonths=6&prediction=autoContributions=&bpi=true&zoomStart=2023-01"></iframe>
</details>
</details>
<details open>
<summary>Register</summary>
<div id="reg">
</div>
</details>
</body>
<footer>
</footer>
</html>

View File

@@ -1,12 +1,13 @@
#! /bin/bash
cd "$(dirname "$(realpath "$BASH_SOURCE")")"
go run . -http=:8081 \
cd ..
go run . http -http=:8081 \
-foo reg \
-like-after 1023-08 \
-group-date ^....-.. \
-group-name '^[^:]*:[^:]*' \
-like-name '(AssetAccount|Retirement)' \
-bpi ./bpi.dat \
-bpi ./http/bpi.dat \
"$@" \
macro.d/*
./http/macro.d/

1
cmd/http/ledger.dat Symbolic link
View File

@@ -0,0 +1 @@
moolah.dat.real

46
cmd/http/main.go Normal file
View File

@@ -0,0 +1,46 @@
package http
import (
"flag"
"fmt"
"log"
"net/http"
_ "embed"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
func Main() {
likeName := flag.String("like-name", ".", "regexp to match")
likeBefore := flag.String("like-before", "9", "date str to compare")
likeAfter := flag.String("like-after", "0", "date str to compare")
groupName := flag.String("group-name", ".*", "grouping to apply to names")
groupDate := flag.String("group-date", ".*", "grouping to apply to dates")
bpiPath := flag.String("bpi", "/dev/null", "bpi file")
httpOutput := flag.String("http", ":8080", "http output listen address, like :8080")
flag.Parse()
if flag.NArg() < 1 {
panic(fmt.Errorf("positional arguments for files required"))
}
f, err := ledger.NewFiles(flag.Args()[0], flag.Args()[1:]...)
if err != nil {
panic(err)
}
r := NewRouter(
f,
*likeName,
*likeBefore,
*likeAfter,
*groupName,
*groupDate,
*bpiPath,
)
log.Println("listening on", *httpOutput)
if err := http.ListenAndServe(*httpOutput, r); err != nil {
panic(err)
}
}

3090
cmd/http/moolah.dat.real Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -29,9 +29,15 @@
console.log(path, query);
document.getElementById("graph").src = window.origin + path + query
}
function init() {
const zeroPad = (num, places) => String(num).padStart(places, '0')
var d = new Date()
d.setMonth(d.getMonth()-6)
document.getElementsByName("zoomStart")[0].value = `${d.getFullYear()}-${zeroPad(d.getMonth(), 2)}`
}
</script>
</header>
<body style="height: 100%;">
<body style="height: 100%;" onload="init();">
<div id="grapher" style="width: 100%; height: 100%; display: flex; flex-direction: column;">
<form onsubmit="draw(this); return false;" style="display: flex; flex-direction: row; flex-wrap: wrap; gap: 1em;">
<span>
@@ -41,7 +47,7 @@
<span>
<label for="likeName">likeName</label>
<input name="likeName" type="text" value="AssetAccount"/>
<input name="likeName" type="text" value="Bel:AssetAccount"/>
</span>
<span>
@@ -61,12 +67,12 @@
<span>
<label for="zoomStart">zoomStart</label>
<input name="zoomStart" type="text" value="2023-06"/>
<input name="zoomStart" type="text" value="YYYY-MM"/>
</span>
<span>
<label for="prediction">prediction</label>
<input name="prediction" type="text" value="interest=AssetAccount:Cash \$ 0.02&prediction=contributions=AssetAccount:Bonds $ 1875&prediction=interest=AssetAccount:Monthly \$ 0.03&prediction=contributions=AssetAccount:Monthly $ 2500"/>
<input name="prediction" type="text" value="interest=Bel:AssetAccount:Cash \$ 0.02&prediction=contributions=Bel:AssetAccount:Bonds $ 1916&prediction=interest=Bel:AssetAccount:Monthly \$ 0.03&prediction=contributions=Bel:AssetAccount:Monthly $ 3500"/>
</span>
<span>
@@ -76,7 +82,7 @@
<span>
<label for="whatIf">whatIf</label>
<input name="whatIf" type="text" value="AssetAccount:Cash $ -.10000"/>
<input name="whatIf" type="text" value="Bel:AssetAccount:Cash $ -.10000"/>
</span>
<span>

View File

@@ -0,0 +1,28 @@
<html style="height: calc(100% - 4em);">
<header>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css">
<script>
function init() {
const zeroPad = (num, places) => String(num).padStart(places, '0')
var d = new Date()
d.setMonth(d.getMonth()-6)
const replacement = `${d.getFullYear()}-${zeroPad(d.getMonth(), 2)}`
var anchors = document.getElementsByTagName("a")
for (var i = 0; i < anchors.length; i++)
anchors[i].href = anchors[i].href.replace("YYYY-MM", replacement)
}
</script>
</header>
<body style="height: 100%;" onload="init();">
<h1>Moolah2 Hub</h1>
<ul style="line-height: 3em;">
<li><a href="/transactions.html">Transactions on Shared Chase</a></li>
<li><a href="/api/trends">Where does the house money go?</a></li>
<li><a href="/explore.html">Explore Bel's Money</a></li>
<li><a href="/api/bal?x=y&mode=bal&likeName=AssetAccount&chart=stack&predictionMonths=120&bpi=true&zoomStart=YYYY-MM&prediction=interest=AssetAccount:Cash%20\$%200.02&prediction=contributions=AssetAccount:Bonds%20$%201875&prediction=interest=AssetAccount:Monthly%20\$%200.03&prediction=contributions=AssetAccount:Monthly%20$%202500&predictFixedGrowth=VBTLX=0.02&predictFixedGrowth=GLD=0.02&predictFixedGrowth=FXAIX=0.03&predictFixedGrowth=FSPSX=0.03&whatIf=AssetAccount:Cash%20$%20-.10000&=">Project Bel's Net Worth</a></li>
<!--<li><a href="/api/reg?x=y&mode=reg&likeName=Bel:Withdrawal:&chart=stack&predictionMonths=3&bpi=false&zoomStart=YYYY-MM&prediction=autoContributions=&predictFixedGrowth=VBTLX=0&whatIf=AssetAccount:Cash%20$%20-.10000&=">Expect Bel's Expenses</a></li>-->
</ul>
</body>
<footer>
</footer>
</html>

View File

@@ -0,0 +1,264 @@
<html>
<header>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css">
<style>
input[type="text"] {
border: 1px solid black;
}
.amended {
border: 2px solid red;
}
.revised {
border: 2px solid green;
}
</style>
<script>
function http(method, remote, callback, body) {
var xmlhttp = new XMLHttpRequest()
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == XMLHttpRequest.DONE) {
callback(xmlhttp.responseText, xmlhttp.status)
}
}
xmlhttp.open(method, remote, true)
if (typeof body == "undefined") {
body = null
}
xmlhttp.send(body)
}
function callback(responseBody, responseStatus) {
}
//var f = String(window.location).split("/transactions.html")[1]
//if (!f) {
// f = "/ledger.dat"
//}
//f = "." + f
function init() {
const zeroPad = (num, places) => String(num).padStart(places, '0')
var d = new Date()
d.setMonth(d.getMonth()-6)
const replacement = `${d.getFullYear()}-${zeroPad(d.getMonth(), 2)}`
var iframe = document.getElementsByTagName("iframe")[0]
iframe.src = iframe.src.replace("YYYY-MM", replacement)
load()
}
function load(callback) {
http("GET", "/api/transactions", (body, status) => {
var d = JSON.parse(body)
console.log("loading", d)
loadNormalized(d.normalized)
loadBalances(d.balances)
loadTransactions(d.transactions)
if (callback != null) {
callback()
}
})
}
function loadNormalized(normalized) {
console.log("loading normalized", normalized)
var result = `<table>`
for (var k in normalized) {
result += `<tr style="display: flex; flex-direction: row; width: 100%; justify-content: space-between;"><td>${k}</td><td>${Math.floor(normalized[k]["$"])}</td></tr>`
}
result += `</table>`
document.getElementById("norm").innerHTML = result
}
function loadBalances(balances) {
console.log("loading balances", balances)
var result = `<table>`
for (var k in balances) {
result += `<tr style="display: flex; flex-direction: row; width: 100%; justify-content: space-between;"><td>${k}</td><td>${Math.floor(balances[k]["$"])}</td></tr>`
}
result += `</table>`
document.getElementById("bal").innerHTML = result
}
function loadTransactions(transactions) {
transactions.reverse()
console.log(transactions[0])
var result = `<table>`
for (var t of transactions) {
result += `<tr name="transaction-${t[0].Date}-${t[0].Description}" transaction='${btoa(JSON.stringify(t))}' style="cursor: crosshair;" onclick="stageEdit(this); return false;" class="${t[0].Transaction.includes("inbox") ? "revised" : ""}">`
result += ` <td style="width: 6em;">${t[0].Date}</td>`
result += ` <td style="width: 10em;">${t[0].Description}</td>`
result += ` <td><table style="margin: 0;">`
for (var delta of t) {
result += ` <tr>`
result += ` <td><span style="font-variant: petite-caps;">${delta.Name.split(":")[0]}</span><span style="opacity: 0.6;"> :${delta.Name.split(":").slice(1, 100).join(":")}</span></td>`
result += ` <td style="text-align: right; width: 2em;">${delta.Payee ? delta.Currency : ""}</td>`
result += ` <td style="text-align: right; width: 5em;">${delta.Payee ? delta.Value : ""}</td>`
result += ` </tr>`
}
result += ` </table></td>`
result += `</tr>`
}
result += `</table>`
document.getElementById("reg").innerHTML = result
}
function stageEdit(row) {
const xactionJSON = atob(row.attributes.transaction.value)
const xaction = JSON.parse(xactionJSON)
console.log(xaction)
row.attributes.onclick = ""
row.onclick = ""
row.style = ""
console.log(row)
var result = `<td colspan="3">`
result += `<form method="modal">`
result += ` <div>`
result += ` <label for="date">Date</label>`
result += ` <input type="text" name="date" value='${xaction[0].Date}' style="width: 100%;"/>`
result += ` </div>`
result += ` <div>`
result += ` <label for="description">Description</label>`
result += ` <input type="text" name="description" value='${xaction[0].Description}' style="width: 100%;"/>`
result += ` </div>`
for (var i in xaction) {
var delta = xaction[i]
result += `<hr>`
result += `<div style="display: flex; flex-direction: row; width: 100%; margin-top: 1em;">`
result += ` <div style="flex-grow: 1; margin-left: 1em; margin-right: 1em;">`
result += ` <label for="name${i}">Name${i}</label>`
result += ` <input type="text" name="name${i}" value='${delta.Name}' style="width: 100%;"/>`
result += ` </div>`
result += ` <div style="margin-left: 1em; margin-right: 1em;">`
result += ` <label for="value${i}">Value${i}</label>`
result += ` <input type="text" name="value${i}" value='${delta.Value}' style="width: 100%;"/>`
result += ` </div>`
result += ` <input type="button" name="submit${i}" value="Submit${i}" onclick="submitEdit(this); return false;" delta="${btoa(JSON.stringify(delta))}"/>`
result += `</div>`
}
result += `</form`
result += `</td>`
row.innerHTML = result
}
function submitEdit(btn) {
const old = JSON.parse(atob(btn.attributes.delta.value))
const now = JSON.parse(atob(btn.attributes.delta.value))
const idx = btn.name.split("submit")[1]
const form = btn.parentElement.parentElement
now.Value = parseFloat(form[`value${idx}`].value)
now.Name = form[`name${idx}`].value
now.Description = form[`description`].value
now.Date = form[`date`].value
var different = false
for(var k in old) {
different = different || old[k] != now[k]
}
if (!different) {
return
}
http("PUT", "/api/amend", (body, status) => {
if (status != 200)
throw(`Unexpected status ${status}: ${body}`)
load(() => {
/*
var elements = document.getElementsByName(`transaction-${old.Date}-${old.Description}`)
for (var ele of elements)
ele.className = "amended"
var elements = document.getElementsByName(`transaction-${now.Date}-${now.Description}`)
for (var ele of elements)
ele.className = "revised"
*/
})
}, JSON.stringify({
old: old,
now: now,
}))
}
function create() {
http("POST", "/api/create", (body, status) => {
if (status != 200)
throw(`Unexpected status ${status}: ${body}`)
loadTransactions([])
load()
}, JSON.stringify({}))
}
function stage(who, contributesToHouse) {
var d = new Date()
const zeroPad = (num, places) => String(num).padStart(places, '0')
const today = `${d.getFullYear()}-${zeroPad(1+d.getMonth(), 2)}-${zeroPad(d.getDate(), 2)}`
var who_currency = who.split(":").at(-1).toUpperCase()
var benefactor = who
var benefactor_value = `-0.00 ${who_currency}`
var beneficiary = "AssetAccount:Chase:5876"
var beneficiary_value = `$0.00`
if (!contributesToHouse) {
beneficiary_value = `$-0.00`
benefactor_value = `0.00 ${who_currency}`
}
console.log(`@${today} ${benefactor} gave to ${beneficiary}`)
}
</script>
</header>
<body onload="init();" style="">
<h2>Moolah2</h2>
<details open>
<summary>Normalized</summary>
<div id="norm">
</details>
<details>
<summary>Balance</summary>
<div id="bal">
</div>
<details>
<summary><i>Look at this graph</i></summary>
<iframe style="background: white; width: 100%; resize: both;" src="/api/reg?x=y&mode=reg&likeName=Withdrawal:[0123]&chart=stack&predictionMonths=6&prediction=autoContributions=&bpi=true&zoomStart=YYYY-MM"></iframe>
</details>
<details>
<summary><i>Where did the money go</i></summary>
<iframe style="background: white; width: 100%; resize: both;" src="/api/trends"></iframe>
</details>
</details>
<details open style="display: none;">
<summary>Edit</summary>
<div style="display:flex; flex-direction:row; width:100%; justify-content:space-between;">
<div>
<input type="button" onclick="stage('AssetAccount:Zach', true)" value="Stage Zach's Payment"/>
<input type="button" onclick="stage('AssetAccount:Zach', false)" value="Stage Zach's Charge"/>
</div>
<div>
<input type="button" onclick="stage('AssetAccount:Bel', true)" value="Stage Bel's Payment"/>
<input type="button" onclick="stage('AssetAccount:Bel', false)" value="Stage Bel's Charge"/>
</div>
</div>
</details>
<details open>
<summary>Register</summary>
<form action="#" onsubmit="create(); return false;">
<button style="width: 100%">
CREATE
</button>
</form>
<div id="reg">
</div>
</details>
</body>
<footer>
</footer>
</html>

485
cmd/http/router.go Normal file
View File

@@ -0,0 +1,485 @@
package http
import (
"embed"
"encoding/json"
"fmt"
"io"
"io/fs"
"math"
"net/http"
"os"
"slices"
"strconv"
"strings"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/ana"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
"gogs.inhome.blapointe.com/ana-ledger/src/view"
)
//go:embed public/*
var _staticFileDir embed.FS
type Router struct {
files ledger.Files
like struct {
name string
before string
after string
}
group struct {
name string
date string
}
bpiPath string
}
func NewRouter(files ledger.Files, likeName, likeBefore, likeAfter string, groupName, groupDate string, bpiPath string) Router {
r := Router{}
r.files = files
r.like.name = likeName
r.like.before = likeBefore
r.like.after = likeAfter
r.group.name = groupName
r.group.date = groupDate
r.bpiPath = bpiPath
return r
}
func (router Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch strings.Split(r.URL.Path, "/")[1] {
case "api":
router.API(w, r)
default:
router.FS(w, r)
}
}
func (router Router) FS(w http.ResponseWriter, r *http.Request) {
if os.Getenv("DEBUG") != "" {
http.FileServer(http.Dir("./http/public")).ServeHTTP(w, r)
} else {
sub, err := fs.Sub(_staticFileDir, "public")
if err != nil {
panic(err)
}
http.FileServer(http.FS(sub)).ServeHTTP(w, r)
}
}
func (router Router) API(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/transactions":
router.APITransactions(w, r)
case "/api/amend":
router.APIAmend(w, r)
case "/api/create":
router.APICreate(w, r)
case "/api/trends":
router.APITrends(w, r)
case "/api/reg", "/api/bal":
router.APIReg(w, r)
default:
http.NotFound(w, r)
}
}
func (router Router) APITransactions(w http.ResponseWriter, r *http.Request) {
bpis, err := router.bpis()
if err != nil {
panic(err)
}
deltas, err := router.files.Deltas()
if err != nil {
panic(err)
}
houseRelatedDeltas := deltas.Like(ledger.LikeTransactions(
deltas.Like(ledger.LikeName(`^House`))...,
))
recent := time.Hour * 24 * 365 / 6
normalizer := ana.NewDefaultNormalizer()
{
deltas := houseRelatedDeltas.
Like(ledger.LikeAfter(time.Now().Add(-1 * recent).Format("2006-01")))
balances := houseRelatedDeltas.Balances().
Like(`^(Zach|Bel|House[^:]*:Debts:)`).
Group(`^[^:]*`).
WithBPIs(bpis)
transactions := houseRelatedDeltas.
Like(ledger.LikeAfter(time.Now().Add(-1 * recent).Format("2006-01"))).
Transactions()
normalized := normalizer.Normalize(houseRelatedDeltas).Balances().
Like(`^(Zach|Bel):`).
Group(`^[^:]*:`).
WithBPIs(bpis)
var biggest float64
for _, v := range normalized {
if v := math.Abs(v["$"]); v > biggest {
biggest = v
}
}
ks := []string{}
for k := range normalized {
ks = append(ks, k)
}
for _, k := range ks {
v := normalized[k]
if v := math.Abs(v["$"]); v < biggest {
normalizedDelta := biggest - v
normalizedFactor := normalizer.NormalizeFactor(k, time.Now().Format("2006-01-02"))
normalized[fmt.Sprintf(`(%s trailing $)`, k)] = ledger.Balance{"$": normalizedDelta * normalizedFactor}
}
}
json.NewEncoder(w).Encode(map[string]any{
"deltas": deltas,
"balances": balances,
"normalized": normalized,
"transactions": transactions,
})
}
}
func (router Router) APITrends(w http.ResponseWriter, r *http.Request) {
bpis, err := router.bpis()
if err != nil {
panic(err)
}
deltas, err := router.files.Deltas()
if err != nil {
panic(err)
}
recent := time.Hour * 24 * 365 / 2
pie := func(title, groupName string, min int) {
recentHouseRelatedDeltas := deltas.
Like(ledger.LikeTransactions(
deltas.Like(ledger.LikeName(`^House`))...,
)).
Like(ledger.LikeAfter(time.Now().Add(-1 * recent).Format("2006-01"))).
Group(ledger.GroupName(groupName)).
Group(ledger.GroupDate(`^[0-9]*-[0-9]*`)).
Like(ledger.LikeNotName(`^$`))
monthsToDeltas := map[string]ledger.Deltas{}
for _, delta := range recentHouseRelatedDeltas {
monthsToDeltas[delta.Date] = append(monthsToDeltas[delta.Date], delta)
}
months := []string{}
for k := range monthsToDeltas {
months = append(months, k)
}
slices.Sort(months)
catToMonth := map[string][]int{}
for _, month := range months {
balances := monthsToDeltas[month].Balances().WithBPIs(bpis)
for category, balance := range balances {
catToMonth[category] = append(catToMonth[category], int(balance[ledger.USD]))
}
}
chart := view.NewChart("pie")
ttl := 0
for cat, month := range catToMonth {
for i := 0; i < len(months)-len(month); i++ {
month = append(month, 0)
}
slices.Sort(month)
median := month[len(month)/2]
ttl += median
if median > min {
chart.AddY(cat, []int{median})
}
}
fmt.Fprintln(w, "<!DOCTYPE html><h2>", title, "($", ttl, ")</h2>")
if err := chart.Render(w); err != nil {
panic(err)
}
}
pie(fmt.Sprintf("Median Monthly Spending Since %dmo ago", int(recent/time.Hour/24/30)), `Withdrawal:[0-9]*`, 50)
pie("Median Monthly Spending (detailed)", `Withdrawal:[0-9]*:[^:]*`, 25)
pie("Median Monthly Spending (MORE detailed)", `Withdrawal:[0-9]*:[^:]*:[^:]*`, 10)
}
func (router Router) APICreate(w http.ResponseWriter, r *http.Request) {
new := ledger.Delta{
Name: "TODO",
Date: time.Now().Format(`2006-01-02`),
Description: "TODO",
Currency: "$",
Value: 0.01,
}
if err := router.files.Add("HouseyMcHouseface:Withdrawal:0:TODO", new); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
func (router Router) APIAmend(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
var req struct {
Old ledger.Delta
Now ledger.Delta
}
if err := json.Unmarshal(b, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.Now.Name = strings.ReplaceAll(req.Now.Name, " ", "_")
if err := router.files.Amend(req.Old, req.Now); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
func (router Router) APIReg(w http.ResponseWriter, r *http.Request) {
deltas, err := router.files.Deltas()
if err != nil {
panic(err)
}
deltas = deltas.Group(ledger.GroupName(router.group.name), ledger.GroupDate(router.group.date))
like := ledger.Likes{
ledger.LikeName(router.like.name),
ledger.LikeBefore(router.like.before),
ledger.LikeAfter(router.like.after),
}
foolike := make(ledger.Likes, 0)
for _, v := range r.URL.Query()["likeName"] {
foolike = append(foolike, ledger.LikeName(v))
}
for _, v := range r.URL.Query()["likeAfter"] {
foolike = append(foolike, ledger.LikeAfter(v))
}
for _, v := range r.URL.Query()["likeBefore"] {
foolike = append(foolike, ledger.LikeBefore(v))
}
if len(foolike) == 0 {
foolike = like
}
deltas = deltas.Like(foolike...)
// MODIFIERS
for i, whatIf := range r.URL.Query()["whatIf"] {
fields := strings.Fields(whatIf)
date := "2001-01"
name := fields[0]
currency := ledger.Currency(fields[1])
value, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
panic(err)
}
deltas = append(deltas, ledger.Delta{
Date: date,
Name: name,
Value: value,
Currency: currency,
Description: fmt.Sprintf("?whatIf[%d]", i),
})
}
register := deltas.Register()
predicted := make(ledger.Register)
bpis, err := router.bpis()
if err != nil {
panic(err)
}
if predictionMonths, err := strconv.ParseInt(r.URL.Query().Get("predictionMonths"), 10, 16); err == nil && predictionMonths > 0 {
window := time.Hour * 24.0 * 365.0 / 12.0 * time.Duration(predictionMonths)
// TODO whatif
prediction := make(ana.Prediction, 0)
for _, spec := range r.URL.Query()["prediction"] {
idx := strings.Index(spec, "=")
k := spec[:idx]
fields := strings.Fields(spec[idx+1:])
switch k {
case "interest":
apy, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
panic(err)
}
prediction = append(prediction, ana.NewInterestPredictor(fields[0], fields[1], apy))
case "autoContributions":
prediction = append(prediction, ana.NewAutoContributionPredictor(register))
case "contributions":
name := fields[0]
currency := ledger.Currency(fields[1])
value, err := strconv.ParseFloat(fields[2], 64)
if err != nil {
panic(err)
}
prediction = append(prediction, ana.NewContributionPredictor(ledger.Balances{name: ledger.Balance{currency: value}}))
default:
panic(k)
}
}
predicted = prediction.Predict(register, window)
for _, currencyRate := range r.URL.Query()["predictFixedGrowth"] {
currency := strings.Split(currencyRate, "=")[0]
rate, err := strconv.ParseFloat(strings.Split(currencyRate, "=")[1], 64)
if err != nil {
panic(err)
}
bpis, err = ana.BPIsWithFixedGrowthPrediction(bpis, window, currency, rate)
if err != nil {
panic(err)
}
}
}
if r.URL.Query().Get("bpi") == "true" {
register = register.WithBPIs(bpis)
predicted = predicted.WithBPIs(bpis)
}
if zoomStart, err := time.ParseInLocation("2006-01", r.URL.Query().Get("zoomStart"), time.Local); err == nil {
register = register.Between(zoomStart, time.Now().Add(time.Hour*24*365*100))
predicted = predicted.Between(zoomStart, time.Now().Add(time.Hour*24*365*100))
}
// /MODIFIERS
dates := register.Dates()
names := register.Names()
for _, date := range predicted.Dates() {
found := false
for i := range dates {
found = found || dates[i] == date
}
if !found {
dates = append(dates, date)
}
}
for _, name := range predicted.Names() {
found := false
for i := range names {
found = found || names[i] == name
}
if !found {
names = append(names, name)
}
}
instant := map[string]string{}
toChart := func(cumulative bool, display string, reg ledger.Register) view.Chart {
nameCurrencyDateValue := map[string]map[ledger.Currency]map[string]float64{}
for date, balances := range reg {
for name, balance := range balances {
for currency, value := range balance {
if _, ok := nameCurrencyDateValue[name]; !ok {
nameCurrencyDateValue[name] = make(map[ledger.Currency]map[string]float64)
}
if _, ok := nameCurrencyDateValue[name][currency]; !ok {
nameCurrencyDateValue[name][currency] = make(map[string]float64)
}
nameCurrencyDateValue[name][currency][date] += value
}
}
}
chart := view.NewChart("line")
if v := display; v != "" {
chart = view.NewChart(v)
}
chart.AddX(dates)
if cumulative {
for _, name := range names {
currencyDateValue := nameCurrencyDateValue[name]
for currency, dateValue := range currencyDateValue {
series := make([]int, len(dates))
for i := range dates {
var lastValue float64
for j := range dates[:i+1] {
if newLastValue, ok := dateValue[dates[j]]; ok {
lastValue = newLastValue
}
}
series[i] = int(lastValue)
}
key := fmt.Sprintf("%s (%s)", name, currency)
for i := range dates {
if !(reg.Dates()[0] <= dates[i] && dates[i] <= reg.Dates()[len(reg.Dates())-1]) {
series[i] = 0
} else {
instant[key] = fmt.Sprintf("@%s %v", dates[i], series[i])
}
}
if slices.Min(series) != 0 || slices.Max(series) != 0 {
chart.AddY(key, series)
}
}
}
} else {
for _, name := range names {
currencyDateValue := nameCurrencyDateValue[name]
for currency, dateValue := range currencyDateValue {
series := make([]int, len(dates))
for i := range dates {
var prevValue float64
var lastValue float64
for j := range dates[:i+1] {
if newLastValue, ok := dateValue[dates[j]]; ok {
prevValue = lastValue
lastValue = newLastValue
}
}
series[i] = int(lastValue - prevValue)
}
for i := range series { // TODO no prior so no delta
if series[i] != 0 {
series[i] = 0
break
}
}
key := fmt.Sprintf("%s (%s)", name, currency)
for i := range dates {
if !(reg.Dates()[0] <= dates[i] && dates[i] <= reg.Dates()[len(reg.Dates())-1]) {
series[i] = 0
} else {
instant[key] = fmt.Sprintf("@%s %v", dates[i], series[i])
}
}
if slices.Min(series) != 0 || slices.Max(series) != 0 {
chart.AddY(key, series)
}
}
}
}
return chart
}
primary := toChart(r.URL.Path == "/api/bal", r.URL.Query().Get("chart"), register)
if len(predicted) > 0 {
primary.Overlap(toChart(r.URL.Path == "/api/bal", "line", predicted))
}
if err := primary.Render(w); err != nil {
panic(err)
}
for k, v := range instant {
fmt.Fprintf(w, "<br>\n%s = %s", k, v)
}
}
func (r Router) bpis() (ledger.BPIs, error) {
if r.bpiPath == "" {
return make(ledger.BPIs), nil
}
return ledger.NewBPIs(r.bpiPath)
}

3
cmd/install.sh Normal file
View File

@@ -0,0 +1,3 @@
#! /bin/bash
cd "$(dirname "$(realpath "$BASH_SOURCE")")"
go build -o $GOPATH/bin/ana-ledger

1
cmd/install_scratch.sh Normal file
View File

@@ -0,0 +1 @@
CGO_ENABLED=1 CC=x86_64-linux-musl-gcc go build -ldflags="-linkmode external -extldflags -static" -o $HOME/Go/bin/ana-ledger

57
cmd/main.go Normal file
View File

@@ -0,0 +1,57 @@
package main
import (
"context"
"log"
"os"
"os/signal"
"strings"
"syscall"
"gogs.inhome.blapointe.com/ana-ledger/cmd/cli"
"gogs.inhome.blapointe.com/ana-ledger/cmd/http"
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
)
func main() {
switch os.Args[1] {
case "tel":
ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
defer can()
if c, err := teller.New(); err != nil {
} else if _, err := c.Accounts(ctx); err != nil {
} else {
log.Println("teller already init")
}
if err := teller.Init(ctx); err != nil {
panic(err)
}
case "http":
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
http.Main()
case "cli":
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
cli.Main()
case "shared":
files := os.Args[2:]
os.Args = []string{os.Args[0], "cli"}
for _, f := range files {
if strings.HasPrefix(f, "-") {
os.Args = append(os.Args, f)
} else {
os.Args = append(os.Args, "-f", f)
}
}
os.Args = append(os.Args,
"-w=^Housey",
"--depth=1",
"--usd",
"-n",
"--no-percent",
"bal", "^Bel", "^Zach",
)
main()
}
}

16
go.mod
View File

@@ -1,5 +1,17 @@
module gogs.inhome.blapointe.com/ana-ledger
go 1.21.1
go 1.23.0
require github.com/go-echarts/go-echarts/v2 v2.3.1
toolchain go1.24.2
require (
github.com/go-echarts/go-echarts/v2 v2.3.1
github.com/guptarohit/asciigraph v0.7.3
golang.org/x/crypto v0.38.0
golang.org/x/time v0.11.0
)
require (
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
)

10
go.sum
View File

@@ -2,9 +2,19 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-echarts/go-echarts/v2 v2.3.1 h1:Yw0HVjVTxpYm48l974dMjRzx8ni2ql0kKi/kawSgxFE=
github.com/go-echarts/go-echarts/v2 v2.3.1/go.mod h1:56YlvzhW/a+du15f3S2qUGNDfKnFOeJSThBIrVFHDtI=
github.com/guptarohit/asciigraph v0.7.3 h1:p05XDDn7cBTWiBqWb30mrwxd6oU0claAjqeytllnsPY=
github.com/guptarohit/asciigraph v0.7.3/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.6.0 h1:jlIyCplCJFULU/01vCkhKuTyc3OorI3bJFuw6obfgho=
github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,92 +0,0 @@
package ledger
import (
"fmt"
"maps"
"regexp"
"strings"
)
type Balances map[string]Balance
type Balance map[Currency]float64
func (balances Balances) Like(pattern string) Balances {
result := make(Balances)
p := regexp.MustCompile(pattern)
for k, v := range balances {
if p.MatchString(k) {
result[k] = maps.Clone(v)
}
}
return result
}
func (balances Balances) WithBPIs(bpis BPIs) Balances {
return balances.WithBPIsAt(bpis, "9")
}
func (balances Balances) WithBPIsAt(bpis BPIs, date string) Balances {
result := make(Balances)
for k, v := range balances {
if _, ok := result[k]; !ok {
result[k] = make(Balance)
}
for k2, v2 := range v {
scalar := 1.0
if k2 != USD {
scalar = bpis[k2].Lookup(date)
}
result[k][USD] += v2 * scalar
}
}
return result
}
func (balances Balances) PushAll(other Balances) {
for k, v := range other {
if _, ok := balances[k]; !ok {
balances[k] = make(Balance)
}
for k2, v2 := range v {
if _, ok := balances[k][k2]; !ok {
balances[k][k2] = 0
}
balances[k][k2] += v2
}
}
}
func (balances Balances) Push(d Delta) {
if _, ok := balances[d.Name]; !ok {
balances[d.Name] = make(Balance)
}
balances[d.Name].Push(d)
}
func (balance Balance) Push(d Delta) {
if _, ok := balance[d.Currency]; !ok {
balance[d.Currency] = 0
}
balance[d.Currency] += d.Value
}
func (balances Balances) Debug() string {
result := []string{}
for k, v := range balances {
result = append(result, fmt.Sprintf("%s:[%s]", k, v.Debug()))
}
return strings.Join(result, " ")
}
func (balance Balance) Debug() string {
result := []string{}
for k, v := range balance {
if k == USD {
result = append(result, fmt.Sprintf("%s%.2f", k, v))
} else {
result = append(result, fmt.Sprintf("%.3f %s", v, k))
}
}
return strings.Join(result, " + ")
}

View File

@@ -1,38 +0,0 @@
package ledger
import "fmt"
type Currency string
const (
USD = Currency("$")
)
type Delta struct {
Date string
Name string
Value float64
Currency Currency
Description string
isSet bool
}
func newDelta(d, desc, name string, v float64, c string, isSet bool) Delta {
return Delta{
Date: d,
Name: name,
Value: v,
Currency: Currency(c),
Description: desc,
isSet: isSet,
}
}
func (delta Delta) Debug() string {
return fmt.Sprintf("{@%s %s:\"%s\" %s%.2f %s}", delta.Date, delta.Name, delta.Description, func() string {
if !delta.isSet {
return ""
}
return "= "
}(), delta.Value, delta.Currency)
}

View File

@@ -1,151 +0,0 @@
package ledger
import (
"fmt"
"io"
"os"
"sort"
"unicode"
)
var filesAppendDelim = "\t"
type Files []string
func NewFiles(p string, q ...string) (Files, error) {
f := Files(append([]string{p}, q...))
_, err := f.Deltas()
return f, err
}
func (files Files) Add(payee string, delta Delta) error {
currencyValue := fmt.Sprintf("%s%.2f", delta.Currency, delta.Value)
if delta.Currency != USD {
currencyValue = fmt.Sprintf("%.2f %s", delta.Value, delta.Currency)
}
return files.append(fmt.Sprintf("%s %s\n%s%s%s%s\n%s%s",
delta.Date, delta.Description,
filesAppendDelim, delta.Name, filesAppendDelim+filesAppendDelim+filesAppendDelim, currencyValue,
filesAppendDelim, payee,
))
}
func (files Files) append(s string) error {
if err := files.trimTrainlingWhitespace(); err != nil {
return err
}
f, err := os.OpenFile(string(files[0]), os.O_APPEND|os.O_CREATE|os.O_WRONLY, os.ModePerm)
if err != nil {
return err
}
defer f.Close()
fmt.Fprintf(f, "\n%s", s)
return f.Close()
}
func (files Files) trimTrainlingWhitespace() error {
idx, err := files._lastNonWhitespacePos()
if err != nil {
return err
}
if idx < 1 {
return nil
}
f, err := os.OpenFile(string(files[0]), os.O_CREATE|os.O_WRONLY, os.ModePerm)
if err != nil {
return err
}
defer f.Close()
return f.Truncate(int64(idx + 1))
}
func (files Files) _lastNonWhitespacePos() (int, error) {
f, err := os.Open(string(files[0]))
if os.IsNotExist(err) {
return -1, nil
}
if err != nil {
return -1, err
}
defer f.Close()
b, err := io.ReadAll(f)
if err != nil {
return -1, err
}
for i := len(b) - 1; i >= 0; i-- {
if !unicode.IsSpace(rune(b[i])) {
return i, nil
}
}
return len(b) - 1, nil
}
func (files Files) Deltas(like ...Like) (Deltas, error) {
transactions, err := files.transactions()
if err != nil {
return nil, err
}
sort.Slice(transactions, func(i, j int) bool {
return fmt.Sprintf("%s %s", transactions[i].date, transactions[i].description) < fmt.Sprintf("%s %s", transactions[j].date, transactions[j].description)
})
result := make(Deltas, 0, len(transactions)*2)
for _, transaction := range transactions {
sums := map[string]float64{}
for _, recipient := range transaction.recipients {
sums[recipient.currency] += recipient.value
delta := newDelta(
transaction.date,
transaction.description,
recipient.name,
recipient.value,
recipient.currency,
recipient.isSet,
)
result = append(result, delta)
}
for currency, value := range sums {
if value == 0 {
continue
}
if transaction.payee == "" {
//return nil, fmt.Errorf("didnt find net zero and no dumping ground payee set: %+v", transaction)
} else {
delta := newDelta(
transaction.date,
transaction.description,
transaction.payee,
-1.0*value,
currency,
false,
)
result = append(result, delta)
}
}
}
balances := make(Balances)
for i := range result {
if result[i].isSet {
var was float64
if m, ok := balances[result[i].Name]; ok {
was = m[result[i].Currency]
}
result[i].Value = result[i].Value - was
result[i].isSet = false
}
balances.Push(result[i])
}
for i := range result {
if result[i].isSet {
return nil, fmt.Errorf("failed to resolve isSet: %+v", result[i])
}
}
return result.Like(like...), nil
}

View File

@@ -1,49 +0,0 @@
package ledger
import (
"regexp"
)
type Like func(Delta) bool
type Likes []Like
func LikeBefore(date string) Like {
return func(d Delta) bool {
return date >= d.Date
}
}
func LikeAfter(date string) Like {
return func(d Delta) bool {
return date <= d.Date
}
}
func LikeName(pattern string) Like {
return func(d Delta) bool {
return like(pattern, d.Name)
}
}
func like(pattern string, other string) bool {
return regexp.MustCompile(pattern).MatchString(other)
}
func (likes Likes) Any(delta Delta) bool {
for i := range likes {
if likes[i](delta) {
return true
}
}
return false
}
func (likes Likes) All(delta Delta) bool {
for i := range likes {
if !likes[i](delta) {
return false
}
}
return true
}

View File

@@ -1 +0,0 @@
../../../../../../Sync/Core/ledger/eras/2022-

View File

@@ -1 +0,0 @@
../../../../../../Sync/Core/ledger/eras/2022-/fidelity.76.dat.txt

View File

@@ -1,223 +0,0 @@
package ledger
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"regexp"
"strconv"
"unicode"
)
type transaction struct {
date string
description string
payee string
recipients []transactionRecipient
}
func (t transaction) empty() bool {
return fmt.Sprint(t) == fmt.Sprint(transaction{})
}
type transactionRecipient struct {
name string
value float64
currency string
isSet bool
}
func (t transactionRecipient) empty() bool {
return t == (transactionRecipient{})
}
func (files Files) transactions() ([]transaction, error) {
result := make([]transaction, 0)
for i := range files {
some, err := files._transactions(files[i])
if err != nil {
return nil, err
}
result = append(result, some...)
}
return result, nil
}
func (files Files) _transactions(file string) ([]transaction, error) {
f, err := os.Open(string(file))
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
defer f.Close()
r := bufio.NewReaderSize(f, 2048)
result := make([]transaction, 0)
for {
one, err := readTransaction(r)
if !one.empty() {
result = append(result, one)
}
if err == io.EOF {
return result, nil
}
if err != nil {
return result, err
}
}
}
func readTransaction(r *bufio.Reader) (transaction, error) {
result, err := _readTransaction(r)
if err != nil {
return result, err
}
if result.empty() {
return result, nil
}
if result.payee == "" && len(result.recipients) < 2 {
return result, fmt.Errorf("found a transaction with no payee and less than 2 recipeints: %+v", result)
}
if result.payee != "" && len(result.recipients) < 1 {
return result, fmt.Errorf("found a transaction with payee but no recipeints: %+v", result)
}
return result, nil
}
func _readTransaction(r *bufio.Reader) (transaction, error) {
readTransactionLeadingWhitespace(r)
firstLine, err := readTransactionLine(r)
if len(bytes.TrimSpace(firstLine)) == 0 {
return transaction{}, err
}
dateDescriptionPattern := regexp.MustCompile(`^([0-9]+-[0-9]+-[0-9]+)\s+(.*)$`)
dateDescriptionMatches := dateDescriptionPattern.FindAllSubmatch(firstLine, 4)
if len(dateDescriptionMatches) != 1 {
return transaction{}, fmt.Errorf("bad first line: %v matches: %q", len(dateDescriptionMatches), firstLine)
} else if len(dateDescriptionMatches[0]) != 3 {
return transaction{}, fmt.Errorf("bad first line: %v submatches: %q", len(dateDescriptionMatches[0]), firstLine)
}
result := transaction{
date: string(dateDescriptionMatches[0][1]),
description: string(dateDescriptionMatches[0][2]),
}
for {
name, value, currency, isSet, err := readTransactionName(r)
if name != "" {
if currency == "" {
result.payee = name
} else {
result.recipients = append(result.recipients, transactionRecipient{
name: name,
value: value,
currency: currency,
isSet: isSet,
})
}
}
if name == "" || err != nil {
return result, err
}
}
}
func readTransactionLeadingWhitespace(r *bufio.Reader) {
b, err := r.Peek(2048)
if err != nil && err != io.EOF {
return
}
i := 0
for i < len(b) {
if len(bytes.TrimSpace(b[:i])) != 0 {
break
}
i++
}
if i > 0 {
r.Read(make([]byte, i-1))
}
}
func readTransactionLine(r *bufio.Reader) ([]byte, error) {
for {
b, err := _readTransactionLine(r)
if err != nil || (len(bytes.TrimSpace(b)) > 0 && bytes.TrimSpace(b)[0] != '#') {
return b, err
}
}
}
func _readTransactionLine(r *bufio.Reader) ([]byte, error) {
b, err := r.Peek(2048)
if len(b) == 0 {
return nil, err
}
endOfLine := len(b)
if idx := bytes.Index(b, []byte{'\n'}); idx > -1 {
endOfLine = idx
}
b2 := make([]byte, endOfLine)
n, err := r.Read(b2)
if err == io.EOF {
err = nil
}
if check, _ := r.Peek(1); len(check) == 1 && check[0] == '\n' {
r.Read(make([]byte, 1))
}
return b2[:n], err
}
func readTransactionName(r *bufio.Reader) (string, float64, string, bool, error) {
line, err := readTransactionLine(r)
if err != nil {
return "", 0, "", false, err
}
if len(line) > 0 && !unicode.IsSpace(rune(line[0])) {
r2 := *r
*r = *bufio.NewReader(io.MultiReader(bytes.NewReader(append(line, '\n')), &r2))
return "", 0, "", false, nil
}
fields := bytes.Fields(line)
isSet := false
if len(fields) > 2 && string(fields[1]) == "=" {
isSet = true
fields = append(fields[:1], fields[2:]...)
}
switch len(fields) {
case 1: // payee
return string(fields[0]), 0, "", false, nil
case 2: // payee $00.00
b := bytes.TrimLeft(fields[1], "$")
value, err := strconv.ParseFloat(string(b), 64)
if err != nil {
return "", 0, "", isSet, fmt.Errorf("failed to parse value from $XX.YY from %q (%q): %w", line, fields[1], err)
}
return string(fields[0]), value, string(USD), isSet, nil
case 3: // payee 00.00 XYZ
value, err := strconv.ParseFloat(string(fields[1]), 64)
if err != nil {
return "", 0, "", false, fmt.Errorf("failed to parse value from XX.YY XYZ from %q (%q): %w", line, fields[1], err)
}
return string(fields[0]), value, string(fields[2]), isSet, nil
default:
return "", 0, "", isSet, fmt.Errorf("cannot interpret %q", line)
}
}

View File

@@ -1,68 +0,0 @@
package ledger
import (
"bufio"
"fmt"
"io"
"strings"
"testing"
)
func TestReadTransaction(t *testing.T) {
cases := map[string]struct {
input string
want transaction
err error
}{
"empty": {
input: "",
want: transaction{},
err: io.EOF,
},
"white space": {
input: " ",
want: transaction{},
err: io.EOF,
},
"verbose": {
input: `
2003-04-05 Reasoning here
A:B $1.00
C:D $-1.00
`,
want: transaction{
date: "2003-04-05",
description: "Reasoning here",
payee: "",
recipients: []transactionRecipient{
{
name: "A:B",
value: 1.0,
currency: "$",
},
{
name: "C:D",
value: -1.0,
currency: "$",
},
},
},
err: io.EOF,
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
r := bufio.NewReader(strings.NewReader(c.input))
got, err := readTransaction(r)
if err != c.err {
t.Error(err)
}
if fmt.Sprintf("%+v", got) != fmt.Sprintf("%+v", c.want) {
t.Errorf("want\n\t%+v, got\n\t%+v", c.want, got)
}
})
}
}

View File

@@ -5,7 +5,7 @@ import (
"regexp"
"time"
"gogs.inhome.blapointe.com/ana-ledger/ledger"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
func BPIsWithFixedGrowthPrediction(bpis ledger.BPIs, window time.Duration, pattern string, apy float64) (ledger.BPIs, error) {

View File

@@ -5,7 +5,7 @@ import (
"testing"
"time"
"gogs.inhome.blapointe.com/ana-ledger/ledger"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
func TestBPIPrediction(t *testing.T) {

89
src/ana/normalize.go Normal file
View File

@@ -0,0 +1,89 @@
package ana
import (
"regexp"
"slices"
"strings"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
type Normalizer struct {
m map[string][]normalize
}
type normalize struct {
startDate string
factor float64
}
func NewDefaultNormalizer() Normalizer {
return NewNormalizer().
With("^Zach", "2025-09-01", 151). // turtle up
With("^Zach", "2023-10-05", 139). // to turtle
With("^Zach", "2021-12-30", 135). // at pluralsight
With("^Zach", "2020-07-30", 120). // to pluralsight
With("^Zach", "2019-07-16", 77). // at fedex
With("^Zach", "2017-02-16", 49). // to fedex
With("^Bel", "2025-10-01", 225). // render up
With("^Bel", "2025-04-01", 214). // lc4 at render
With("^Bel", "2023-12-05", 190). // to render
With("^Bel", "2022-12-31", 154). // at q
With("^Bel", "2022-06-30", 148). // at q
With("^Bel", "2021-12-31", 122). // at q
With("^Bel", "2020-12-31", 118). // at q
With("^Bel", "2019-12-31", 111). // at q
With("^Bel", "2018-12-31", 92). // at q
With("^Bel", "2018-02-16", 86) // to q
}
func NewNormalizer() Normalizer {
return Normalizer{
m: make(map[string][]normalize),
}
}
func (n Normalizer) With(pattern, startDate string, factor float64) Normalizer {
n.m[pattern] = append(n.m[pattern], normalize{startDate: startDate, factor: factor})
slices.SortFunc(n.m[pattern], func(a, b normalize) int {
return -1 * strings.Compare(a.startDate, b.startDate)
})
return n
}
func (n Normalizer) Normalize(deltas ledger.Deltas) ledger.Deltas {
deltas = slices.Clone(deltas)
patterns := []string{}
for pattern := range n.m {
patterns = append(patterns, pattern)
}
like := ledger.LikeName(strings.Join(patterns, "|"))
for i, delta := range deltas {
if !like(delta) {
continue
}
deltas[i] = n.NormalizeDelta(delta)
}
return deltas
}
func (n Normalizer) NormalizeDelta(delta ledger.Delta) ledger.Delta {
delta.Value /= n.NormalizeFactor(delta.Name, delta.Date)
return delta
}
func (n Normalizer) NormalizeFactor(name, date string) float64 {
for pattern := range n.m {
if regexp.MustCompile(pattern).MatchString(name) {
for _, normalize := range n.m[pattern] {
if normalize.startDate < date {
return normalize.factor
}
}
}
}
return 1.0
}

30
src/ana/normalize_test.go Normal file
View File

@@ -0,0 +1,30 @@
package ana
import (
"testing"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
func TestNormalize(t *testing.T) {
deltas := ledger.Deltas{
ledger.Delta{Date: "2024-12-04", Value: 100, Name: "Bel:Withdrawal"},
ledger.Delta{Date: "2024-12-06", Value: 100, Name: "Bel:Withdrawal"},
}
normalizer := NewNormalizer().
With("^Bel:", "2024-12-05", 190_000).
With("^Bel:", "2018-02-16", 86_000)
normalized := normalizer.Normalize(deltas)
for i, delta := range normalized {
t.Logf("[%d] %+v", i, delta)
}
if normalized[0].Value != 100/float64(86_000) {
t.Errorf("earliest transaction didnt use earliest salary")
}
if normalized[1].Value != 100/float64(190_000) {
t.Errorf("latest transaction didnt use latest salary")
}
}

View File

@@ -4,7 +4,7 @@ import (
"maps"
"time"
"gogs.inhome.blapointe.com/ana-ledger/ledger"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
type Prediction []Predictor

View File

@@ -5,7 +5,7 @@ import (
"testing"
"time"
"gogs.inhome.blapointe.com/ana-ledger/ledger"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
func TestPredictionPredict(t *testing.T) {

View File

@@ -7,7 +7,7 @@ import (
"slices"
"time"
"gogs.inhome.blapointe.com/ana-ledger/ledger"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
const (

View File

@@ -4,7 +4,7 @@ import (
"testing"
"time"
"gogs.inhome.blapointe.com/ana-ledger/ledger"
"gogs.inhome.blapointe.com/ana-ledger/src/ledger"
)
func TestNewInterestPredictor(t *testing.T) {

107
src/bank/cache/cache.go vendored Normal file
View File

@@ -0,0 +1,107 @@
package cache
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/bank"
)
type Client struct {
Client bank.Agg
}
var _ bank.Agg = Client{}
func New(client bank.Agg) Client {
return Client{Client: client}
}
func (c Client) Accounts(ctx context.Context) ([]bank.Account, error) {
k := "accounts"
result := []bank.Account{}
if err := fromCache(k, &result); err != nil {
log.Printf("%q not in cache: %v", k, err)
} else {
return result, nil
}
result, err := c.Client.Accounts(ctx)
if err != nil {
return nil, err
}
toCache(k, result)
return result, nil
}
func (c Client) Transactions(ctx context.Context, a bank.Account) ([]bank.Transaction, error) {
k := path.Join("accounts.d", a.Account)
result := []bank.Transaction{}
if err := fromCache(k, &result); err != nil {
log.Printf("%q not in cache: %v", k, err)
} else {
return result, nil
}
result, err := c.Client.Transactions(ctx, a)
if err != nil {
return nil, err
}
toCache(k, result)
return result, nil
}
var (
d = path.Join("/tmp/ana_ledger_bank_cache.d")
)
func toCache(k string, v interface{}) {
if err := _toCache(k, v); err != nil {
log.Printf("failed to cache %s: %v", k, err)
}
}
func _toCache(k string, v interface{}) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
p := path.Join(d, k)
os.MkdirAll(path.Dir(p), os.ModePerm)
if err := os.WriteFile(p, b, os.ModePerm); err != nil {
os.Remove(p)
return err
}
return nil
}
func fromCache(k string, ptr interface{}) error {
p := path.Join(d, k)
if stat, err := os.Stat(p); err != nil {
return err
} else if time.Since(stat.ModTime()) > 24*time.Hour {
return fmt.Errorf("stale")
}
b, err := os.ReadFile(p)
if err != nil {
return err
}
if err := json.Unmarshal(b, ptr); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,50 @@
//go:build integration
package cache_test
import (
"context"
"strconv"
"testing"
"gogs.inhome.blapointe.com/ana-ledger/src/bank/cache"
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
)
func Test(t *testing.T) {
tellerC, err := teller.New()
if err != nil {
t.Fatal(err)
}
client := cache.New(tellerC)
ctx := context.Background()
for i := 0; i < 2; i++ {
i := i
client := client
t.Run(strconv.Itoa(i), func(t *testing.T) {
accounts, err := client.Accounts(ctx)
if err != nil {
t.Fatal(err)
}
for _, account := range accounts {
account := account
t.Run(account.Account, func(t *testing.T) {
transactions, err := client.Transactions(ctx, account)
if err != nil {
t.Fatal(err)
}
for i, tr := range transactions {
t.Logf("[%d] %+v", i, tr)
}
})
break
}
})
client.Client = nil
}
}

View File

@@ -0,0 +1 @@
app_pdvv33dtmta4fema66000

View File

@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIExjCCAq6gAwIBAgIIGEJSCPAVjIYwDQYJKoZIhvcNAQELBQAwYTELMAkGA1UE
BhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxDzANBgNVBAcMBkxvbmRvbjEPMA0GA1UE
CgwGVGVsbGVyMR4wHAYDVQQLDBVUZWxsZXIgQXBwbGljYXRpb24gQ0EwHhcNMjUw
NTI0MDEyMzIzWhcNMjgwNTIzMDEyMzIzWjAkMSIwIAYDVQQDDBlhcHBfcGR2djMz
ZHRtdGE0ZmVtYTY2MDAwMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
tWbYAc2wQBBvKJhTlo5YPLqkM5GgaYaWUqJJ5bFht+U2PL0rJHbS7oRG9fXtgb12
jEzD3CUUPpCxj7oRgYdji2SH5NKmo3M85/Cry1y5pmotmqGlqrt01zStj2A+FuzR
BqdmVb1CIE2iJmttGn3C3f9OCbV5kMKreu4DhVdPW7eafpo+yIMaoJxx2CAMqw5Y
GK2NNpehMHzpL4Z0032yEQJaBblYeUT4zgMCxoupXQ7hCsyjn/ws7ocpMHXY9r08
6PjaY1j6wYwX9OUJDXz0zgoeShr2vcfkjBc1QavRLVQQUWgYJevS/8rCfo32B7ub
Ym1CmWz2cY3+4UV3uihJgQIDAQABo4G+MIG7MA4GA1UdDwEB/wQEAwIF4DATBgNV
HSUEDDAKBggrBgEFBQcDAjCBkwYDVR0jBIGLMIGIgBSEq++simSLxXkuNSUKjel6
pmhxmqFlpGMwYTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxDzANBgNV
BAcMBkxvbmRvbjEPMA0GA1UECgwGVGVsbGVyMR4wHAYDVQQLDBVUZWxsZXIgQXBw
bGljYXRpb24gQ0GCCQDiNWG/vm85CTANBgkqhkiG9w0BAQsFAAOCAgEARv/Kjwcu
ppXbTf9pvsesEgo6O+OM1qW73SkmQeB5ZfF6KEOn57SujTjVQRlBGhVs//+Ezlav
GKKm4Xw0eCKUfISIgz+nY7lSlVvW2REZAdpuiY6owsqtiL/Fe/RBvkUmNSWnu8vc
OIfnpqP6flKL7KjXwQXoI/Xt9Zw6D56dHQi5hYgYtP0HqtEZn6vdtroHM3mbIL6D
Dnfdhb3ywwVZTiQE5ceQk+StDYuzTz7PNQL6IWxcH4j73dQlvFzMLSDm9yA2NguK
SEiRi4gYmluQxSiTN3gYqfOMeVv/buklHknkRHCInOTOiDWX8ku0FWwApiwsmvLS
3z/9WK4QEMLQfxBbp7UJePWx0Tq6KE61gcTgMbjqz+Xi9n2KM6RenDdMg2rQXWX4
xqC0sOOuSS19WFCGYDBRjI2JOQqfeEymq8pQsrGm+XyzCPMi+eFyLZqkRiT85MCr
IZuUWpMTcqILGP3Ar5Z0ppDI1ppVDhWMoq5EYx0iuJjNQZEtHlbu1j+cw6uVAoqJ
T/E5/qKcLRkkDKV68B+CP2z+iOXH5M1dcYFu6yEkKxVbEuTJNyN5gHhBATyIoMQ0
RdohL8F9hdOHvTLo89xrFEHiLExIT0NISjlT0M/mTqq4rbr5kp5W/ee9h5uSVUJ8
3nCv0NH3CX0Ygjzd7Czd9hWRahQz+vv55u4=
-----END CERTIFICATE-----

64
src/bank/teller/init.go Normal file
View File

@@ -0,0 +1,64 @@
package teller
import (
"context"
_ "embed"
"fmt"
"io"
"net/http"
"os"
"slices"
"text/template"
)
var (
//go:embed application_id.txt
applicationId string
//go:embed init.html
initHTML string
)
func Init(ctx context.Context) error {
environment := "development"
if sandbox := !slices.Contains(os.Args, "forreal"); sandbox {
environment = "sandbox"
}
fmt.Printf("environment=%q\n", environment)
newTokens := make(chan string)
defer close(newTokens)
s := &http.Server{
Addr: ":20000",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
b, _ := io.ReadAll(r.Body)
newTokens <- string(b)
return
}
t, err := template.New("initHTML").Parse(initHTML)
if err != nil {
panic(err)
}
if err := t.Execute(w, map[string]string{
"applicationId": applicationId,
"environment": environment,
}); err != nil {
panic(err)
}
}),
}
defer s.Close()
go s.ListenAndServe()
fmt.Println("Open http://localhost:20000")
select {
case <-ctx.Done():
case newToken := <-newTokens:
return fmt.Errorf("not impl: %q >> token.txt", newToken)
}
return ctx.Err()
}

58
src/bank/teller/init.html Normal file
View File

@@ -0,0 +1,58 @@
<html>
<head></head>
<body>
<button id="teller-connect">Connect to your bank</button>
<h3 id="log">
</h3>
<script src="https://cdn.teller.io/connect/connect.js"></script>
<script>
function logme(msg) {
document.getElementById("log").innerHTML += `<br>* ${msg}`
}
function http(method, remote, callback, body) {
var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == XMLHttpRequest.DONE) {
callback(xmlhttp.responseText, xmlhttp.status)
}
};
xmlhttp.open(method, remote, true);
if (typeof body == "undefined") {
body = null
}
xmlhttp.send(body);
}
function callback(responseBody, responseStatus) {
}
document.addEventListener("DOMContentLoaded", function() {
var tellerConnect = TellerConnect.setup({
applicationId: "{{.applicationId}}",
environment: "{{.environment}}",
products: ["verify", "balance", "transactions"],
onInit: function() {
logme("Teller Connect has initialized")
},
onSuccess: function(enrollment) {
logme(`User enrolled successfully: ${enrollment.accessToken}`)
http("post", "/", callback, enrollment.accessToken)
},
onExit: function() {
logme("User closed Teller Connect")
},
onFailure: function(failure) {
logme(`Failed: type=${failure.type} code=${failure.code} message=${failure.message}`)
},
});
var el = document.getElementById("teller-connect");
el.addEventListener("click", function() {
tellerConnect.open();
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC1ZtgBzbBAEG8o
mFOWjlg8uqQzkaBphpZSoknlsWG35TY8vSskdtLuhEb19e2BvXaMTMPcJRQ+kLGP
uhGBh2OLZIfk0qajczzn8KvLXLmmai2aoaWqu3TXNK2PYD4W7NEGp2ZVvUIgTaIm
a20afcLd/04JtXmQwqt67gOFV09bt5p+mj7IgxqgnHHYIAyrDlgYrY02l6EwfOkv
hnTTfbIRAloFuVh5RPjOAwLGi6ldDuEKzKOf/Czuhykwddj2vTzo+NpjWPrBjBf0
5QkNfPTOCh5KGva9x+SMFzVBq9EtVBBRaBgl69L/ysJ+jfYHu5tibUKZbPZxjf7h
RXe6KEmBAgMBAAECggEAExDEEyxzIciYZkPcRS6gx4E2UNU1buHeWsED00hZZOKK
WMfpCOQUN01fx+oZFFG9a/GFhFXBUvISN3Du9hYsuDHQtpQNP5CVDiuVYsJUINF4
CZCDwPYCybuXokITRIWPUou1jb1efdaq/C6+QNKG8J4srYiNRlGvhDQP2qvag2ET
YtY+6OuQPF9QSeoKLBCJJi8bP/wy3OfOQkTqvlpyEGVOUc5utGuziITShL2mLtQw
1O22r4BIFtkvnhM4kGimbSDMYwtAgMrBrktR4Xp7JICV6eZn6fJgjpYOix/4ILxS
Ri69qPLso4qF+ML/vsTHlxQj7FwZDZozT4hm3DK3gQKBgQDdnEAdQYuKIvwrHflk
Pxb9q7MjrMJMGiA9GvSVTimeWu68kH2y/dUUQyBtr8SRCScNUmhRCfaZCBakSYR0
0v+6FLXleI28dD93Qzn70G0N6kVdKLdM3Rt0r+fojPhP+8pHn74UzvcoGUDOzoax
VPvHLe19wqrbRFIU4IAE6qjmNQKBgQDRjUFjRJIh4wgrB6rROeoTtYRRyS9RLliw
dpH78Vz7OYTx7qnvtVOl8led82Ott7hEXcK9Ihobz8uPCewW/x+sPYty13shT46K
8zEj9FoP9XhAS3MOqLDNx7h5jv7nvua8aRQEPkF30SmPqb+X4nN5+JjuJiTqWgJ+
5nfnyX4PnQKBgQCE87brVmV27GJJI+R5JfiPG7GPl5fBvHLW9hMCeDAz1u4fprgi
6HIrg9IyvB67vLf3IBeBdu7BBL9AtPKIfAX8B2zRTLAL/doNnQFud67ViFUw/Lpr
nMNaECabt+dJZRAIRGfvZ/OT1QKyj+jy/r9G0eEHcAC9J5HvAHkNehL2eQKBgAU+
a6x4Qs/mRoYNIxEpSdpEaJNDXZPCfSWtUenkGFeREOqc9lOxTe6RKfAh7xShzFKp
pf3lpJGdmZJyxR2uNLSytZKiIcqrmv2PKGOl8bsEgYXaXX64afQ8Uzl3gpl6BXwh
hQa2KB0/drLJpKnAWPNsbSdIfRQAPJ/AVK/QMv9hAoGAMtmCDJK1KVZcpFJbFSMk
pPJjcp1XRWKsiBOPfUKlwbBSFDBUnhyPcsfL9ooAwzBnBcpNf6S5I+I22AdDuk8E
S3uOBBlNhoecWN1tqVlcbTR1p5kXV0WVAcQ09hGPqja5ghrTTpWbC7SviSVNa0fx
LXKvqDx5qTleSmrPwPs5TW4=
-----END PRIVATE KEY-----

95
src/bank/teller/teller.go Normal file
View File

@@ -0,0 +1,95 @@
package teller
import (
"context"
"crypto/tls"
_ "embed"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/bank"
"golang.org/x/time/rate"
)
type Client struct {
cert tls.Certificate
}
var _ bank.Agg = Client{}
var (
//go:embed certificate.pem
certificate []byte
//go:embed private_key.pem
privateKey []byte
//go:embed token.txt
Tokens string
)
func New() (Client, error) {
cert, err := tls.X509KeyPair(certificate, privateKey)
return Client{cert: cert}, err
}
func (c Client) Accounts(ctx context.Context) ([]bank.Account, error) {
var result []bank.Account
for _, token := range strings.Fields(Tokens) {
var more []bank.Account
if err := c.get(ctx, "https://api.teller.io/accounts", token, &more); err != nil {
return nil, err
}
for i := range more {
more[i].Token = token
}
result = append(result, more...)
}
return result, nil
}
func (c Client) Transactions(ctx context.Context, a bank.Account) ([]bank.Transaction, error) {
var result []bank.Transaction
err := c.get(ctx, "https://api.teller.io/accounts/"+a.Account+"/transactions", a.Token, &result)
return result, err
}
var limiter = rate.NewLimiter(0.1, 1)
func (c Client) get(ctx context.Context, url, token string, ptr interface{}) error {
if err := limiter.Wait(ctx); err != nil {
return err
}
log.Printf("Teller.Get(%s, %s)", url, token)
httpc := &http.Client{
Timeout: time.Second,
Transport: &http.Transport{
DisableKeepAlives: true,
TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{c.cert}},
},
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
req.SetBasicAuth(token, "")
req = req.WithContext(ctx)
resp, err := httpc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if err := json.Unmarshal(b, &ptr); err != nil {
return fmt.Errorf("cannot unmarshal: %w: %s", err, b)
}
return nil
}

View File

@@ -0,0 +1,41 @@
//go:build integration
package teller_test
import (
"context"
"testing"
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
)
func Test(t *testing.T) {
teller.Tokens = "test_token_bfu2cyvq3il6o"
c, err := teller.New()
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
accounts, err := c.Accounts(ctx)
if err != nil {
t.Fatal(err)
}
for _, account := range accounts {
account := account
t.Run(account.Account, func(t *testing.T) {
transactions, err := c.Transactions(ctx, account)
if err != nil {
t.Fatal(err)
}
for i, tr := range transactions {
t.Logf("[%d] %+v", i, tr)
}
})
break
}
}

View File

@@ -0,0 +1,59 @@
//go:build manual
package teller_test
import (
"crypto/tls"
"io"
"net/http"
"testing"
"time"
"gogs.inhome.blapointe.com/ana-ledger/src/bank/teller"
)
func TestIntegration(t *testing.T) {
teller.Tokens = "test_token_bfu2cyvq3il6o"
//curl --cert certificate.pem --cert-key private_key.pem --auth test_token_bfu2cyvq3il6o: https://api.teller.io/accounts
cert, err := tls.LoadX509KeyPair("./certificate.pem", "./private_key.pem")
if err != nil {
t.Fatal(err)
}
c := &http.Client{
Timeout: time.Second,
Transport: &http.Transport{
DisableKeepAlives: true,
TLSClientConfig: &tls.Config{Certificates: []tls.Certificate{cert}},
},
}
//curl --cert certificate.pem --cert-key private_key.pem --auth test_token_bfu2cyvq3il6o: https://api.teller.io/accounts
for _, url := range []string{
"https://api.teller.io/accounts",
"https://api.teller.io/accounts/acc_pdvv4810fi9hmrcn6g000/transactions",
} {
url := url
t.Run(url, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
req.SetBasicAuth("test_token_bfu2cyvq3il6o", "")
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if code := resp.StatusCode; code >= 300 {
t.Fatalf("(%d) %s", code, body)
}
t.Logf("(%d) %s", resp.StatusCode, body)
})
}
}

View File

@@ -0,0 +1,2 @@
token_2utqstwpn3pxwgvyno56hqdehq
token_vr6dnzvfv7c24wuxtmnnzyiqbm

31
src/bank/types.go Normal file
View File

@@ -0,0 +1,31 @@
package bank
import "context"
type Agg interface {
Accounts(context.Context) ([]Account, error)
Transactions(context.Context, Account) ([]Transaction, error)
}
type Account struct {
Institution struct {
Name string `json:"name"`
} `json:"institution"`
Name string `json:"last_four"`
Account string `json:"id"`
Token string `json:"__token"`
}
type Transaction struct {
Amount float64 `json:"amount,string"`
Details struct {
ProcessingStatus string `json:"processing_status"`
CounterParty struct {
Name string `json:"name"`
} `json:"counterparty"`
} `json:"details"`
Description string `json:"description"`
Date string `json:"date"`
Type string `json:"type"`
Status string `json:"status"`
}

263
src/ledger/balances.go Normal file
View File

@@ -0,0 +1,263 @@
package ledger
import (
"fmt"
"maps"
"regexp"
"sort"
"strings"
"time"
)
type Normalizer interface {
NormalizeFactor(string, string) float64
}
type Balances map[string]Balance
type Balance map[Currency]float64
func (balances Balances) Sub(other Balances) Balances {
result := make(Balances)
for k, v := range balances {
result[k] = v.Sub(other[k])
}
for k, v := range other {
if _, ok := balances[k]; ok {
continue
}
result[k] = v.Invert()
}
return result
}
func (balances Balances) Nonzero() Balances {
result := make(Balances)
for k, v := range balances {
if nonzero := v.Nonzero(); len(nonzero) > 0 {
result[k] = nonzero
}
}
return result
}
func (balances Balances) NotLike(pattern string) Balances {
result := make(Balances)
p := regexp.MustCompile(pattern)
for k, v := range balances {
if !p.MatchString(k) {
result[k] = maps.Clone(v)
}
}
return result
}
func (balances Balances) KindaLike(like Like) Balances {
ks := []string{}
for k := range balances {
ks = append(ks, k)
}
sort.Strings(ks)
result := make(Balances)
for _, k := range ks {
v := balances[k]
if like(v.kinda(k)) {
result[k] = maps.Clone(v)
}
}
return result
}
func (b Balance) kinda(k string) Delta {
return Delta{
Date: time.Now().Format("2006-01-02"),
Name: k,
Value: b[USD],
Description: k,
Transaction: k,
Payee: false,
}
}
func (balances Balances) Like(pattern string) Balances {
result := make(Balances)
p := regexp.MustCompile(pattern)
for k, v := range balances {
if p.MatchString(k) {
result[k] = maps.Clone(v)
}
}
return result
}
func (balances Balances) Group(pattern string) Balances {
result := make(Balances)
p := regexp.MustCompile(pattern)
for k, v := range balances {
k2 := k
if p.MatchString(k) {
k2 = p.FindString(k)
}
was := result[k2]
if was == nil {
was = make(Balance)
}
for k3, v3 := range v {
was[k3] += v3
}
result[k2] = was
}
return result
}
func (balances Balances) KindaGroup(group Group) Balances {
ks := []string{}
for k := range balances {
ks = append(ks, k)
}
sort.Strings(ks)
result := make(Balances)
for _, k := range ks {
v := balances[k]
k2 := k
if k3 := group(v.kinda(k)).Name; k2 != k3 {
k2 = k3
}
if _, ok := result[k2]; !ok {
result[k2] = make(Balance)
}
for k3, v3 := range v {
result[k2][k3] += v3
}
}
return result
}
func (balances Balances) WithBPIs(bpis BPIs) Balances {
return balances.WithBPIsAt(bpis, "9")
}
func (balances Balances) WithBPIsAt(bpis BPIs, date string) Balances {
ks := []string{}
for k := range balances {
ks = append(ks, k)
}
sort.Strings(ks)
result := make(Balances)
for _, k := range ks {
v := balances[k]
if _, ok := result[k]; !ok {
result[k] = make(Balance)
}
for k2, v2 := range v {
if k2 == USD {
result[k][USD] = result[k][USD] + v2
} else if scalar := bpis[k2].Lookup(date); scalar != nil {
result[k][USD] = result[k][USD] + v2*(*scalar)
} else {
result[k][k2] = result[k][k2] + v2
}
}
}
return result
}
func (balances Balances) PushAll(other Balances) {
for k, v := range other {
if _, ok := balances[k]; !ok {
balances[k] = make(Balance)
}
for k2, v2 := range v {
if _, ok := balances[k][k2]; !ok {
balances[k][k2] = 0
}
balances[k][k2] += v2
}
}
}
func (balances Balances) Push(d Delta) {
if _, ok := balances[d.Name]; !ok {
balances[d.Name] = make(Balance)
}
balances[d.Name].Push(d)
}
func (balance Balance) Sub(other Balance) Balance {
return balance.Sum(other.Invert())
}
func (balance Balance) Sum(other Balance) Balance {
result := make(Balance)
for k, v := range balance {
result[k] += v
}
for k, v := range other {
result[k] += v
}
return result
}
func (balance Balance) Nonzero() Balance {
result := make(Balance)
for k, v := range balance {
if v != 0 {
result[k] = v
}
}
return result
}
func (balance Balance) Invert() Balance {
result := make(Balance)
for k, v := range balance {
result[k] = v * -1.0
}
return result
}
func (balance Balance) Push(d Delta) {
if _, ok := balance[d.Currency]; !ok {
balance[d.Currency] = 0
}
balance[d.Currency] += d.Value
}
func (balances Balances) Debug() string {
result := []string{}
for k, v := range balances {
result = append(result, fmt.Sprintf("%s:[%s]", k, v.Debug()))
}
return strings.Join(result, " ")
}
func (balance Balance) Debug() string {
result := []string{}
for k, v := range balance {
if k == USD {
result = append(result, fmt.Sprintf("%s%.2f", k, v))
} else {
result = append(result, fmt.Sprintf("%.3f %s", v, k))
}
}
return strings.Join(result, " + ")
}
func (balances Balances) Normalize(n Normalizer, date string) Balances {
result := make(Balances)
for name, balance := range balances {
result[name] = make(Balance)
for currency, value := range balance {
result[name][currency] = value / n.NormalizeFactor(name, date)
}
}
return result
}

View File

@@ -51,4 +51,38 @@ func TestBalances(t *testing.T) {
t.Error("didnt sum other", b["ab"])
}
})
t.Run("like", func(t *testing.T) {
was := make(Balances)
was.Push(Delta{Name: "a", Currency: USD, Value: 0.1})
was.Push(Delta{Name: "ab", Currency: USD, Value: 1.2})
got := was.Like(`^ab$`)
if len(got) != 1 || got["ab"][USD] != 1.2 {
t.Error(got)
}
got = was.NotLike(`^ab$`)
if len(got) != 1 || got["a"][USD] != 0.1 {
t.Error(got)
}
})
t.Run("group", func(t *testing.T) {
was := make(Balances)
was.Push(Delta{Name: "a:1", Currency: USD, Value: 0.1})
was.Push(Delta{Name: "a:2", Currency: USD, Value: 1.2})
was.Push(Delta{Name: "b:1", Currency: USD, Value: 2.2})
got := was.Group(`^[^:]*`)
if len(got) != 2 {
t.Error(got)
}
if got["a"][USD] != 1.3 {
t.Error(got)
}
if got["b"][USD] != 2.2 {
t.Error(got)
}
})
}

View File

@@ -50,7 +50,7 @@ func NewBPIs(p string) (BPIs, error) {
}
}
func (bpi BPI) Lookup(date string) float64 {
func (bpi BPI) Lookup(date string) *float64 {
var closestWithoutGoingOver string
for k := range bpi {
if k <= date && k > closestWithoutGoingOver {
@@ -58,7 +58,8 @@ func (bpi BPI) Lookup(date string) float64 {
}
}
if closestWithoutGoingOver == "" {
return 0
return nil
}
return bpi[closestWithoutGoingOver]
f := bpi[closestWithoutGoingOver]
return &f
}

View File

@@ -157,16 +157,16 @@ P 2023-10-22 07:33:56 GME $17.18
t.Fatal(err)
}
if got := got["USDC"].Lookup("2099-01-01"); got != 0 {
if got := got["USDC"].Lookup("2099-01-01"); got != nil {
t.Error("default got != 0:", got)
}
if got := got["GME"].Lookup("2099-01-01"); got != 17.18 {
if got := got["GME"].Lookup("2099-01-01"); got == nil || *got != 17.18 {
t.Errorf("shouldve returned latest but got %v", got)
}
if got := got["GME"].Lookup("2023-09-19"); got != 18.22 {
if got := got["GME"].Lookup("2023-09-19"); got == nil || *got != 18.22 {
t.Errorf("shouldve returned one day before but got %v", got)
}
if got := got["GME"].Lookup("2023-09-18"); got != 18.22 {
if got := got["GME"].Lookup("2023-09-18"); got == nil || *got != 18.22 {
t.Errorf("shouldve returned day of but got %v", got)
}
t.Log(got)

65
src/ledger/delta.go Normal file
View File

@@ -0,0 +1,65 @@
package ledger
import "fmt"
type Currency string
const (
USD = Currency("$")
)
type Delta struct {
Date string
Name string
Value float64
Currency Currency
Description string
Transaction string
Payee bool
isSet bool
fileName string
lineNo int
with []Delta
}
func newDelta(transaction string, payee bool, d, desc, name string, v float64, c string, isSet bool, fileName string, lineNo int) Delta {
return Delta{
Date: d,
Name: name,
Value: v,
Currency: Currency(c),
Description: desc,
Transaction: transaction,
Payee: payee,
isSet: isSet,
fileName: fileName,
lineNo: lineNo,
}
}
func (delta Delta) withWith(other Delta) Delta {
other.with = nil
delta.with = append(delta.with, other)
return delta
}
func (delta Delta) equivalent(other Delta) bool {
delta.fileName = ""
delta.lineNo = 0
delta.with = nil
other.fileName = ""
other.lineNo = 0
other.with = nil
return fmt.Sprintf("%+v", delta) == fmt.Sprintf("%+v", other)
}
func (delta Delta) Debug() string {
return fmt.Sprintf("{@%s %s(payee=%v):\"%s\" %s%.2f %s @%s#%d}", delta.Date, delta.Name, delta.Payee, delta.Description, func() string {
if !delta.isSet {
return ""
}
return "= "
}(), delta.Value, delta.Currency, delta.fileName, delta.lineNo)
}

View File

@@ -6,8 +6,14 @@ import (
func TestDelta(t *testing.T) {
d := "2099-08-07"
delta := newDelta(d, "", "name", 34.56, "$", false)
delta := newDelta("x", true, d, "", "name", 34.56, "$", false, "", 0)
if delta.Transaction != "x" {
t.Error(delta.Transaction)
}
if !delta.Payee {
t.Error(delta.Payee)
}
if delta.Date != d {
t.Error(delta.Date)
}

View File

@@ -57,12 +57,8 @@ func (deltas Deltas) Balances() Balances {
}
result[delta.Name][delta.Currency] += delta.Value
if result[delta.Name][delta.Currency] < 0.000000001 && result[delta.Name][delta.Currency] > -0.000000001 {
delete(result[delta.Name], delta.Currency)
if len(result[delta.Name]) == 0 {
delete(result, delta.Name)
result[delta.Name][delta.Currency] = 0
}
}
}
return result
}

View File

@@ -38,9 +38,12 @@ func TestDeltas(t *testing.T) {
}
balances := deltas.Balances()
if len(balances) != 1 {
if len(balances) != 2 {
t.Error(len(balances), balances)
}
if balances["a"][""] != 0 {
t.Error(balances["a"])
}
if balances["b"][""] != 1.3 {
t.Error(balances["b"])
}

205
src/ledger/file.go Normal file
View File

@@ -0,0 +1,205 @@
package ledger
import (
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"sort"
"strings"
"unicode"
)
var filesAppendDelim = "\t"
type Files []string
func NewFiles(p string, q ...string) (Files, error) {
f := Files(append([]string{p}, q...))
_, err := f.Deltas()
return f, err
}
func (files Files) Amend(old, now Delta) error {
if now.isSet {
return fmt.Errorf("cannot ammend: immutable isSet is set")
}
xactions, err := files.transactions()
if err != nil {
return err
}
var transaction transaction
for _, xaction := range xactions {
if xaction.name != old.Transaction {
continue
}
transaction = xaction
break
}
if transaction.payee == old.Name {
if len(transaction.recipients) != 1 {
return fmt.Errorf("cannot amend: modifying original payee, but many recipients cant share new value")
}
transaction.payee, transaction.recipients[0].name, transaction.recipients[0].value = transaction.recipients[0].name, transaction.payee, transaction.recipients[0].value*-1.0
}
idx := -1
for i, recipient := range transaction.recipients {
if recipient.name == old.Name && recipient.value == old.Value {
idx = i
}
}
if idx == -1 {
return fmt.Errorf("cannot amend: no recipient with name %q value %.2f found in %+v to set new value", old.Name, old.Value, transaction)
}
old.Value *= -1
return files.Add(transaction.payee, []Delta{old, now}...)
}
func (files Files) paths() []string {
result := make([]string, 0, len(files))
for i := range files {
if info, err := os.Stat(files[i]); err == nil && info.IsDir() {
if err := filepath.WalkDir(files[i], func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
result = append(result, p)
}
return nil
}); err != nil {
panic(err)
}
} else {
result = append(result, files[i])
}
}
return result
}
func (files Files) Add(payee string, deltas ...Delta) error {
for _, delta := range deltas {
if err := files.add(payee, delta); err != nil {
return err
}
}
return nil
}
func (files Files) add(payee string, delta Delta) error {
currencyValue := fmt.Sprintf("%s%.2f", delta.Currency, delta.Value)
if delta.Currency != USD {
currencyValue = fmt.Sprintf("%.2f %s", delta.Value, delta.Currency)
}
return files.append(fmt.Sprintf("%s %s\n%s%s%s%s\n%s%s",
delta.Date, delta.Description,
filesAppendDelim, delta.Name, filesAppendDelim+filesAppendDelim+filesAppendDelim, currencyValue,
filesAppendDelim, payee,
))
}
func (files Files) append(s string) error {
p := path.Join(path.Dir(files.paths()[0]), "inbox.txt")
if err := files.trimTrailingWhitespace(p); err != nil {
return err
}
f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, os.ModePerm)
if err != nil {
return err
}
defer f.Close()
fmt.Fprintf(f, "\n%s", s)
return f.Close()
}
func (files Files) trimTrailingWhitespace(p string) error {
idx, err := files._lastNonWhitespacePos(p)
if err != nil {
return err
}
if idx < 1 {
return nil
}
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY, os.ModePerm)
if err != nil {
return err
}
defer f.Close()
return f.Truncate(int64(idx + 1))
}
func (files Files) _lastNonWhitespacePos(p string) (int, error) {
f, err := os.Open(p)
if os.IsNotExist(err) {
return -1, nil
}
if err != nil {
return -1, err
}
defer f.Close()
b, err := io.ReadAll(f)
if err != nil {
return -1, err
}
for i := len(b) - 1; i >= 0; i-- {
if !unicode.IsSpace(rune(b[i])) {
return i, nil
}
}
return len(b) - 1, nil
}
func (files Files) Deltas(like ...Like) (Deltas, error) {
transactions, err := files.transactions()
if err != nil {
return nil, err
}
sort.Slice(transactions, func(i, j int) bool {
return fmt.Sprintf("%s %s", transactions[i].date, transactions[i].description) < fmt.Sprintf("%s %s", transactions[j].date, transactions[j].description)
})
result := make(Deltas, 0, len(transactions)*2)
for _, transaction := range transactions {
result = append(result, transaction.deltas()...)
}
slices.SortFunc(result, func(a, b Delta) int {
if str := strings.Compare(a.Date+a.fileName, b.Date+b.fileName); str != 0 {
return str
}
return a.lineNo - b.lineNo
})
balances := make(Balances)
for i := range result {
if result[i].isSet {
var was float64
if m, ok := balances[result[i].Name]; ok {
was = m[result[i].Currency]
}
result[i].Value = result[i].Value - was
result[i].isSet = false
}
balances.Push(result[i])
}
for i := range result {
if result[i].isSet {
return nil, fmt.Errorf("failed to resolve isSet: %+v", result[i])
}
}
return result.Like(like...), nil
}

View File

@@ -7,6 +7,160 @@ import (
"testing"
)
func TestFileAmend(t *testing.T) {
cases := map[string]struct {
from string
old Delta
now Delta
want string
}{
"multi recipient": {
from: `
2006-01-02 description
recipientA $3.45
recipientB $6.45
payee
`,
old: Delta{
Date: "2006-01-02",
Name: "recipientB",
Value: 6.45,
Currency: "$",
Description: "description",
},
now: Delta{
Date: "2106-11-12",
Name: "recipientC",
Value: 16.45,
Currency: "T",
Description: "1description",
},
want: `
2006-01-02 description
recipientB $-6.45
payee
2106-11-12 1description
recipientC 16.45 T
payee`,
},
"recipient": {
from: `
2006-01-02 description
recipient $3.45
payee $-3.45
`,
old: Delta{
Date: "2006-01-02",
Name: "recipient",
Value: 3.45,
Currency: "$",
Description: "description",
},
now: Delta{
Date: "2106-11-12",
Name: "1recipient",
Value: 13.45,
Currency: "T",
Description: "1description",
},
want: `
2006-01-02 description
recipient $-3.45
payee
2106-11-12 1description
1recipient 13.45 T
payee`,
},
"payee": {
from: `
2006-01-02 description
recipient $3.45
payee
`,
old: Delta{
Date: "2006-01-02",
Name: "payee",
Value: -3.45,
Currency: "$",
Description: "description",
},
now: Delta{
Date: "2106-11-12",
Name: "1payee",
Value: -13.45,
Currency: "T",
Description: "1description",
},
want: `
2006-01-02 description
payee $3.45
recipient
2106-11-12 1description
1payee -13.45 T
recipient`,
},
"was set": {
from: `
2006-01-02 description
recipient $3.45
payee
`,
old: Delta{
Date: "2006-01-02",
Name: "recipient",
Value: 3.45,
Currency: "$",
Description: "description",
},
now: Delta{
Date: "2106-11-12",
Name: "1recipient",
Value: 13.45,
Currency: "T",
Description: "1description",
},
want: `
2006-01-02 description
recipient $-3.45
payee
2106-11-12 1description
1recipient 13.45 T
payee`,
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
p := path.Join(t.TempDir(), "dat")
if err := os.WriteFile(p, []byte(c.from), os.ModePerm); err != nil {
t.Fatal(err)
}
files, err := NewFiles(p)
if err != nil {
t.Fatal(err)
} else if deltas, err := files.Deltas(); err != nil {
t.Fatal(err)
} else if filtered := deltas.Like(func(d Delta) bool {
c.old.Transaction = d.Transaction
c.old.Payee = d.Payee
return d.equivalent(c.old)
}); len(filtered) != 1 {
t.Fatalf("input \n\t%s \ndidnt include old \n\t%+v \nin \n\t%+v: \n\t%+v", c.from, c.old, deltas, filtered)
}
if err := files.Amend(c.old, c.now); err != nil {
t.Fatal(err)
} else if b, err := os.ReadFile(path.Join(path.Dir(p), "inbox.txt")); err != nil {
t.Fatal(err)
} else if string(b) != c.want {
t.Fatalf("expected \n\t%q\nbut got\n\t%q\n\t%s", c.want, b, b)
}
})
}
}
func TestFileAdd(t *testing.T) {
filesAppendDelim = " "
payee := "name:3"
@@ -67,7 +221,7 @@ func TestFileAdd(t *testing.T) {
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
p := path.Join(t.TempDir(), "input")
p := path.Join(t.TempDir(), "inbox.txt")
if c.given != nil {
if err := os.WriteFile(p, []byte(c.given), os.ModePerm); err != nil {
t.Fatal(err)
@@ -97,6 +251,7 @@ func TestFileTestdataMacroWithBPI(t *testing.T) {
if err != nil {
t.Fatal(err)
}
t.Log(paths)
f, err := NewFiles(paths[0], paths[1:]...)
if err != nil {
@@ -217,6 +372,10 @@ func TestFileDeltas(t *testing.T) {
Value: -97.92,
Currency: USD,
Description: "Electricity / Power Bill TG2PJ-2PLP5",
Payee: true,
fileName: "",
lineNo: 0,
},
{
Date: "2022-12-12",
@@ -224,6 +383,9 @@ func TestFileDeltas(t *testing.T) {
Value: 97.92,
Currency: USD,
Description: "Electricity / Power Bill TG2PJ-2PLP5",
fileName: "",
lineNo: 0,
},
{
Date: "2022-12-12",
@@ -231,6 +393,10 @@ func TestFileDeltas(t *testing.T) {
Value: -1.00,
Currency: USD,
Description: "Test pay chase TG32S-BT2FF",
Payee: true,
fileName: "",
lineNo: 0,
},
{
Date: "2022-12-12",
@@ -238,6 +404,9 @@ func TestFileDeltas(t *testing.T) {
Value: 1.00,
Currency: USD,
Description: "Test pay chase TG32S-BT2FF",
fileName: "",
lineNo: 0,
},
}
@@ -250,7 +419,8 @@ func TestFileDeltas(t *testing.T) {
for name, d := range cases {
want := d
t.Run(name, func(t *testing.T) {
f, err := NewFiles("./testdata/" + name + ".dat")
fileName := "./testdata/" + name + ".dat"
f, err := NewFiles(fileName)
if err != nil {
t.Fatal(err)
}
@@ -264,13 +434,34 @@ func TestFileDeltas(t *testing.T) {
t.Error(len(deltas))
}
for i := range want {
want[i].fileName = fileName
deltas[i].lineNo = 0
if i >= len(deltas) {
break
}
if want[i] != deltas[i] {
if deltas[i].Transaction == "" {
t.Error(deltas[i].Transaction)
}
deltas[i].Transaction = ""
if !want[i].equivalent(deltas[i]) {
t.Errorf("[%d] \n\twant=%s, \n\t got=%s", i, want[i].Debug(), deltas[i].Debug())
}
}
})
}
}
func TestFilesOfDir(t *testing.T) {
d := t.TempDir()
files := Files([]string{d, "/dev/null"})
if paths := files.paths(); len(paths) != 1 {
t.Error(paths)
}
os.WriteFile(path.Join(d, "1"), []byte{}, os.ModePerm)
os.Mkdir(path.Join(d, "d2"), os.ModePerm)
os.WriteFile(path.Join(d, "d2", "2"), []byte{}, os.ModePerm)
if paths := files.paths(); len(paths) != 3 || paths[0] != path.Join(d, "1") || paths[1] != path.Join(d, "d2", "2") {
t.Error(paths)
}
}

View File

@@ -1,6 +1,8 @@
package ledger
import "regexp"
import (
"regexp"
)
type Group func(Delta) Delta
@@ -17,6 +19,9 @@ func GroupDate(pattern string) Group {
p := regexp.MustCompile(pattern)
return func(d Delta) Delta {
d.Date = p.FindString(d.Date)
for i := range d.with {
d.with[i].Date = p.FindString(d.with[i].Date)
}
return d
}
}
@@ -25,6 +30,9 @@ func GroupName(pattern string) Group {
p := regexp.MustCompile(pattern)
return func(d Delta) Delta {
d.Name = p.FindString(d.Name)
for i := range d.with {
d.with[i].Name = p.FindString(d.with[i].Name)
}
return d
}
}

95
src/ledger/like.go Normal file
View File

@@ -0,0 +1,95 @@
package ledger
import (
"regexp"
)
type Like func(Delta) bool
type Likes []Like
func LikeWith(pattern string) Like {
l := LikeName(pattern)
return func(d Delta) bool {
if l(d) {
return true
}
for _, d := range d.with {
if l(d) {
return true
}
}
return false
}
}
func LikeTransactions(deltas ...Delta) Like {
return func(d Delta) bool {
for i := range deltas {
if deltas[i].Transaction == d.Transaction {
return true
}
}
return false
}
}
func LikeTransaction(delta Delta) Like {
return LikeTransactions(delta)
}
func LikeBefore(date string) Like {
return func(d Delta) bool {
return date >= d.Date
}
}
func LikeAfter(date string) Like {
return func(d Delta) bool {
return date <= d.Date
}
}
func LikeNotName(pattern string) Like {
return func(d Delta) bool {
return !like(pattern, d.Name)
}
}
func LikeNot(like Like) Like {
return func(d Delta) bool {
return !like(d)
}
}
func NotLikeName(pattern string) Like {
return LikeNot(LikeName(pattern))
}
func LikeName(pattern string) Like {
return func(d Delta) bool {
return like(pattern, d.Name)
}
}
func like(pattern string, other string) bool {
return regexp.MustCompile(pattern).MatchString(other)
}
func (likes Likes) Any(delta Delta) bool {
for i := range likes {
if likes[i](delta) {
return true
}
}
return false
}
func (likes Likes) All(delta Delta) bool {
for i := range likes {
if !likes[i](delta) {
return false
}
}
return true
}

View File

@@ -36,3 +36,20 @@ func TestLikesAll(t *testing.T) {
t.Error(likes.All(delta))
}
}
func TestLikeWith(t *testing.T) {
delta := Delta{
Name: "x",
with: []Delta{
Delta{Name: "y"},
Delta{Name: "z"},
},
}
if !LikeWith("x")(delta) {
t.Error("like with self not caught")
}
if !LikeWith("z")(delta) {
t.Error("like with reverse not caught")
}
}

View File

@@ -51,6 +51,26 @@ func (register Register) Names() []string {
return result
}
func (register Register) Like(pattern string) Register {
result := make(Register)
for k, v := range register {
if got := v.Like(pattern); len(got) > 0 {
result[k] = got
}
}
return result
}
func (register Register) NotLike(pattern string) Register {
result := make(Register)
for k, v := range register {
if got := v.NotLike(pattern); len(got) > 0 {
result[k] = got
}
}
return result
}
func (register Register) Dates() []string {
result := make([]string, 0, len(register))
for k := range register {

1
src/ledger/testdata/bpi.bpi vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../../../../Sync/Core/ledger/bpi.dat

1
src/ledger/testdata/macro.d vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../../../../Sync/Core/ledger/eras/2022-/

1
src/ledger/testdata/macro.dat vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../../../../Sync/Core/ledger/eras/2022-/fidelity.76.dat.txt

415
src/ledger/transaction.go Normal file
View File

@@ -0,0 +1,415 @@
package ledger
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"os"
"regexp"
"slices"
"strconv"
"strings"
"unicode"
)
type Transaction Deltas
type Transactions []Transaction
func (transactions Transactions) Deltas() Deltas {
result := make(Deltas, 0, len(transactions))
for _, transaction := range transactions {
result = append(result, transaction...)
}
return result
}
func (deltas Deltas) Transactions() Transactions {
m := make(map[string]Transaction)
for _, d := range deltas {
m[d.Transaction] = append(m[d.Transaction], d)
}
result := make(Transactions, 0, len(m))
for _, v := range m {
result = append(result, v)
}
slices.SortFunc(result, func(a, b Transaction) int {
if a[0].Date == b[0].Date {
if a[0].Description == b[0].Description {
return strings.Compare(a[0].Transaction, b[0].Transaction)
}
return strings.Compare(a[0].Description, b[0].Description)
}
return strings.Compare(a[0].Date, b[0].Date)
})
return result
}
func (transactions Transactions) NotLike(like ...Like) Transactions {
result := make(Transactions, 0, len(transactions))
for _, transaction := range transactions {
if matching := (Deltas)(transaction).Like(like...); len(matching) == 0 {
result = append(result, transaction)
}
}
return result
}
func (transactions Transactions) Like(like ...Like) Transactions {
result := make(Transactions, 0, len(transactions))
for _, transaction := range transactions {
if matching := (Deltas)(transaction).Like(like...); len(matching) > 0 {
result = append(result, transaction)
}
}
return result
}
func (transaction Transaction) Payee() string {
balances := Deltas(transaction).Balances()
candidates := []string{}
for name, balance := range balances {
deltas := Deltas(transaction).Like(LikeName(`^` + name + `$`))
if len(deltas) != 1 {
continue
}
everyoneElse := balances.NotLike(`^` + name + `$`).Group(`^`)[""]
matches := true
for currency, value := range balance {
matches = matches && everyoneElse[currency]*-1 == value
}
if matches {
candidates = append(candidates, name)
}
}
slices.Sort(candidates)
if len(candidates) == 0 {
panic(balances)
}
for _, candidate := range candidates {
if strings.HasPrefix(candidate, "Withdrawal") {
return candidate
}
}
return candidates[len(candidates)-1]
}
type transaction struct {
date string
description string
payee string
recipients []transactionRecipient
name string
fileName string
lineNo int
}
func (t transaction) empty() bool {
return fmt.Sprint(t) == fmt.Sprint(transaction{})
}
type transactionRecipient struct {
name string
value float64
currency string
isSet bool
}
func (t transaction) deltas() Deltas {
result := []Delta{}
sums := map[string]float64{}
for i, recipient := range t.recipients {
sums[recipient.currency] += recipient.value
result = append(result, newDelta(
t.name,
true,
t.date,
t.description,
recipient.name,
recipient.value,
recipient.currency,
recipient.isSet,
t.fileName,
t.lineNo+i,
))
}
for currency, value := range sums {
if value == 0 {
continue
}
if t.payee == "" {
//return nil, fmt.Errorf("didnt find net zero and no dumping ground payee set: %+v", transaction)
} else {
result = append(result, newDelta(
t.name,
false,
t.date,
t.description,
t.payee,
-1.0*value,
currency,
false,
t.fileName,
t.lineNo,
))
}
}
for i := range result {
for j := range result {
if i != j {
result[i] = result[i].withWith(result[j])
}
}
}
return result
}
func (t transactionRecipient) empty() bool {
return t == (transactionRecipient{})
}
func (files Files) transactions() ([]transaction, error) {
result := make([]transaction, 0)
for _, path := range files.paths() {
some, err := files._transactions(path)
if err != nil {
return nil, err
}
result = append(result, some...)
}
return result, nil
}
func (files Files) _transactions(file string) ([]transaction, error) {
f, err := os.Open(string(file))
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
defer f.Close()
r := bufio.NewReaderSize(f, 2048)
result := make([]transaction, 0)
for {
name := fmt.Sprintf("%s/%d", file, len(result))
one, err := readTransaction(name, r)
if !one.empty() {
one.fileName = file
one.lineNo = len(result)
result = append(result, one)
}
if err == io.EOF {
return result, nil
}
if err != nil {
return result, err
}
}
}
func readTransaction(name string, r *bufio.Reader) (transaction, error) {
result, err := _readTransaction(name, r)
if err != nil && !errors.Is(err, io.EOF) {
return result, err
}
if result.empty() {
return result, err
}
if result.payee != "" && len(result.recipients) < 1 {
return result, fmt.Errorf("found a transaction with payee but no recipeints: %+v", result)
}
if result.payee == "" {
if len(result.recipients) < 2 {
return result, fmt.Errorf("found a transaction with no payee and less than 2 recipeints: %+v", result)
}
func() {
sumPerRecipient := map[string]float64{}
recipients := []string{}
for _, recipient := range result.recipients {
recipients = append(recipients, recipient.name)
sumPerRecipient[recipient.name] += recipient.value
}
slices.Sort(recipients)
for _, k := range recipients {
n := 0
for i := range result.recipients {
if result.recipients[i].name == k {
n += 1
}
}
if n != 1 {
continue
}
v := sumPerRecipient[k]
everyoneElse := 0.0
for j := range sumPerRecipient {
if k != j {
everyoneElse += sumPerRecipient[j]
}
}
if -1.0*v == everyoneElse {
result.payee = k
result.recipients = slices.DeleteFunc(result.recipients, func(recipient transactionRecipient) bool {
return recipient.name == k
})
return
}
}
return
}()
}
return result, err
}
func _readTransaction(name string, r *bufio.Reader) (transaction, error) {
readTransactionLeadingWhitespace(r)
firstLine, err := readTransactionLine(r)
if len(bytes.TrimSpace(firstLine)) == 0 {
return transaction{}, err
}
dateDescriptionPattern := regexp.MustCompile(`^([0-9]+-[0-9]+-[0-9]+)\s+(.*)$`)
dateDescriptionMatches := dateDescriptionPattern.FindAllSubmatch(firstLine, 4)
if len(dateDescriptionMatches) != 1 {
return transaction{}, fmt.Errorf("bad first line: %v matches: %q", len(dateDescriptionMatches), firstLine)
} else if len(dateDescriptionMatches[0]) != 3 {
return transaction{}, fmt.Errorf("bad first line: %v submatches: %q", len(dateDescriptionMatches[0]), firstLine)
}
result := transaction{
date: string(dateDescriptionMatches[0][1]),
description: string(dateDescriptionMatches[0][2]),
name: name,
}
for {
name, value, currency, isSet, err := readTransactionName(r)
if name != "" {
if currency == "" {
result.payee = name
} else {
result.recipients = append(result.recipients, transactionRecipient{
name: name,
value: value,
currency: currency,
isSet: isSet,
})
}
}
if name == "" || err != nil {
return result, err
}
}
}
func readTransactionLeadingWhitespace(r *bufio.Reader) {
b, err := r.Peek(2048)
if err != nil && err != io.EOF {
return
}
i := 0
for i < len(b) {
if len(bytes.TrimSpace(b[:i])) != 0 {
break
}
i++
}
if i > 0 {
r.Read(make([]byte, i-1))
}
}
func readTransactionLine(r *bufio.Reader) ([]byte, error) {
for {
b, err := _readTransactionLine(r)
if err != nil || (len(bytes.TrimSpace(b)) > 0 && bytes.TrimSpace(b)[0] != '#') {
return b, err
}
}
}
func _readTransactionLine(r *bufio.Reader) ([]byte, error) {
b, err := r.Peek(2048)
if len(b) == 0 {
return nil, err
}
endOfLine := len(b)
if idx := bytes.Index(b, []byte{'\n'}); idx > -1 {
endOfLine = idx
}
b2 := make([]byte, endOfLine)
n, err := r.Read(b2)
if err == io.EOF {
err = nil
}
if check, _ := r.Peek(1); len(check) == 1 && check[0] == '\n' {
r.Read(make([]byte, 1))
}
return b2[:n], err
}
func readTransactionName(r *bufio.Reader) (string, float64, string, bool, error) {
line, err := readTransactionLine(r)
if err != nil {
return "", 0, "", false, err
}
if len(line) > 0 && !unicode.IsSpace(rune(line[0])) {
r2 := *r
*r = *bufio.NewReader(io.MultiReader(bytes.NewReader(append(line, '\n')), &r2))
return "", 0, "", false, nil
}
line = bytes.Split(line, []byte(";"))[0] // comment-free
fields := bytes.Fields(line)
isSet := false
if len(fields) > 2 && string(fields[1]) == "=" {
isSet = true
fields = append(fields[:1], fields[2:]...)
}
switch len(fields) {
case 1: // payee
return string(fields[0]), 0, "", false, nil
case 2: // payee $00.00
b := bytes.TrimLeft(fields[1], "$")
value, err := strconv.ParseFloat(string(b), 64)
if err != nil {
return "", 0, "", isSet, fmt.Errorf("failed to parse value from $XX.YY from %q (%q): %w", line, fields[1], err)
}
return string(fields[0]), value, string(USD), isSet, nil
case 3: // payee 00.00 XYZ
value, err := strconv.ParseFloat(string(fields[1]), 64)
if err != nil {
return "", 0, "", false, fmt.Errorf("failed to parse value from XX.YY XYZ from %q (%q): %w", line, fields[1], err)
}
return string(fields[0]), value, string(fields[2]), isSet, nil
default:
return "", 0, "", isSet, fmt.Errorf("cannot interpret %q", line)
}
}

View File

@@ -0,0 +1,121 @@
package ledger
import (
"bufio"
"fmt"
"io"
"strings"
"testing"
)
func TestTransactionPayee(t *testing.T) {
given := Transaction{
Delta{Name: "x", Transaction: "a", Value: 9},
Delta{Name: "Withdrawal:z", Transaction: "a", Value: -3},
Delta{Name: "Withdrawal:z", Transaction: "a", Value: -6},
}
got := given.Payee()
if got != "x" {
t.Error(got)
}
}
func TestDeltasTransactions(t *testing.T) {
given := Deltas{
Delta{Date: "2", Name: "x", Transaction: "a"},
Delta{Date: "2", Name: "y", Transaction: "a"},
Delta{Date: "1", Name: "z", Transaction: "b"},
}
got := given.Transactions()
if len(got) != 2 {
t.Error(len(got))
}
if len(got[0]) != 1 {
t.Error("first xaction is not earliest date", len(got[0]))
}
if len(got[1]) != 2 {
t.Error("second xaction is not latest date", len(got[1]))
}
}
func TestReadTransaction(t *testing.T) {
cases := map[string]struct {
input string
want transaction
err error
}{
"empty": {
input: "",
want: transaction{},
err: io.EOF,
},
"white space": {
input: " ",
want: transaction{},
err: io.EOF,
},
"verbose": {
input: `
2003-04-05 Reasoning here
A:B $1.00
C:D $-1.00
`,
want: transaction{
date: "2003-04-05",
description: "Reasoning here",
payee: "A:B",
recipients: []transactionRecipient{
{
name: "C:D",
value: -1.0,
currency: "$",
},
},
},
err: io.EOF,
},
"multi send": {
input: `
2003-04-05 Reasoning here
A:B $1.00
A:B $2.00
C:D
`,
want: transaction{
date: "2003-04-05",
description: "Reasoning here",
payee: "C:D",
recipients: []transactionRecipient{
{
name: "A:B",
value: 1.0,
currency: "$",
},
{
name: "A:B",
value: 2.0,
currency: "$",
},
},
},
err: io.EOF,
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
r := bufio.NewReader(strings.NewReader(c.input))
got, err := readTransaction("", r)
if err != c.err {
t.Error(err)
}
if fmt.Sprintf("%+v", got) != fmt.Sprintf("%+v", c.want) {
t.Errorf("want\n\t%+v, got\n\t%+v", c.want, got)
}
})
}
}

160
src/view/chart.go Normal file
View File

@@ -0,0 +1,160 @@
package view
import (
"fmt"
"io"
"slices"
"strings"
"github.com/go-echarts/go-echarts/v2/charts"
"github.com/go-echarts/go-echarts/v2/opts"
)
type Chart interface {
AddX(interface{})
AddY(string, []int)
Render(io.Writer) error
Overlap(Chart)
}
func NewChart(name string) Chart {
switch name {
case "line":
return NewLine()
case "bar":
return NewBar()
case "stack":
return NewStack()
case "pie":
return NewPie()
default:
panic("bad chart name " + name)
}
}
type Line struct {
*charts.Line
}
func NewLine() Line {
return Line{Line: charts.NewLine()}
}
func (line Line) AddX(v interface{}) {
line.SetXAxis(v)
}
func (line Line) AddY(name string, v []int) {
y := make([]opts.LineData, len(v))
for i := range y {
y[i].Value = v[i]
}
line.AddSeries(name, y).
SetSeriesOptions(charts.WithBarChartOpts(opts.BarChart{
Stack: "stackB",
}))
}
func (line Line) Overlap(other Chart) {
overlapper, ok := other.(charts.Overlaper)
if !ok {
panic(fmt.Sprintf("cannot overlap %T", other))
}
line.Line.Overlap(overlapper)
}
type Bar struct {
*charts.Bar
}
func NewBar() Bar {
return Bar{Bar: charts.NewBar()}
}
func (bar Bar) AddX(v interface{}) {
bar.SetXAxis(v)
}
func (bar Bar) AddY(name string, v []int) {
y := make([]opts.BarData, len(v))
for i := range v {
y[i].Value = v[i]
}
bar.AddSeries(name, y)
}
func (bar Bar) Overlap(other Chart) {
overlapper, ok := other.(charts.Overlaper)
if !ok {
panic(fmt.Sprintf("cannot overlap %T", other))
}
bar.Bar.Overlap(overlapper)
}
type Stack struct {
Bar
}
func NewStack() Stack {
bar := NewBar()
bar.SetSeriesOptions(charts.WithBarChartOpts(opts.BarChart{Stack: "x"}))
return Stack{Bar: bar}
}
func (stack Stack) AddY(name string, v []int) {
y := make([]opts.BarData, len(v))
for i := range v {
y[i].Value = v[i]
}
stack.AddSeries(name, y).
SetSeriesOptions(charts.WithBarChartOpts(opts.BarChart{
Stack: "stackA",
}))
}
type Pie struct {
*charts.Pie
series []opts.PieData
}
func NewPie() *Pie {
return &Pie{Pie: charts.NewPie()}
}
func (pie *Pie) AddX(v interface{}) {
}
func (pie *Pie) Render(w io.Writer) error {
slices.SortFunc(pie.series, func(a, b opts.PieData) int {
return strings.Compare(a.Name, b.Name)
})
commonPrefixLen := -1
for i := 0; i < len(pie.series[0].Name) && i < len(pie.series[len(pie.series)-1].Name); i++ {
if pie.series[0].Name[i] != pie.series[len(pie.series)-1].Name[i] {
break
}
commonPrefixLen = i
}
for i := range pie.series {
pie.series[i].Name = pie.series[i].Name[commonPrefixLen+1:]
}
pie.AddSeries("", pie.series)
pie.SetGlobalOptions(charts.WithLegendOpts(opts.Legend{
Show: false,
}))
return pie.Pie.Render(w)
}
func (pie *Pie) AddY(name string, v []int) {
for _, v := range v {
pie.series = append(pie.series, opts.PieData{
Name: fmt.Sprintf("%s ($%d)", name, v),
Value: v,
})
}
}
func (pie *Pie) Overlap(other Chart) {
panic("nope")
}

View File

@@ -1,8 +1,15 @@
todo:
- cicd in gitea is hard to get it to build a docker image tho
- cicd on not gogs,gitness but gitea; registry-app.inhome.blapointe.com:5001/docker
stuff
- html version can accept new transactions for moolah
- |
scale by salaries for proportional house contributions
if bel makes 190k and contributes $190
equal to z making 140k and contri $140
so contrib/sal = %of_me_contributed
... is that fair tho?
i think it is--if we both give N%, then 100% of house is covered, so we were hit equally
- ui can create transactions
- combine transactions in /transactions.html
- amend /transactions.html
- cicd including run
scheduled: []
done:
- todo: balances over time window
@@ -70,3 +77,14 @@ done:
ts: Fri Oct 27 20:37:12 MDT 2023
- todo: cicd on not gogs,gitness but gitea
ts: Fri Oct 27 22:22:04 MDT 2023
- todo: cicd in gitea is hard to get it to build a docker image tho
ts: Sat Oct 28 09:44:55 MDT 2023
- todo: cicd on not gogs,gitness but gitea; registry-app.inhome.blapointe.com:5001/docker
stuff
ts: Sat Oct 28 09:44:55 MDT 2023
- todo: services_docker including gitea cicd
ts: Sat Oct 28 10:10:08 MDT 2023
- todo: go embed cmd/http/public dir?
ts: Sat Oct 28 10:40:50 MDT 2023
- todo: html version can accept new transactions for moolah
ts: Sun Oct 29 10:11:50 MDT 2023

21
vendor/github.com/go-echarts/go-echarts/v2/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019~now chenjiandongx
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,29 @@
package actions
import (
"math/rand"
"time"
)
func init() {
rand.Seed(time.Now().UnixNano())
}
// Type kind of dispatch action
type Type string
// Areas means select-boxes. Multi-boxes can be specified.
// If Areas is empty, all of the select-boxes will be deleted.
// The first area.
type Areas struct {
//BrushType Optional: 'polygon', 'rect', 'lineX', 'lineY'
BrushType string `json:"brushType,omitempty"`
// CoordRange Only for "coordinate system area", define the area with the
// coordinates.
CoordRange []string `json:"coordRange,omitempty"`
// XAxisIndex Assigns which of the xAxisIndex can use Area selecting.
XAxisIndex interface{} `json:"xAxisIndex,omitempty"`
}

View File

@@ -0,0 +1,63 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Bar represents a bar chart.
type Bar struct {
RectChart
isXYReversal bool
}
// Type returns the chart type.
func (*Bar) Type() string { return types.ChartBar }
// NewBar creates a new bar chart instance.
func NewBar() *Bar {
c := &Bar{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasXYAxis = true
return c
}
// EnablePolarType enables the polar bar.
func (c *Bar) EnablePolarType() *Bar {
c.hasXYAxis = false
c.hasPolar = true
return c
}
// SetXAxis sets the X axis.
func (c *Bar) SetXAxis(x interface{}) *Bar {
c.xAxisData = x
return c
}
// AddSeries adds the new series.
func (c *Bar) AddSeries(name string, data []opts.BarData, options ...SeriesOpts) *Bar {
series := SingleSeries{Name: name, Type: types.ChartBar, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// XYReversal checks if X axis and Y axis are reversed.
func (c *Bar) XYReversal() *Bar {
c.isXYReversal = true
return c
}
// Validate validates the given configuration.
func (c *Bar) Validate() {
c.XAxisList[0].Data = c.xAxisData
if c.isXYReversal {
c.YAxisList[0].Data = c.xAxisData
c.XAxisList[0].Data = nil
}
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,30 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Bar3D represents a 3D bar chart.
type Bar3D struct {
Chart3D
}
// Type returns the chart type.
func (*Bar3D) Type() string { return types.ChartBar3D }
// NewBar3D creates a new 3D bar chart.
func NewBar3D() *Bar3D {
c := &Bar3D{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.initChart3D()
return c
}
// AddSeries adds the new series.
func (c *Bar3D) AddSeries(name string, data []opts.Chart3DData, options ...SeriesOpts) *Bar3D {
c.addSeries(types.ChartBar3D, name, data, options...)
return c
}

View File

@@ -0,0 +1,430 @@
package charts
import (
"bytes"
"encoding/json"
"html/template"
"github.com/go-echarts/go-echarts/v2/actions"
"github.com/go-echarts/go-echarts/v2/datasets"
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
)
// GlobalOpts sets the Global options for charts.
type GlobalOpts func(bc *BaseConfiguration)
// GlobalActions sets the Global actions for charts
type GlobalActions func(ba *BaseActions)
// BaseConfiguration represents an option set needed by all chart types.
type BaseConfiguration struct {
opts.Legend `json:"legend"`
opts.Tooltip `json:"tooltip"`
opts.Toolbox `json:"toolbox"`
opts.Title `json:"title"`
opts.Polar `json:"polar"`
opts.AngleAxis `json:"angleAxis"`
opts.RadiusAxis `json:"radiusAxis"`
opts.Brush `json:"brush"`
*opts.AxisPointer `json:"axisPointer"`
render.Renderer `json:"-"`
opts.Initialization `json:"-"`
opts.Assets `json:"-"`
opts.RadarComponent `json:"-"`
opts.GeoComponent `json:"-"`
opts.ParallelComponent `json:"-"`
opts.JSFunctions `json:"-"`
opts.SingleAxis `json:"-"`
MultiSeries
XYAxis
opts.XAxis3D
opts.YAxis3D
opts.ZAxis3D
opts.Grid3D
opts.Grid
legends []string
// Colors is the color list of palette.
// If no color is set in series, the colors would be adopted sequentially and circularly
// from this list as the colors of series.
Colors []string
appendColor []string // append customize color to the Colors(reverse order)
// Animation whether enable the animation, default true
Animation bool `json:"animation" default:"true"`
// Array of datasets, managed by AddDataset()
DatasetList []opts.Dataset `json:"dataset,omitempty"`
DataZoomList []opts.DataZoom `json:"datazoom,omitempty"`
VisualMapList []opts.VisualMap `json:"visualmap,omitempty"`
// ParallelAxisList represents the component list which is the coordinate axis for parallel coordinate.
ParallelAxisList []opts.ParallelAxis
has3DAxis bool
hasXYAxis bool
hasGeo bool
hasRadar bool
hasParallel bool
hasSingleAxis bool
hasPolar bool
hasBrush bool
GridList []opts.Grid `json:"grid,omitempty"`
}
// BaseActions represents a dispatchAction set needed by all chart types.
type BaseActions struct {
actions.Type `json:"type,omitempty"`
actions.Areas `json:"areas,omitempty"`
}
// JSON wraps all the options to a map so that it could be used in the base template
//
// Get data in bytes
// bs, _ : = json.Marshal(bar.JSON())
func (bc *BaseConfiguration) JSON() map[string]interface{} {
return bc.json()
}
// JSONNotEscaped works like method JSON, but it returns a marshaled object whose characters will not be escaped in the template
func (bc *BaseConfiguration) JSONNotEscaped() template.HTML {
obj := bc.json()
buff := bytes.NewBufferString("")
enc := json.NewEncoder(buff)
enc.SetEscapeHTML(false)
enc.Encode(obj)
return template.HTML(buff.String())
}
// JSONNotEscapedAction works like method JSON, but it returns a marshaled object whose characters will not be escaped in the template
func (ba *BaseActions) JSONNotEscapedAction() template.HTML {
obj := ba.json()
buff := bytes.NewBufferString("")
enc := json.NewEncoder(buff)
enc.SetEscapeHTML(false)
enc.Encode(obj)
return template.HTML(buff.String())
}
func (bc *BaseConfiguration) json() map[string]interface{} {
obj := map[string]interface{}{
"title": bc.Title,
"legend": bc.Legend,
"animation": bc.Animation,
"tooltip": bc.Tooltip,
"series": bc.MultiSeries,
}
// if only one item, use it directly instead of an Array
if len(bc.DatasetList) == 1 {
obj["dataset"] = bc.DatasetList[0]
} else if len(bc.DatasetList) > 1 {
obj["dataset"] = bc.DatasetList
}
if bc.AxisPointer != nil {
obj["axisPointer"] = bc.AxisPointer
}
if bc.hasPolar {
obj["polar"] = bc.Polar
obj["angleAxis"] = bc.AngleAxis
obj["radiusAxis"] = bc.RadiusAxis
}
if bc.hasGeo {
obj["geo"] = bc.GeoComponent
}
if bc.hasRadar {
obj["radar"] = bc.RadarComponent
}
if bc.hasParallel {
obj["parallel"] = bc.ParallelComponent
obj["parallelAxis"] = bc.ParallelAxisList
}
if bc.hasSingleAxis {
obj["singleAxis"] = bc.SingleAxis
}
if bc.Toolbox.Show {
obj["toolbox"] = bc.Toolbox
}
if len(bc.DataZoomList) > 0 {
obj["dataZoom"] = bc.DataZoomList
}
if len(bc.VisualMapList) > 0 {
obj["visualMap"] = bc.VisualMapList
}
if bc.hasXYAxis {
obj["xAxis"] = bc.XAxisList
obj["yAxis"] = bc.YAxisList
}
if bc.has3DAxis {
obj["xAxis3D"] = bc.XAxis3D
obj["yAxis3D"] = bc.YAxis3D
obj["zAxis3D"] = bc.ZAxis3D
obj["grid3D"] = bc.Grid3D
}
if bc.Theme == "white" {
obj["color"] = bc.Colors
}
if bc.BackgroundColor != "" {
obj["backgroundColor"] = bc.BackgroundColor
}
if len(bc.GridList) > 0 {
obj["grid"] = bc.GridList
}
if bc.hasBrush {
obj["brush"] = bc.Brush
}
return obj
}
// GetAssets returns the Assets options.
func (bc *BaseConfiguration) GetAssets() opts.Assets {
return bc.Assets
}
// AddDataset adds a Dataset to this chart
func (bc *BaseConfiguration) AddDataset(dataset ...opts.Dataset) {
bc.DatasetList = append(bc.DatasetList, dataset...)
}
// FillDefaultValues fill default values for chart options.
func (bc *BaseConfiguration) FillDefaultValues() {
opts.SetDefaultValue(bc)
}
func (bc *BaseConfiguration) initBaseConfiguration() {
bc.initSeriesColors()
bc.InitAssets()
bc.initXYAxis()
bc.Initialization.Validate()
bc.FillDefaultValues()
}
func (bc *BaseConfiguration) initSeriesColors() {
bc.Colors = []string{
"#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de",
"#3ba272", "#fc8452", "#9a60b4", "#ea7ccc",
}
}
func (bc *BaseConfiguration) insertSeriesColors(colors []string) {
reversed := reverseSlice(colors)
for i := 0; i < len(reversed); i++ {
bc.Colors = append(bc.Colors, "")
copy(bc.Colors[1:], bc.Colors[0:])
bc.Colors[0] = reversed[i]
}
}
func (bc *BaseConfiguration) setBaseGlobalOptions(opts ...GlobalOpts) {
for _, opt := range opts {
opt(bc)
}
}
func (ba *BaseActions) setBaseGlobalActions(opts ...GlobalActions) {
for _, opt := range opts {
opt(ba)
}
}
func (ba *BaseActions) json() map[string]interface{} {
obj := map[string]interface{}{
"type": ba.Type,
"areas": ba.Areas,
}
return obj
}
// WithAreas sets the areas of the action
func WithAreas(act actions.Areas) GlobalActions {
return func(ba *BaseActions) {
ba.Areas = act
}
}
// WithType sets the type of the action
func WithType(act actions.Type) GlobalActions {
return func(ba *BaseActions) {
ba.Type = act
}
}
// WithAngleAxisOps sets the angle of the axis.
func WithAngleAxisOps(opt opts.AngleAxis) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.AngleAxis = opt
}
}
// WithRadiusAxisOps sets the radius of the axis.
func WithRadiusAxisOps(opt opts.RadiusAxis) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.RadiusAxis = opt
}
}
// WithBrush sets the Brush.
func WithBrush(opt opts.Brush) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.hasBrush = true
bc.Brush = opt
}
}
// WithPolarOps sets the polar.
func WithPolarOps(opt opts.Polar) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Polar = opt
}
}
// WithTitleOpts sets the title.
func WithTitleOpts(opt opts.Title) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Title = opt
}
}
// WithAnimation enable or disable the animation.
func WithAnimation() GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Animation = false
}
}
// WithToolboxOpts sets the toolbox.
func WithToolboxOpts(opt opts.Toolbox) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Toolbox = opt
}
}
// WithSingleAxisOpts sets the single axis.
func WithSingleAxisOpts(opt opts.SingleAxis) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.SingleAxis = opt
}
}
// WithTooltipOpts sets the tooltip.
func WithTooltipOpts(opt opts.Tooltip) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Tooltip = opt
}
}
// WithLegendOpts sets the legend.
func WithLegendOpts(opt opts.Legend) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Legend = opt
}
}
// WithInitializationOpts sets the initialization.
func WithInitializationOpts(opt opts.Initialization) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Initialization = opt
if bc.Initialization.Theme != "" &&
bc.Initialization.Theme != "white" &&
bc.Initialization.Theme != "dark" {
bc.JSAssets.Add("themes/" + opt.Theme + ".js")
}
bc.Initialization.Validate()
}
}
// WithDataZoomOpts sets the list of the zoom data.
func WithDataZoomOpts(opt ...opts.DataZoom) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.DataZoomList = append(bc.DataZoomList, opt...)
}
}
// WithVisualMapOpts sets the List of the visual map.
func WithVisualMapOpts(opt ...opts.VisualMap) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.VisualMapList = append(bc.VisualMapList, opt...)
}
}
// WithRadarComponentOpts sets the component of the radar.
func WithRadarComponentOpts(opt opts.RadarComponent) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.RadarComponent = opt
}
}
// WithGeoComponentOpts sets the geo component.
func WithGeoComponentOpts(opt opts.GeoComponent) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.GeoComponent = opt
bc.JSAssets.Add("maps/" + datasets.MapFileNames[opt.Map] + ".js")
}
}
// WithParallelComponentOpts sets the parallel component.
func WithParallelComponentOpts(opt opts.ParallelComponent) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.ParallelComponent = opt
}
}
// WithParallelAxisList sets the list of the parallel axis.
func WithParallelAxisList(opt []opts.ParallelAxis) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.ParallelAxisList = opt
}
}
// WithColorsOpts sets the color.
func WithColorsOpts(opt opts.Colors) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.insertSeriesColors(opt)
}
}
// reverseSlice reverses the string slice.
func reverseSlice(s []string) []string {
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}
return s
}
// WithGridOpts sets the List of the grid.
func WithGridOpts(opt ...opts.Grid) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.GridList = append(bc.GridList, opt...)
}
}
// WithAxisPointerOpts sets the axis pointer.
func WithAxisPointerOpts(opt *opts.AxisPointer) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.AxisPointer = opt
}
}

View File

@@ -0,0 +1,44 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// BoxPlot represents a boxplot chart.
type BoxPlot struct {
RectChart
}
// Type returns the chart type.
func (*BoxPlot) Type() string { return types.ChartBoxPlot }
// NewBoxPlot creates a new boxplot chart.
func NewBoxPlot() *BoxPlot {
c := &BoxPlot{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasXYAxis = true
return c
}
// SetXAxis adds the X axis.
func (c *BoxPlot) SetXAxis(x interface{}) *BoxPlot {
c.xAxisData = x
return c
}
// AddSeries adds the new series.
func (c *BoxPlot) AddSeries(name string, data []opts.BoxPlotData, options ...SeriesOpts) *BoxPlot {
series := SingleSeries{Name: name, Type: types.ChartBoxPlot, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// Validate validates the given configuration.
func (c *BoxPlot) Validate() {
c.XAxisList[0].Data = c.xAxisData
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,67 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/types"
)
// Chart3D is a chart in 3D coordinates.
type Chart3D struct {
BaseConfiguration
}
// WithXAxis3DOpts sets the X axis of the Chart3D instance.
func WithXAxis3DOpts(opt opts.XAxis3D) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.XAxis3D = opt
}
}
// WithYAxis3DOpts sets the Y axis of the Chart3D instance.
func WithYAxis3DOpts(opt opts.YAxis3D) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.YAxis3D = opt
}
}
// WithZAxis3DOpts sets the Z axis of the Chart3D instance.
func WithZAxis3DOpts(opt opts.ZAxis3D) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.ZAxis3D = opt
}
}
// WithGrid3DOpts sets the grid of the Chart3D instance.
func WithGrid3DOpts(opt opts.Grid3D) GlobalOpts {
return func(bc *BaseConfiguration) {
bc.Grid3D = opt
}
}
func (c *Chart3D) initChart3D() {
c.JSAssets.Add(opts.CompatibleEchartsJS)
c.JSAssets.Add("echarts-gl.min.js")
c.has3DAxis = true
}
func (c *Chart3D) addSeries(chartType, name string, data []opts.Chart3DData, options ...SeriesOpts) {
series := SingleSeries{
Name: name,
Type: chartType,
Data: data,
CoordSystem: types.ChartCartesian3D,
}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
}
// SetGlobalOptions sets options for the Chart3D instance.
func (c *Chart3D) SetGlobalOptions(options ...GlobalOpts) *Chart3D {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// Validate validates the given configuration.
func (c *Chart3D) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,44 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// EffectScatter represents an effect scatter chart.
type EffectScatter struct {
RectChart
}
// Type returns the chart type.
func (*EffectScatter) Type() string { return types.ChartEffectScatter }
// NewEffectScatter creates a new effect scatter chart.
func NewEffectScatter() *EffectScatter {
c := &EffectScatter{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasXYAxis = true
return c
}
// SetXAxis adds the X axis.
func (c *EffectScatter) SetXAxis(x interface{}) *EffectScatter {
c.xAxisData = x
return c
}
// AddSeries adds the Y axis.
func (c *EffectScatter) AddSeries(name string, data []opts.EffectScatterData, options ...SeriesOpts) *EffectScatter {
series := SingleSeries{Name: name, Type: types.ChartEffectScatter, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// Validate validates the given configuration.
func (c *EffectScatter) Validate() {
c.XAxisList[0].Data = c.xAxisData
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,49 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Funnel represents a funnel chart.
type Funnel struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*Funnel) Type() string { return types.ChartFunnel }
// NewFunnel creates a new funnel chart.
func NewFunnel() *Funnel {
c := &Funnel{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
return c
}
// AddSeries adds new data sets.
func (c *Funnel) AddSeries(name string, data []opts.FunnelData, options ...SeriesOpts) *Funnel {
series := SingleSeries{Name: name, Type: types.ChartFunnel, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the Funnel instance.
func (c *Funnel) SetGlobalOptions(options ...GlobalOpts) *Funnel {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Gauge instance.
func (c *Funnel) SetDispatchActions(actions ...GlobalActions) *Funnel {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Funnel) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,49 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Gauge represents a gauge chart.
type Gauge struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*Gauge) Type() string { return types.ChartGauge }
// NewGauge creates a new gauge chart.
func NewGauge() *Gauge {
c := &Gauge{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
return c
}
// AddSeries adds new data sets.
func (c *Gauge) AddSeries(name string, data []opts.GaugeData, options ...SeriesOpts) *Gauge {
series := SingleSeries{Name: name, Type: types.ChartGauge, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the Gauge instance.
func (c *Gauge) SetGlobalOptions(options ...GlobalOpts) *Gauge {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Gauge instance.
func (c *Gauge) SetDispatchActions(actions ...GlobalActions) *Gauge {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Gauge) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,75 @@
package charts
import (
"log"
"github.com/go-echarts/go-echarts/v2/datasets"
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Geo represents a geo chart.
type Geo struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*Geo) Type() string { return types.ChartGeo }
var geoFormatter = `function (params) {
return params.name + ' : ' + params.value[2];
}`
// NewGeo creates a new geo chart.
func NewGeo() *Geo {
c := &Geo{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasGeo = true
return c
}
// AddSeries adds new data sets.
// geoType options:
// * types.ChartScatter
// * types.ChartEffectScatter
// * types.ChartHeatMap
func (c *Geo) AddSeries(name, geoType string, data []opts.GeoData, options ...SeriesOpts) *Geo {
series := SingleSeries{Name: name, Type: geoType, Data: data, CoordSystem: types.ChartGeo}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
func (c *Geo) extendValue(region string, v float32) []float32 {
res := make([]float32, 0)
tv := datasets.Coordinates[region]
if tv == [2]float32{0, 0} {
log.Printf("goecharts: No coordinate is specified for %s\n", region)
} else {
res = append(tv[:], v)
}
return res
}
// SetGlobalOptions sets options for the Geo instance.
func (c *Geo) SetGlobalOptions(options ...GlobalOpts) *Geo {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Geo instance.
func (c *Geo) SetDispatchActions(actions ...GlobalActions) *Geo {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Geo) Validate() {
if c.Tooltip.Formatter == "" {
c.Tooltip.Formatter = opts.FuncOpts(geoFormatter)
}
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,55 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Graph represents a graph chart.
type Graph struct {
BaseConfiguration
BaseActions
}
// Type returns the chart type.
func (*Graph) Type() string { return types.ChartGraph }
// NewGraph creates a new graph chart.
func NewGraph() *Graph {
chart := new(Graph)
chart.initBaseConfiguration()
chart.Renderer = render.NewChartRender(chart, chart.Validate)
return chart
}
// AddSeries adds the new series.
func (c *Graph) AddSeries(name string, nodes []opts.GraphNode, links []opts.GraphLink, options ...SeriesOpts) *Graph {
series := SingleSeries{Name: name, Type: types.ChartGraph, Links: links, Data: nodes}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// SetGlobalOptions sets options for the Graph instance.
func (c *Graph) SetGlobalOptions(options ...GlobalOpts) *Graph {
c.BaseConfiguration.setBaseGlobalOptions(options...)
return c
}
// SetDispatchActions sets actions for the Graph instance.
func (c *Graph) SetDispatchActions(actions ...GlobalActions) *Graph {
c.BaseActions.setBaseGlobalActions(actions...)
return c
}
// Validate validates the given configuration.
func (c *Graph) Validate() {
// If there is no layout setting, default layout is set to "force".
for i := 0; i < len(c.MultiSeries); i++ {
if c.MultiSeries[i].Layout == "" {
c.MultiSeries[i].Layout = "force"
}
}
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,43 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// HeatMap represents a heatmap chart.
type HeatMap struct {
RectChart
}
// Type returns the chart type.
func (*HeatMap) Type() string { return types.ChartHeatMap }
// NewHeatMap creates a new heatmap chart.
func NewHeatMap() *HeatMap {
c := &HeatMap{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasXYAxis = true
return c
}
// SetXAxis adds the X axis.
func (c *HeatMap) SetXAxis(x interface{}) *HeatMap {
c.xAxisData = x
return c
}
// AddSeries adds the new series.
func (c *HeatMap) AddSeries(name string, data []opts.HeatMapData, options ...SeriesOpts) *HeatMap {
series := SingleSeries{Name: name, Type: types.ChartHeatMap, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// Validate validates the given configuration.
func (c *HeatMap) Validate() {
c.Assets.Validate(c.AssetsHost)
}

View File

@@ -0,0 +1,44 @@
package charts
import (
"github.com/go-echarts/go-echarts/v2/opts"
"github.com/go-echarts/go-echarts/v2/render"
"github.com/go-echarts/go-echarts/v2/types"
)
// Kline represents a kline chart.
type Kline struct {
RectChart
}
// Type returns the chart type.
func (*Kline) Type() string { return types.ChartKline }
// NewKLine creates a new kline chart.
func NewKLine() *Kline {
c := &Kline{}
c.initBaseConfiguration()
c.Renderer = render.NewChartRender(c, c.Validate)
c.hasXYAxis = true
return c
}
// SetXAxis adds the X axis.
func (c *Kline) SetXAxis(xAxis interface{}) *Kline {
c.xAxisData = xAxis
return c
}
// AddSeries adds the new series.
func (c *Kline) AddSeries(name string, data []opts.KlineData, options ...SeriesOpts) *Kline {
series := SingleSeries{Name: name, Type: types.ChartKline, Data: data}
series.ConfigureSeriesOpts(options...)
c.MultiSeries = append(c.MultiSeries, series)
return c
}
// Validate validates the given configuration.
func (c *Kline) Validate() {
c.XAxisList[0].Data = c.xAxisData
c.Assets.Validate(c.AssetsHost)
}

Some files were not shown because too many files have changed in this diff Show More