Compare commits
86 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
320954282a | ||
|
|
9e1d8a753b | ||
|
|
3d4def59d6 | ||
|
|
da18c3d850 | ||
|
|
cb3fd34f90 | ||
|
|
593892bc2b | ||
|
|
bbcd3881db | ||
|
|
ee53546c1b | ||
|
|
66c27b8df4 | ||
|
|
03e027e089 | ||
|
|
b7799aaa8e | ||
|
|
094128fef1 | ||
|
|
a5212f0f5e | ||
|
|
62d5df795b | ||
|
|
3cae528dfd | ||
|
|
12380dc8ad | ||
|
|
0bc0d87768 | ||
|
|
e13c3d9271 | ||
|
|
1e0635b31a | ||
|
|
bddaab0192 | ||
|
|
dc3147c101 | ||
|
|
2681dd96b1 | ||
|
|
a947db92b2 | ||
|
|
778098ebb0 | ||
|
|
ca39b26070 | ||
|
|
b462bc8f8c | ||
|
|
73ecf0b912 | ||
|
|
cdaed495b3 | ||
|
|
651028a9f3 | ||
|
|
c4fb9a54a2 | ||
|
|
9af355fc59 | ||
|
|
773cd0da71 | ||
|
|
5da34c4609 | ||
|
|
957584b69c | ||
|
|
d0a15b8a98 | ||
|
|
9956a9540e | ||
|
|
8c1a7af37f | ||
|
|
c5704ffd45 | ||
|
|
8372e26864 | ||
|
|
6477c0f766 | ||
|
|
2a8bb57c9c | ||
|
|
2f432ec0c3 | ||
|
|
e3269e8981 | ||
|
|
8f891b8d8c | ||
|
|
775921092c | ||
|
|
83e2bfceb8 | ||
|
|
87a40aafeb | ||
|
|
a681e73fea | ||
|
|
d3f9be15f1 | ||
|
|
115f792198 | ||
|
|
e4ac5c87e4 | ||
|
|
a4fef176e8 | ||
|
|
ee5fc2be38 | ||
|
|
28524b3f08 | ||
|
|
bcbb37a146 | ||
|
|
de53a50e45 | ||
|
|
32e647f0fb | ||
|
|
4ebc08e5a4 | ||
|
|
ee162bbef7 | ||
|
|
df391e0a14 | ||
|
|
6182a62573 | ||
|
|
981a1cb2ee | ||
|
|
e0d8499a8c | ||
|
|
483d67846c | ||
|
|
e9c8897eaf | ||
|
|
9e09931c0e | ||
|
|
98f3f172a9 | ||
|
|
0e15bda6eb | ||
|
|
8f356656fc | ||
|
|
6e59fdb369 | ||
|
|
457247da8e | ||
|
|
41c991384a | ||
|
|
77f166a5f8 | ||
|
|
ac27a1c87f | ||
|
|
32748b0632 | ||
|
|
444155c103 | ||
|
|
8654a98e6e | ||
|
|
3dd67d3ed6 | ||
|
|
4efbb58197 | ||
|
|
94345ddc3a | ||
|
|
6212d57915 | ||
|
|
5f75e2e14f | ||
|
|
55f7cb1bc2 | ||
|
|
5ac3a808b2 | ||
|
|
30c19b9d2e | ||
|
|
34811d8fd8 |
22
.github/DISCUSSION_TEMPLATE/feature-requests.yml
vendored
Normal file
22
.github/DISCUSSION_TEMPLATE/feature-requests.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
title: Feature Request
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for your interest in Maybe! Please follow the template below to submit your feature request. You can visit our [roadmap](https://github.com/orgs/maybe-finance/projects/13) to see what's currently planned.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the feature
|
||||
description: Provide a clear and concise description of the feature you would like.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Why is this feature important?
|
||||
description: Tell us what specific problem(s) this feature solves for you or other users.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context, screenshots, and relevant links
|
||||
description: Provide additional info to help us evaluate whether this feature is a good fit for the product.
|
||||
7
.github/ISSUE_TEMPLATE/bug_report.md
vendored
7
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -2,16 +2,11 @@
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: 'Bug: '
|
||||
labels: ":bug: Bug, :rocket: Feature"
|
||||
labels: ":bug: Bug"
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Where did this bug occur?**
|
||||
|
||||
- [ ] Local development
|
||||
- [ ] Self hosted app (i.e. Docker)
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
|
||||
33
.github/ISSUE_TEMPLATE/feature-specification.md
vendored
33
.github/ISSUE_TEMPLATE/feature-specification.md
vendored
@@ -1,33 +0,0 @@
|
||||
---
|
||||
name: Feature Specification
|
||||
about: A fully scoped feature with designs, requirements, and implementation plan
|
||||
title: 'Feature: '
|
||||
labels: ":rocket: Feature"
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
_If your feature requires designs, UI changes, or input from the Maybe team, you should open a feature request instead. This template is for a **fully scoped and ready** feature. _
|
||||
|
||||
## Feature Overview
|
||||
|
||||
## Requirements
|
||||
|
||||
_If there is a missing / incorrect requirement, please leave a comment before starting work on this._
|
||||
|
||||
- [ ] Requirement 1
|
||||
|
||||
## Implementation Suggestions
|
||||
|
||||
_Below are some ideas for implementation to get you started. Use your best judgment here—if there's a better way to do things, go for it!_
|
||||
|
||||
## Designs
|
||||
|
||||
Below are the designs you should follow while implementing this:
|
||||
|
||||
## Reminders
|
||||
|
||||
- Make sure to review our [contributing guidelines](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md) before starting on an issue
|
||||
- We do our best to define a clear spec for new features and fixes, but think of them as "suggestions", not "hard requirements". We welcome ideas and suggestions!
|
||||
- If you see missing requirements to this issue, please leave a comment below explaining what is missing and why it is important.
|
||||
- If you see a requirement that you think is _incorrect_ or _not optimal_, please leave a comment explaining what you think needs to change below.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: 'Feature Request: '
|
||||
labels: ":bulb: Feature Request"
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
21
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Other
|
||||
about: All other issues
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**PLEASE READ before opening an issue:**
|
||||
|
||||
- Is this a feature request? Please [open a feature request discussion](https://github.com/maybe-finance/maybe/discussions/new?category=feature-requests).
|
||||
- Do you need help or have a question? Please [open a discussion](https://github.com/maybe-finance/maybe/discussions/new/choose) or [join our Discord](https://link.maybe.co/discord) and post to the "help" channel.
|
||||
|
||||
----------------------
|
||||
|
||||
**Is this issue related to a problem? Please describe.**
|
||||
|
||||
**Describe the work that needs to be done to address this issue**
|
||||
|
||||
**Additional context**
|
||||
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
scan_ruby:
|
||||
@@ -59,6 +59,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
||||
RAILS_ENV: test
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
@@ -82,16 +86,19 @@ jobs:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: Run tests and smoke test seed
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
||||
- name: DB setup and smoke test
|
||||
run: |
|
||||
bin/rails db:create
|
||||
bin/rails db:schema:load
|
||||
bin/rails test
|
||||
bin/rails db:seed
|
||||
|
||||
- name: Unit and integration tests
|
||||
run: bin/rails test
|
||||
|
||||
- name: System tests
|
||||
run: DISABLE_PARALLELIZATION=true bin/rails test:system
|
||||
continue-on-error: true # TODO: Eventually we'll enforce for PRs
|
||||
|
||||
- name: Keep screenshots from failed system tests
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
|
||||
8
.github/workflows/pr.yml
vendored
Normal file
8
.github/workflows/pr.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
name: Pull Request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
54
.github/workflows/publish.yml
vendored
54
.github/workflows/publish.yml
vendored
@@ -15,56 +15,12 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Install packages
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: Run tests and smoke test seed
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
||||
run: |
|
||||
bin/rails db:create
|
||||
bin/rails db:schema:load
|
||||
bin/rails test:all
|
||||
bin/rails db:seed
|
||||
|
||||
- name: Keep screenshots from failed system tests
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: screenshots
|
||||
path: ${{ github.workspace }}/tmp/screenshots
|
||||
if-no-files-found: ignore
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
build:
|
||||
name: Build docker image
|
||||
needs: [ test ]
|
||||
needs: [ ci ]
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -102,14 +58,14 @@ jobs:
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
|
||||
3
Gemfile
3
Gemfile
@@ -25,9 +25,6 @@ gem "turbo-rails"
|
||||
# Background Jobs
|
||||
gem "good_job"
|
||||
|
||||
# Search
|
||||
gem "ransack", github: "maybe-finance/ransack", branch: "main"
|
||||
|
||||
# Error logging
|
||||
gem "stackprof"
|
||||
gem "sentry-ruby"
|
||||
|
||||
224
Gemfile.lock
224
Gemfile.lock
@@ -1,48 +1,38 @@
|
||||
GIT
|
||||
remote: https://github.com/maybe-finance/lucide-rails.git
|
||||
revision: 6170b3a0eceb43a8af6552638e9526673c356d0d
|
||||
revision: 79d989593ee4ac6c50106ec5e4d2bd4ec8f5af87
|
||||
specs:
|
||||
lucide-rails (0.2.0)
|
||||
railties (>= 4.1.0)
|
||||
|
||||
GIT
|
||||
remote: https://github.com/maybe-finance/ransack.git
|
||||
revision: dec20edc9ccccac77f5b4b8a1c1a9f20dc58fa04
|
||||
branch: main
|
||||
specs:
|
||||
ransack (4.1.1)
|
||||
activerecord (>= 6.1.5)
|
||||
activesupport (>= 6.1.5)
|
||||
i18n
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: fb4300ce193c338e00c8fe3a8372dc594f6c5de8
|
||||
revision: 9e370f0243870b39ea9801eeb95498f3a0d7bd83
|
||||
branch: 7-2-stable
|
||||
specs:
|
||||
actioncable (7.2.0.alpha)
|
||||
actionpack (= 7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
actioncable (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.2.0.alpha)
|
||||
actionpack (= 7.2.0.alpha)
|
||||
activejob (= 7.2.0.alpha)
|
||||
activerecord (= 7.2.0.alpha)
|
||||
activestorage (= 7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
actionmailbox (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activestorage (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.0.alpha)
|
||||
actionpack (= 7.2.0.alpha)
|
||||
actionview (= 7.2.0.alpha)
|
||||
activejob (= 7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
actionmailer (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
actionview (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.0.alpha)
|
||||
actionview (= 7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
actionpack (7.2.0.beta2)
|
||||
actionview (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
@@ -51,60 +41,61 @@ GIT
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.0.alpha)
|
||||
actionpack (= 7.2.0.alpha)
|
||||
activerecord (= 7.2.0.alpha)
|
||||
activestorage (= 7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
actiontext (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activestorage (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
actionview (7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
activejob (7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
activerecord (7.2.0.alpha)
|
||||
activemodel (= 7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
activemodel (7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
activerecord (7.2.0.beta2)
|
||||
activemodel (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.2.0.alpha)
|
||||
actionpack (= 7.2.0.alpha)
|
||||
activejob (= 7.2.0.alpha)
|
||||
activerecord (= 7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
activestorage (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.0.alpha)
|
||||
activesupport (7.2.0.beta2)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1, < 5.22.0)
|
||||
logger
|
||||
minitest (>= 5.1)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
rails (7.2.0.alpha)
|
||||
actioncable (= 7.2.0.alpha)
|
||||
actionmailbox (= 7.2.0.alpha)
|
||||
actionmailer (= 7.2.0.alpha)
|
||||
actionpack (= 7.2.0.alpha)
|
||||
actiontext (= 7.2.0.alpha)
|
||||
actionview (= 7.2.0.alpha)
|
||||
activejob (= 7.2.0.alpha)
|
||||
activemodel (= 7.2.0.alpha)
|
||||
activerecord (= 7.2.0.alpha)
|
||||
activestorage (= 7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
rails (7.2.0.beta2)
|
||||
actioncable (= 7.2.0.beta2)
|
||||
actionmailbox (= 7.2.0.beta2)
|
||||
actionmailer (= 7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
actiontext (= 7.2.0.beta2)
|
||||
actionview (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activemodel (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activestorage (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.0.alpha)
|
||||
railties (7.2.0.alpha)
|
||||
actionpack (= 7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
railties (= 7.2.0.beta2)
|
||||
railties (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -118,17 +109,17 @@ GEM
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.930.0)
|
||||
aws-sdk-core (3.196.1)
|
||||
aws-partitions (1.944.0)
|
||||
aws-sdk-core (3.197.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.81.0)
|
||||
aws-sdk-core (~> 3, >= 3.193.0)
|
||||
aws-sdk-kms (1.84.0)
|
||||
aws-sdk-core (~> 3, >= 3.197.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.151.0)
|
||||
aws-sdk-core (~> 3, >= 3.194.0)
|
||||
aws-sdk-s3 (1.152.3)
|
||||
aws-sdk-core (~> 3, >= 3.197.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (1.8.0)
|
||||
@@ -148,7 +139,7 @@ GEM
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.1.2)
|
||||
racc
|
||||
builder (3.2.4)
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
addressable
|
||||
matrix
|
||||
@@ -160,7 +151,7 @@ GEM
|
||||
xpath (~> 3.2)
|
||||
childprocess (5.0.0)
|
||||
climate_control (1.2.0)
|
||||
concurrent-ruby (1.2.3)
|
||||
concurrent-ruby (1.3.3)
|
||||
connection_pool (2.4.1)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
@@ -184,10 +175,10 @@ GEM
|
||||
rainbow
|
||||
rubocop
|
||||
smart_properties
|
||||
erubi (1.12.0)
|
||||
erubi (1.13.0)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
faraday (2.9.0)
|
||||
faraday (2.9.2)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
faraday-net_http (3.1.0)
|
||||
net-http
|
||||
@@ -199,7 +190,7 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (3.28.2)
|
||||
good_job (3.29.4)
|
||||
activejob (>= 6.0.0)
|
||||
activerecord (>= 6.0.0)
|
||||
concurrent-ruby (>= 1.0.2)
|
||||
@@ -235,7 +226,7 @@ GEM
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.7.2)
|
||||
irb (1.13.1)
|
||||
irb (1.13.2)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
@@ -249,6 +240,7 @@ GEM
|
||||
listen (3.9.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.6.0)
|
||||
loofah (2.22.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
@@ -261,13 +253,13 @@ GEM
|
||||
matrix (0.4.2)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.21.2)
|
||||
mocha (2.2.0)
|
||||
minitest (5.24.0)
|
||||
mocha (2.4.0)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
net-http (0.4.1)
|
||||
uri
|
||||
net-imap (0.4.11)
|
||||
net-imap (0.4.14)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -277,42 +269,41 @@ GEM
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.5-aarch64-linux)
|
||||
nokogiri (1.16.6-aarch64-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-arm-linux)
|
||||
nokogiri (1.16.6-arm-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-arm64-darwin)
|
||||
nokogiri (1.16.6-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-x86-linux)
|
||||
nokogiri (1.16.6-x86-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-x86_64-darwin)
|
||||
nokogiri (1.16.6-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-x86_64-linux)
|
||||
nokogiri (1.16.6-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
octokit (8.1.0)
|
||||
base64
|
||||
octokit (9.1.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (8.4.0)
|
||||
pagy (8.4.5)
|
||||
parallel (1.24.0)
|
||||
parser (3.3.1.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.6)
|
||||
prism (0.27.0)
|
||||
propshaft (0.8.0)
|
||||
prism (0.29.0)
|
||||
propshaft (0.9.0)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.1.2)
|
||||
stringio
|
||||
public_suffix (5.0.5)
|
||||
public_suffix (5.1.0)
|
||||
puma (6.4.2)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.7.3)
|
||||
rack (3.0.11)
|
||||
racc (1.8.0)
|
||||
rack (3.1.4)
|
||||
rack-session (2.0.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
@@ -338,13 +329,13 @@ GEM
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
rdoc (6.6.3.1)
|
||||
rdoc (6.7.0)
|
||||
psych (>= 4.0.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.7)
|
||||
reline (0.5.9)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.2.8)
|
||||
strscan (>= 3.0.9)
|
||||
rexml (3.3.0)
|
||||
strscan
|
||||
rubocop (1.63.5)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
@@ -374,13 +365,12 @@ GEM
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.16.6)
|
||||
ruby-lsp (0.17.1)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 0.23.0, < 0.28)
|
||||
prism (>= 0.29.0, < 0.30)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.3.6)
|
||||
ruby-lsp (>= 0.16.5, < 0.17.0)
|
||||
sorbet-runtime (>= 0.5.9897)
|
||||
ruby-lsp-rails (0.3.7)
|
||||
ruby-lsp (>= 0.17.0, < 0.18.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.1)
|
||||
ffi (~> 1.12)
|
||||
@@ -389,8 +379,9 @@ GEM
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
selenium-webdriver (4.21.1)
|
||||
selenium-webdriver (4.22.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
@@ -407,23 +398,23 @@ GEM
|
||||
simplecov-html (0.12.3)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11383)
|
||||
sorbet-runtime (0.5.11406)
|
||||
stackprof (0.2.26)
|
||||
stimulus-rails (1.3.3)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.0)
|
||||
stringio (3.1.1)
|
||||
strscan (3.1.0)
|
||||
tailwindcss-rails (2.6.0)
|
||||
tailwindcss-rails (2.6.1)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-aarch64-linux)
|
||||
tailwindcss-rails (2.6.1-aarch64-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-arm-linux)
|
||||
tailwindcss-rails (2.6.1-arm-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-arm64-darwin)
|
||||
tailwindcss-rails (2.6.1-arm64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-x86_64-darwin)
|
||||
tailwindcss-rails (2.6.1-x86_64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.0-x86_64-linux)
|
||||
tailwindcss-rails (2.6.1-x86_64-linux)
|
||||
railties (>= 7.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
@@ -444,7 +435,7 @@ GEM
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webmock (3.23.0)
|
||||
webmock (3.23.1)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
@@ -455,7 +446,7 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.6.14)
|
||||
zeitwerk (2.6.16)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -494,7 +485,6 @@ DEPENDENCIES
|
||||
puma (>= 5.0)
|
||||
rails!
|
||||
rails-settings-cached
|
||||
ransack!
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
selenium-webdriver
|
||||
|
||||
@@ -35,6 +35,11 @@ There are 3 primary ways to use the Maybe app:
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
**If you are trying to _self-host_ the Maybe app, stop here. You
|
||||
should [read this guide to get started](docs/hosting/docker.md).**
|
||||
|
||||
The instructions below are for developers to get started with contributing to the app.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Ruby 3.3.1
|
||||
|
||||
@@ -7,43 +7,25 @@
|
||||
details > summary::-webkit-details-marker {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
details > summary {
|
||||
@apply list-none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.prose {
|
||||
table {
|
||||
@apply divide-y divide-gray-300;
|
||||
}
|
||||
|
||||
tr {
|
||||
@apply divide-x divide-gray-100;
|
||||
}
|
||||
|
||||
th {
|
||||
@apply whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900;
|
||||
}
|
||||
|
||||
tbody {
|
||||
@apply divide-y divide-gray-200;
|
||||
}
|
||||
|
||||
td {
|
||||
@apply px-2 py-2 text-sm text-gray-500 whitespace-nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.form-field {
|
||||
@apply relative border border-alpha-black-100 bg-white rounded-md shadow-xs;
|
||||
@apply focus-within:shadow-none focus-within:border-gray-900 focus-within:ring-4 focus-within:ring-gray-100;
|
||||
@apply relative rounded-md border bg-white border-alpha-black-100 shadow-xs;
|
||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
@apply px-3 pt-2 pb-0 block text-xs text-gray-500;
|
||||
@apply block px-3 pt-2 pb-0 text-xs text-gray-500;
|
||||
}
|
||||
|
||||
.form-field__input {
|
||||
@apply px-3 pb-2 pt-1 text-sm w-full bg-transparent border-none opacity-100;
|
||||
@apply focus:outline-none focus:ring-0 focus:opacity-100;
|
||||
@apply w-full border-none bg-transparent px-3 pt-1 pb-2 text-sm opacity-100;
|
||||
@apply focus:opacity-100 focus:outline-none focus:ring-0;
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:opacity-50;
|
||||
}
|
||||
@@ -53,12 +35,48 @@
|
||||
}
|
||||
|
||||
.form-field__submit {
|
||||
@apply w-full p-3 text-center text-white bg-black rounded-lg cursor-pointer hover:bg-gray-700;
|
||||
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||
}
|
||||
|
||||
input:checked + label + .toggle-switch-dot {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox {
|
||||
@apply rounded-sm;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--light {
|
||||
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark {
|
||||
@apply ring-gray-900 checked:text-white;
|
||||
}
|
||||
|
||||
[type='checkbox'].maybe-checkbox--dark:checked {
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
.maybe-switch {
|
||||
@apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
|
||||
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out;
|
||||
@apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
|
||||
}
|
||||
|
||||
.prose--github-release-notes {
|
||||
.octicon {
|
||||
@apply inline-block overflow-visible align-text-bottom fill-current;
|
||||
}
|
||||
|
||||
.dropdown-caret {
|
||||
@apply content-none border-4 border-b-0 border-transparent border-t-gray-500 size-0 inline-block;
|
||||
}
|
||||
|
||||
.user-mention {
|
||||
@apply font-bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Small, single purpose classes that should take precedence over other styles */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Accounts::LogosController < ApplicationController
|
||||
class Account::LogosController < ApplicationController
|
||||
def show
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
render_placeholder
|
||||
22
app/controllers/account/transaction/rows_controller.rb
Normal file
22
app/controllers/account/transaction/rows_controller.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class Account::Transaction::RowsController < ApplicationController
|
||||
before_action :set_transaction, only: %i[ show update ]
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def update
|
||||
@transaction.update! transaction_params
|
||||
|
||||
redirect_to account_transaction_row_path(@transaction.account, @transaction)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def transaction_params
|
||||
params.require(:transaction).permit(:category_id)
|
||||
end
|
||||
|
||||
def set_transaction
|
||||
@transaction = Current.family.accounts.find(params[:account_id]).transactions.find(params[:transaction_id])
|
||||
end
|
||||
end
|
||||
6
app/controllers/account/transaction/rules_controller.rb
Normal file
6
app/controllers/account/transaction/rules_controller.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class Account::Transaction::RulesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
def index
|
||||
end
|
||||
end
|
||||
47
app/controllers/account/transactions_controller.rb
Normal file
47
app/controllers/account/transactions_controller.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
class Account::TransactionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_transaction, only: %i[ show update destroy ]
|
||||
|
||||
def index
|
||||
@transactions = @account.transactions.ordered
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def update
|
||||
@transaction.update! transaction_params
|
||||
@transaction.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_transaction_path(@account, @transaction), notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(@transaction) }
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transaction.destroy!
|
||||
@transaction.sync_account_later
|
||||
redirect_back_or_to account_url(@transaction.account), notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_transaction
|
||||
@transaction = @account.transactions.find(params[:id])
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: [])
|
||||
end
|
||||
|
||||
def transaction_params
|
||||
params.require(:account_transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id, tag_ids: [])
|
||||
end
|
||||
end
|
||||
44
app/controllers/account/transfers_controller.rb
Normal file
44
app/controllers/account/transfers_controller.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
class Account::TransfersController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_transfer, only: :destroy
|
||||
|
||||
def new
|
||||
@transfer = Account::Transfer.new
|
||||
end
|
||||
|
||||
def create
|
||||
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
||||
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
||||
|
||||
@transfer = Account::Transfer.build_from_accounts from_account, to_account, \
|
||||
date: transfer_params[:date],
|
||||
amount: transfer_params[:amount].to_d,
|
||||
currency: transfer_params[:currency],
|
||||
name: transfer_params[:name]
|
||||
|
||||
if @transfer.save
|
||||
redirect_to transactions_path, notice: t(".success")
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:error] = @transfer.errors.full_messages.to_sentence
|
||||
redirect_to transactions_path
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transfer.destroy_and_remove_marks!
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_transfer
|
||||
@transfer = Account::Transfer.find(params[:id])
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
params.require(:account_transfer).permit(:from_account_id, :to_account_id, :amount, :currency, :date, :name)
|
||||
end
|
||||
end
|
||||
61
app/controllers/account/valuations_controller.rb
Normal file
61
app/controllers/account/valuations_controller.rb
Normal file
@@ -0,0 +1,61 @@
|
||||
class Account::ValuationsController < ApplicationController
|
||||
before_action :set_account
|
||||
before_action :set_valuation, only: %i[ show edit update destroy ]
|
||||
|
||||
def new
|
||||
@valuation = @account.valuations.new
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
@valuation = @account.valuations.build(valuation_params)
|
||||
|
||||
if @valuation.save
|
||||
@valuation.sync_account_later
|
||||
redirect_to account_path(@account), notice: "Valuation created"
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:error] = @valuation.errors.full_messages.to_sentence
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @valuation.update(valuation_params)
|
||||
@valuation.sync_account_later
|
||||
redirect_to account_path(@account), notice: t(".success")
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:error] = @valuation.errors.full_messages.to_sentence
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@valuation.destroy!
|
||||
@valuation.sync_account_later
|
||||
|
||||
redirect_to account_path(@account), notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_valuation
|
||||
@valuation = @account.valuations.find(params[:id])
|
||||
end
|
||||
|
||||
def valuation_params
|
||||
params.require(:account_valuation).permit(:date, :value, :currency)
|
||||
end
|
||||
end
|
||||
@@ -2,10 +2,12 @@ class AccountsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
include Filterable
|
||||
before_action :set_account, only: %i[ show update destroy sync ]
|
||||
before_action :set_account, only: %i[ edit show destroy sync update ]
|
||||
after_action :sync_account, only: :create
|
||||
|
||||
def index
|
||||
@accounts = Current.family.accounts
|
||||
@institutions = Current.family.institutions
|
||||
@accounts = Current.family.accounts.ungrouped.alphabetically
|
||||
end
|
||||
|
||||
def summary
|
||||
@@ -25,47 +27,33 @@ class AccountsController < ApplicationController
|
||||
balance: nil,
|
||||
accountable: Accountable.from_type(params[:type])&.new
|
||||
)
|
||||
|
||||
if params[:institution_id]
|
||||
@account.institution = Current.family.institutions.find_by(id: params[:institution_id])
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@balance_series = @account.series(period: @period)
|
||||
@valuation_series = @account.valuations.to_series
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @account.update(account_params.except(:accountable_type))
|
||||
|
||||
@account.sync_later if account_params[:is_active] == "1" && @account.can_sync?
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to accounts_path, notice: t(".success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: t(".success") } }),
|
||||
turbo_stream.replace("account_#{@account.id}", partial: "accounts/account", locals: { account: @account })
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
render "show", status: :unprocessable_entity
|
||||
end
|
||||
@account.update! account_params.except(:accountable_type)
|
||||
redirect_back_or_to account_path(@account), notice: t(".success")
|
||||
end
|
||||
|
||||
def create
|
||||
@account = Current.family.accounts.build(account_params.except(:accountable_type, :start_date))
|
||||
@account.accountable = Accountable.from_type(account_params[:accountable_type])&.new
|
||||
@account = Current.family
|
||||
.accounts
|
||||
.create_with_optional_start_balance! \
|
||||
attributes: account_params.except(:start_date, :start_balance),
|
||||
start_date: account_params[:start_date],
|
||||
start_balance: account_params[:start_balance]
|
||||
|
||||
if @account.save
|
||||
@valuation = @account.valuations.new(date: account_params[:start_date] || Date.today, value: @account.balance, currency: @account.currency)
|
||||
@valuation.save!
|
||||
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
else
|
||||
render "new", status: :unprocessable_entity
|
||||
end
|
||||
redirect_back_or_to account_path(@account), notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -74,14 +62,11 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def sync
|
||||
@account.sync_later if @account.can_sync?
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account), notice: t(".success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: t(".success") } })
|
||||
end
|
||||
unless @account.syncing?
|
||||
@account.sync_later
|
||||
end
|
||||
|
||||
redirect_to account_path(@account), notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
@@ -91,6 +76,10 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :currency, :subtype, :is_active)
|
||||
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id)
|
||||
end
|
||||
|
||||
def sync_account
|
||||
@account.sync_later
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,20 +2,8 @@ class ApplicationController < ActionController::Base
|
||||
include Authentication, Invitable, SelfHostable
|
||||
include Pagy::Backend
|
||||
|
||||
before_action :sync_accounts
|
||||
|
||||
default_form_builder ApplicationFormBuilder
|
||||
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
|
||||
private
|
||||
|
||||
def sync_accounts
|
||||
return if Current.user.blank?
|
||||
|
||||
if Current.user.last_login_at.nil? || Current.user.last_login_at.before?(Date.current.beginning_of_day)
|
||||
Current.family.sync_accounts
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
class Transactions::CategoriesController < ApplicationController
|
||||
class CategoriesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_category, only: %i[ edit update ]
|
||||
before_action :set_transaction, only: :create
|
||||
|
||||
def index
|
||||
@categories = Current.family.transaction_categories.alphabetically
|
||||
@categories = Current.family.categories.alphabetically
|
||||
end
|
||||
|
||||
def new
|
||||
@category = Current.family.transaction_categories.new color: Transaction::Category::COLORS.sample
|
||||
@category = Current.family.categories.new color: Category::COLORS.sample
|
||||
end
|
||||
|
||||
def create
|
||||
Transaction::Category.transaction do
|
||||
category = Current.family.transaction_categories.create!(category_params)
|
||||
Category.transaction do
|
||||
category = Current.family.categories.create!(category_params)
|
||||
@transaction.update!(category_id: category.id) if @transaction
|
||||
end
|
||||
|
||||
@@ -32,7 +32,7 @@ class Transactions::CategoriesController < ApplicationController
|
||||
|
||||
private
|
||||
def set_category
|
||||
@category = Current.family.transaction_categories.find(params[:id])
|
||||
@category = Current.family.categories.find(params[:id])
|
||||
end
|
||||
|
||||
def set_transaction
|
||||
@@ -42,6 +42,6 @@ class Transactions::CategoriesController < ApplicationController
|
||||
end
|
||||
|
||||
def category_params
|
||||
params.require(:transaction_category).permit(:name, :color)
|
||||
params.require(:category).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Transactions::Categories::DeletionsController < ApplicationController
|
||||
class Category::DeletionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_category
|
||||
@@ -15,12 +15,12 @@ class Transactions::Categories::DeletionsController < ApplicationController
|
||||
|
||||
private
|
||||
def set_category
|
||||
@category = Current.family.transaction_categories.find(params[:transaction_category_id])
|
||||
@category = Current.family.categories.find(params[:category_id])
|
||||
end
|
||||
|
||||
def set_replacement_category
|
||||
if params[:replacement_category_id].present?
|
||||
@replacement_category = Current.family.transaction_categories.find(params[:replacement_category_id])
|
||||
@replacement_category = Current.family.categories.find(params[:replacement_category_id])
|
||||
end
|
||||
end
|
||||
end
|
||||
22
app/controllers/category/dropdowns_controller.rb
Normal file
22
app/controllers/category/dropdowns_controller.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class Category::DropdownsController < ApplicationController
|
||||
before_action :set_from_params
|
||||
|
||||
def show
|
||||
@categories = categories_scope.to_a.excluding(@selected_category).prepend(@selected_category).compact
|
||||
end
|
||||
|
||||
private
|
||||
def set_from_params
|
||||
if params[:category_id]
|
||||
@selected_category = categories_scope.find(params[:category_id])
|
||||
end
|
||||
|
||||
if params[:transaction_id]
|
||||
@transaction = Current.family.transactions.find(params[:transaction_id])
|
||||
end
|
||||
end
|
||||
|
||||
def categories_scope
|
||||
Current.family.categories.alphabetically
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,8 @@ class ImportsController < ApplicationController
|
||||
end
|
||||
|
||||
def new
|
||||
@import = Import.new
|
||||
account = Current.family.accounts.find_by(id: params[:account_id])
|
||||
@import = Import.new account: account
|
||||
end
|
||||
|
||||
def edit
|
||||
|
||||
35
app/controllers/institutions_controller.rb
Normal file
35
app/controllers/institutions_controller.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class InstitutionsController < ApplicationController
|
||||
before_action :set_institution, except: %i[ new create ]
|
||||
|
||||
def new
|
||||
@institution = Institution.new
|
||||
end
|
||||
|
||||
def create
|
||||
Current.family.institutions.create!(institution_params)
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@institution.update!(institution_params)
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@institution.destroy!
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def institution_params
|
||||
params.require(:institution).permit(:name, :logo)
|
||||
end
|
||||
|
||||
def set_institution
|
||||
@institution = Current.family.institutions.find(params[:id])
|
||||
end
|
||||
end
|
||||
41
app/controllers/merchants_controller.rb
Normal file
41
app/controllers/merchants_controller.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
class MerchantsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_merchant, only: %i[ edit update destroy ]
|
||||
|
||||
def index
|
||||
@merchants = Current.family.merchants.alphabetically
|
||||
end
|
||||
|
||||
def new
|
||||
@merchant = Merchant.new
|
||||
end
|
||||
|
||||
def create
|
||||
Current.family.merchants.create!(merchant_params)
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@merchant.update!(merchant_params)
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@merchant.destroy!
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_merchant
|
||||
@merchant = Current.family.merchants.find(params[:id])
|
||||
end
|
||||
|
||||
def merchant_params
|
||||
params.require(:merchant).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
@@ -21,7 +21,7 @@ class PagesController < ApplicationController
|
||||
|
||||
@accounts = Current.family.accounts
|
||||
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
@transactions = Current.family.transactions.limit(5).order(date: :desc)
|
||||
@transactions = Current.family.transactions.limit(6).order(date: :desc)
|
||||
|
||||
# TODO: Placeholders for trendlines
|
||||
placeholder_series_data = 10.times.map do |i|
|
||||
@@ -31,6 +31,7 @@ class PagesController < ApplicationController
|
||||
end
|
||||
|
||||
def changelog
|
||||
@releases_notes = Provider::Github.new.fetch_latest_releases_notes
|
||||
end
|
||||
|
||||
def feedback
|
||||
|
||||
@@ -16,7 +16,7 @@ class RegistrationsController < ApplicationController
|
||||
@user.role = :admin
|
||||
|
||||
if @user.save
|
||||
Transaction::Category.create_default_categories(@user.family)
|
||||
Category.create_default_categories(@user.family)
|
||||
login @user
|
||||
flash[:notice] = t(".success")
|
||||
redirect_to root_path
|
||||
|
||||
@@ -47,7 +47,7 @@ class Settings::HostingsController < SettingsController
|
||||
end
|
||||
end
|
||||
|
||||
if hosting_params[:upgrades_mode] != "manual" && hosting_params[:render_deploy_hook].blank?
|
||||
if hosting_params[:upgrades_mode] == "auto" && hosting_params[:render_deploy_hook].blank?
|
||||
@errors.add(:render_deploy_hook, t("settings.hostings.update.render_deploy_hook_error"))
|
||||
end
|
||||
|
||||
|
||||
24
app/controllers/tag/deletions_controller.rb
Normal file
24
app/controllers/tag/deletions_controller.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
class Tag::DeletionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_tag
|
||||
before_action :set_replacement_tag, only: :create
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
@tag.replace_and_destroy! @replacement_tag
|
||||
redirect_back_or_to tags_path, notice: t(".deleted")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_tag
|
||||
@tag = Current.family.tags.find_by(id: params[:tag_id])
|
||||
end
|
||||
|
||||
def set_replacement_tag
|
||||
@replacement_tag = Current.family.tags.find_by(id: params[:replacement_tag_id])
|
||||
end
|
||||
end
|
||||
36
app/controllers/tags_controller.rb
Normal file
36
app/controllers/tags_controller.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
class TagsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_tag, only: %i[ edit update ]
|
||||
|
||||
def index
|
||||
@tags = Current.family.tags.alphabetically
|
||||
end
|
||||
|
||||
def new
|
||||
@tag = Current.family.tags.new color: Tag::COLORS.sample
|
||||
end
|
||||
|
||||
def create
|
||||
Current.family.tags.create!(tag_params)
|
||||
redirect_to tags_path, notice: t(".created")
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@tag.update!(tag_params)
|
||||
redirect_to tags_path, notice: t(".updated")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_tag
|
||||
@tag = Current.family.tags.find(params[:id])
|
||||
end
|
||||
|
||||
def tag_params
|
||||
params.require(:tag).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
@@ -1,41 +0,0 @@
|
||||
class Transactions::MerchantsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_merchant, only: %i[ edit update destroy ]
|
||||
|
||||
def index
|
||||
@merchants = Current.family.transaction_merchants
|
||||
end
|
||||
|
||||
def new
|
||||
@merchant = Transaction::Merchant.new
|
||||
end
|
||||
|
||||
def create
|
||||
Current.family.transaction_merchants.create!(merchant_params)
|
||||
redirect_to transaction_merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@merchant.update!(merchant_params)
|
||||
redirect_to transaction_merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@merchant.destroy!
|
||||
redirect_to transaction_merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_merchant
|
||||
@merchant = Current.family.transaction_merchants.find(params[:id])
|
||||
end
|
||||
|
||||
def merchant_params
|
||||
params.require(:transaction_merchant).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +0,0 @@
|
||||
class Transactions::RulesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
def index
|
||||
end
|
||||
end
|
||||
@@ -1,145 +1,73 @@
|
||||
class TransactionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_transaction, only: %i[ show edit update destroy ]
|
||||
|
||||
def index
|
||||
search_params = session[ransack_session_key] || params[:q]
|
||||
@q = Current.family.transactions.ransack(search_params)
|
||||
result = @q.result.order(date: :desc)
|
||||
@pagy, @transactions = pagy(result, items: 10)
|
||||
@q = search_params
|
||||
result = Current.family.transactions.search(@q).ordered
|
||||
@pagy, @transactions = pagy(result, items: params[:per_page] || "10")
|
||||
|
||||
@totals = {
|
||||
count: result.count,
|
||||
income: result.inflows.sum(&:amount_money).abs,
|
||||
expense: result.outflows.sum(&:amount_money).abs
|
||||
count: result.select { |t| t.currency == Current.family.currency }.count,
|
||||
income: result.income_total(Current.family.currency).abs,
|
||||
expense: result.expense_total(Current.family.currency)
|
||||
}
|
||||
@filter_list = Transaction.build_filter_list(search_params, Current.family)
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
def search
|
||||
if params[:clear]
|
||||
session.delete(ransack_session_key)
|
||||
elsif params[:remove_param]
|
||||
current_params = session[ransack_session_key] || {}
|
||||
if params[:remove_param] == "date_range"
|
||||
updated_params = current_params.except("date_gteq", "date_lteq")
|
||||
elsif params[:remove_param_value]
|
||||
key_to_remove = params[:remove_param]
|
||||
value_to_remove = params[:remove_param_value]
|
||||
updated_params = current_params.deep_dup
|
||||
updated_params[key_to_remove] = updated_params[key_to_remove] - [ value_to_remove ]
|
||||
else
|
||||
updated_params = current_params.except(params[:remove_param])
|
||||
end
|
||||
session[ransack_session_key] = updated_params
|
||||
elsif params[:q]
|
||||
session[ransack_session_key] = params[:q]
|
||||
end
|
||||
|
||||
index
|
||||
|
||||
respond_to do |format|
|
||||
format.html { render :index }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace("transactions_summary", partial: "transactions/summary", locals: { totals: @totals }),
|
||||
turbo_stream.replace("transactions_search_form", partial: "transactions/search_form", locals: { q: @q }),
|
||||
turbo_stream.replace("transactions_filters", partial: "transactions/filters", locals: { filters: @filter_list }),
|
||||
turbo_stream.replace("transactions_list", partial: "transactions/list", locals: { transactions: @transactions, pagy: @pagy })
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def new
|
||||
@transaction = Transaction.new.tap do |txn|
|
||||
@transaction = Account::Transaction.new.tap do |txn|
|
||||
if params[:account_id]
|
||||
txn.account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def create
|
||||
@transaction = Current.family.accounts
|
||||
.find(params[:transaction][:account_id])
|
||||
.transactions.build(transaction_params.merge(amount: amount))
|
||||
.find(params[:transaction][:account_id])
|
||||
.transactions
|
||||
.create!(transaction_params.merge(amount: amount))
|
||||
|
||||
respond_to do |format|
|
||||
if @transaction.save
|
||||
@transaction.account.sync_later(@transaction.date)
|
||||
format.html { redirect_to transactions_url, notice: t(".success") }
|
||||
else
|
||||
format.html { render :new, status: :unprocessable_entity }
|
||||
end
|
||||
@transaction.sync_account_later
|
||||
redirect_back_or_to account_path(@transaction.account), notice: t(".success")
|
||||
end
|
||||
|
||||
def bulk_delete
|
||||
destroyed = Current.family.transactions.destroy_by(id: bulk_delete_params[:transaction_ids])
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
end
|
||||
|
||||
def bulk_edit
|
||||
end
|
||||
|
||||
def bulk_update
|
||||
transactions = Current.family.transactions.where(id: bulk_update_params[:transaction_ids])
|
||||
if transactions.update_all(bulk_update_params.except(:transaction_ids).to_h.compact_blank!)
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: transactions.count)
|
||||
else
|
||||
flash.now[:error] = t(".failure")
|
||||
render :index, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
respond_to do |format|
|
||||
sync_start_date = if transaction_params[:date]
|
||||
[ @transaction.date, Date.parse(transaction_params[:date]) ].compact.min
|
||||
else
|
||||
@transaction.date
|
||||
end
|
||||
def mark_transfers
|
||||
Current.family
|
||||
.transactions
|
||||
.where(id: bulk_update_params[:transaction_ids])
|
||||
.mark_transfers!
|
||||
|
||||
if @transaction.update(transaction_params)
|
||||
@transaction.account.sync_later(sync_start_date)
|
||||
|
||||
format.html { redirect_to transaction_url(@transaction), notice: t(".success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: { body: t(".success") } }),
|
||||
turbo_stream.replace("transaction_#{@transaction.id}", partial: "transactions/transaction", locals: { transaction: @transaction })
|
||||
]
|
||||
end
|
||||
else
|
||||
format.html { render :edit, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@account = @transaction.account
|
||||
sync_start_date = @account.transactions.where("date < ?", @transaction.date).order(date: :desc).first&.date
|
||||
@transaction.destroy!
|
||||
@account.sync_later(sync_start_date)
|
||||
def unmark_transfers
|
||||
Current.family
|
||||
.transactions
|
||||
.where(id: bulk_update_params[:transaction_ids])
|
||||
.update_all marked_as_transfer: false
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to transactions_url, notice: t(".success") }
|
||||
end
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def delete_search_param(params, key, value: nil)
|
||||
if value
|
||||
params[key]&.delete(value)
|
||||
params.delete(key) if params[key].empty? # Remove key if it's empty after deleting value
|
||||
else
|
||||
params.delete(key)
|
||||
end
|
||||
|
||||
params
|
||||
end
|
||||
|
||||
def ransack_session_key
|
||||
:ransack_transactions_q
|
||||
end
|
||||
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_transaction
|
||||
@transaction = Transaction.find(params[:id])
|
||||
end
|
||||
|
||||
def amount
|
||||
if nature.income?
|
||||
@@ -153,8 +81,19 @@ class TransactionsController < ApplicationController
|
||||
params[:transaction][:nature].to_s.inquiry
|
||||
end
|
||||
|
||||
# Only allow a list of trusted parameters through.
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(transaction_ids: [])
|
||||
end
|
||||
|
||||
def bulk_update_params
|
||||
params.require(:bulk_update).permit(:date, :notes, :excluded, :category_id, :merchant_id, transaction_ids: [])
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: [])
|
||||
end
|
||||
|
||||
def transaction_params
|
||||
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id, :merchant_id)
|
||||
params.require(:transaction).permit(:name, :date, :amount, :currency, :category_id, tag_ids: [])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
class ValuationsController < ApplicationController
|
||||
before_action :set_valuation, only: %i[ edit update destroy ]
|
||||
def create
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
|
||||
# TODO: placeholder logic until we have a better abstraction for trends
|
||||
@valuation = @account.valuations.new(valuation_params.merge(currency: @account.currency))
|
||||
if @valuation.save
|
||||
@valuation.account.sync_later(@valuation.date)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account), notice: "Valuation created" }
|
||||
format.turbo_stream
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
flash.now[:error] = "Valuation already exists for this date"
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def show
|
||||
@valuation = Current.family.accounts.find(params[:account_id]).valuations.find(params[:id])
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
sync_start_date = [ @valuation.date, Date.parse(valuation_params[:date]) ].compact.min
|
||||
if @valuation.update(valuation_params)
|
||||
@valuation.account.sync_later(sync_start_date)
|
||||
|
||||
redirect_to account_path(@valuation.account), notice: "Valuation updated"
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
flash.now[:error] = "Valuation already exists for this date"
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
@account = @valuation.account
|
||||
sync_start_date = @account.valuations.where("date < ?", @valuation.date).order(date: :desc).first&.date
|
||||
@valuation.destroy!
|
||||
@account.sync_later(sync_start_date)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account), notice: "Valuation deleted" }
|
||||
format.turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
@valuation = @account.valuations.new
|
||||
end
|
||||
|
||||
private
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_valuation
|
||||
@valuation = Valuation.find(params[:id])
|
||||
end
|
||||
|
||||
def valuation_params
|
||||
params.require(:valuation).permit(:date, :value)
|
||||
end
|
||||
end
|
||||
37
app/helpers/account/transaction/searches_helper.rb
Normal file
37
app/helpers/account/transaction/searches_helper.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
module Account::Transaction::SearchesHelper
|
||||
def transaction_search_filters
|
||||
[
|
||||
{ key: "account_filter", name: "Account", icon: "layers" },
|
||||
{ key: "date_filter", name: "Date", icon: "calendar" },
|
||||
{ key: "type_filter", name: "Type", icon: "shapes" },
|
||||
{ key: "amount_filter", name: "Amount", icon: "hash" },
|
||||
{ key: "category_filter", name: "Category", icon: "tag" },
|
||||
{ key: "merchant_filter", name: "Merchant", icon: "store" }
|
||||
]
|
||||
end
|
||||
|
||||
def get_transaction_search_filter_partial_path(filter)
|
||||
"transactions/searches/filters/#{filter[:key]}"
|
||||
end
|
||||
|
||||
def get_default_transaction_search_filter
|
||||
transaction_search_filters[0]
|
||||
end
|
||||
|
||||
def transactions_path_without_param(param_key, param_value)
|
||||
updated_params = request.query_parameters.deep_dup
|
||||
|
||||
q_params = updated_params[:q] || {}
|
||||
|
||||
current_value = q_params[param_key]
|
||||
if current_value.is_a?(Array)
|
||||
q_params[param_key] = current_value - [ param_value ]
|
||||
else
|
||||
q_params.delete(param_key)
|
||||
end
|
||||
|
||||
updated_params[:q] = q_params
|
||||
|
||||
transactions_path(updated_params)
|
||||
end
|
||||
end
|
||||
24
app/helpers/account/transactions_helper.rb
Normal file
24
app/helpers/account/transactions_helper.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
module Account::TransactionsHelper
|
||||
def unconfirmed_transfer?(transaction)
|
||||
transaction.marked_as_transfer && transaction.transfer.nil?
|
||||
end
|
||||
|
||||
def group_transactions_by_date(transactions)
|
||||
grouped_by_date = {}
|
||||
|
||||
transactions.each do |transaction|
|
||||
if transaction.transfer
|
||||
transfer_date = transaction.transfer.inflow_transaction.date
|
||||
grouped_by_date[transfer_date] ||= { transactions: [], transfers: [] }
|
||||
unless grouped_by_date[transfer_date][:transfers].include?(transaction.transfer)
|
||||
grouped_by_date[transfer_date][:transfers] << transaction.transfer
|
||||
end
|
||||
else
|
||||
grouped_by_date[transaction.date] ||= { transactions: [], transfers: [] }
|
||||
grouped_by_date[transaction.date][:transactions] << transaction
|
||||
end
|
||||
end
|
||||
|
||||
grouped_by_date
|
||||
end
|
||||
end
|
||||
2
app/helpers/account/transfers_helper.rb
Normal file
2
app/helpers/account/transfers_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module Account::TransfersHelper
|
||||
end
|
||||
23
app/helpers/account/valuations_helper.rb
Normal file
23
app/helpers/account/valuations_helper.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
module Account::ValuationsHelper
|
||||
def valuation_icon(valuation)
|
||||
if valuation.oldest?
|
||||
"keyboard"
|
||||
elsif valuation.trend.direction.up?
|
||||
"arrow-up"
|
||||
elsif valuation.trend.direction.down?
|
||||
"arrow-down"
|
||||
else
|
||||
"minus"
|
||||
end
|
||||
end
|
||||
|
||||
def valuation_style(valuation)
|
||||
color = valuation.oldest? ? "#D444F1" : valuation.trend.color
|
||||
|
||||
<<-STYLE.strip
|
||||
background-color: color-mix(in srgb, #{color} 5%, white);
|
||||
border-color: color-mix(in srgb, #{color} 10%, white);
|
||||
color: #{color};
|
||||
STYLE
|
||||
end
|
||||
end
|
||||
@@ -27,14 +27,14 @@ module AccountsHelper
|
||||
|
||||
def class_mapping(accountable_type)
|
||||
{
|
||||
"Account::Credit" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
|
||||
"Account::Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
|
||||
"Account::OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
|
||||
"Account::Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
|
||||
"Account::Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
|
||||
"Account::OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
|
||||
"Account::Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
|
||||
"Account::Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
|
||||
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
|
||||
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
|
||||
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
|
||||
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
|
||||
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
|
||||
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
|
||||
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
|
||||
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
|
||||
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,7 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
|
||||
# See `Monetizable` concern, which adds a _money suffix to the attribute name
|
||||
# For a monetized field, the setter will always be the attribute name without the _money suffix
|
||||
def money_field(method, options = {})
|
||||
money = @object.send(method)
|
||||
money = @object && @object.respond_to?(method) ? @object.send(method) : nil
|
||||
raise ArgumentError, "The value of #{method} is not a Money object" unless money.is_a?(Money) || money.nil?
|
||||
|
||||
money_amount_method = method.to_s.chomp("_money").to_sym
|
||||
@@ -39,6 +39,8 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
|
||||
value: money&.amount,
|
||||
"data-money-field-target" => "amount",
|
||||
placeholder: Money.new(0, currency).format,
|
||||
min: -99999999999999,
|
||||
max: 99999999999999,
|
||||
step: currency.step
|
||||
}
|
||||
|
||||
|
||||
@@ -20,23 +20,37 @@ module ApplicationHelper
|
||||
render partial: "shared/notification", locals: { type: options[:type], content: { body: content } }
|
||||
end
|
||||
|
||||
# Wrap view with <%= modal do %> ... <% end %> to have it open in a modal
|
||||
# Make sure to add data-turbo-frame="modal" to the link/button that opens the modal
|
||||
##
|
||||
# Helper to open a centered and overlayed modal with custom contents
|
||||
#
|
||||
# @example Basic usage
|
||||
# <%= modal classes: "custom-class" do %>
|
||||
# <div>Content here</div>
|
||||
# <% end %>
|
||||
#
|
||||
def modal(options = {}, &block)
|
||||
content = capture &block
|
||||
render partial: "shared/modal", locals: { content:, classes: options[:classes] }
|
||||
end
|
||||
|
||||
##
|
||||
# Helper to open a drawer on the right side of the screen with custom contents
|
||||
#
|
||||
# @example Basic usage
|
||||
# <%= drawer do %>
|
||||
# <div>Content here</div>
|
||||
# <% end %>
|
||||
#
|
||||
def drawer(&block)
|
||||
content = capture &block
|
||||
render partial: "shared/drawer", locals: { content: content }
|
||||
end
|
||||
|
||||
def account_groups(period: nil)
|
||||
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: period || Period.last_30_days).values_at(:assets, :liabilities)
|
||||
[ assets.children, liabilities.children ].flatten
|
||||
end
|
||||
|
||||
def sidebar_modal(&block)
|
||||
content = capture &block
|
||||
render partial: "shared/sidebar_modal", locals: { content: content }
|
||||
end
|
||||
|
||||
def sidebar_link_to(name, path, options = {})
|
||||
is_current = current_page?(path) || (request.path.start_with?(path) && path != "/")
|
||||
|
||||
@@ -108,4 +122,11 @@ module ApplicationHelper
|
||||
options.reverse_merge!(money.default_format_options)
|
||||
ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] })
|
||||
end
|
||||
|
||||
def totals_by_currency(collection:, money_method:, separator: " | ", negate: false, options: {})
|
||||
collection.group_by(&:currency)
|
||||
.transform_values { |item| negate ? item.sum(&money_method) * -1 : item.sum(&money_method) }
|
||||
.map { |_currency, money| format_money(money) }
|
||||
.join(separator)
|
||||
end
|
||||
end
|
||||
|
||||
7
app/helpers/categories_helper.rb
Normal file
7
app/helpers/categories_helper.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
module CategoriesHelper
|
||||
def null_category
|
||||
Category.new \
|
||||
name: "Uncategorized",
|
||||
color: Category::UNCATEGORIZED_COLOR
|
||||
end
|
||||
end
|
||||
5
app/helpers/institutions_helper.rb
Normal file
5
app/helpers/institutions_helper.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
module InstitutionsHelper
|
||||
def institution_logo(institution)
|
||||
institution.logo.attached? ? institution.logo : institution.logo_url
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,23 @@ module MenusHelper
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: nil)
|
||||
link_to url, class: "flex items-center rounded-lg text-gray-900 hover:bg-gray-50 py-2 px-3 gap-2", data: { turbo_frame: } do
|
||||
concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-gray-500"))
|
||||
concat(tag.span(label, class: "text-sm"))
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_destructive_item(label, url, turbo_confirm: true, turbo_frame: nil)
|
||||
button_to url,
|
||||
method: :delete,
|
||||
class: "flex items-center w-full rounded-lg text-red-500 hover:bg-red-500/5 py-2 px-3 gap-2",
|
||||
data: { turbo_confirm: turbo_confirm, turbo_frame: } do
|
||||
concat(lucide_icon("trash-2", class: "shrink-0 w-5 h-5"))
|
||||
concat(tag.span(label, class: "text-sm"))
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def contextual_menu_icon
|
||||
tag.button class: "flex hover:bg-gray-100 p-2 rounded", data: { menu_target: "button" } do
|
||||
|
||||
7
app/helpers/tags_helper.rb
Normal file
7
app/helpers/tags_helper.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
module TagsHelper
|
||||
def null_tag
|
||||
Tag.new \
|
||||
name: "Uncategorized",
|
||||
color: Tag::UNCATEGORIZED_COLOR
|
||||
end
|
||||
end
|
||||
@@ -1,7 +0,0 @@
|
||||
module Transactions::CategoriesHelper
|
||||
def null_category
|
||||
Transaction::Category.new \
|
||||
name: "Uncategorized",
|
||||
color: Transaction::Category::UNCATEGORIZED_COLOR
|
||||
end
|
||||
end
|
||||
@@ -1,24 +0,0 @@
|
||||
module TransactionsHelper
|
||||
def transaction_filters
|
||||
[
|
||||
{ name: "Account", partial: "account_filter", icon: "layers" },
|
||||
{ name: "Date", partial: "date_filter", icon: "calendar" },
|
||||
{ name: "Type", partial: "type_filter", icon: "shapes" },
|
||||
{ name: "Amount", partial: "amount_filter", icon: "hash" },
|
||||
{ name: "Category", partial: "category_filter", icon: "tag" },
|
||||
{ name: "Merchant", partial: "merchant_filter", icon: "store" }
|
||||
]
|
||||
end
|
||||
|
||||
def transaction_filter_id(filter)
|
||||
"txn-#{filter[:name].downcase}-filter"
|
||||
end
|
||||
|
||||
def transaction_filter_by_name(name)
|
||||
transaction_filters.find { |filter| filter[:name] == name }
|
||||
end
|
||||
|
||||
def full_width_transaction_row?(route)
|
||||
route != "/"
|
||||
end
|
||||
end
|
||||
@@ -1,2 +0,0 @@
|
||||
module ValuationsHelper
|
||||
end
|
||||
133
app/javascript/controllers/bulk_select_controller.js
Normal file
133
app/javascript/controllers/bulk_select_controller.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import {Controller} from "@hotwired/stimulus"
|
||||
|
||||
// Connects to data-controller="bulk-select"
|
||||
export default class extends Controller {
|
||||
static targets = ["row", "group", "selectionBar", "selectionBarText", "bulkEditDrawerTitle"]
|
||||
static values = {
|
||||
resource: String,
|
||||
selectedIds: {type: Array, default: []}
|
||||
}
|
||||
|
||||
connect() {
|
||||
document.addEventListener("turbo:load", this.#updateView)
|
||||
|
||||
this.#updateView()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener("turbo:load", this.#updateView)
|
||||
}
|
||||
|
||||
bulkEditDrawerTitleTargetConnected(element) {
|
||||
element.innerText = `Edit ${this.selectedIdsValue.length} ${this.#pluralizedResourceName()}`
|
||||
}
|
||||
|
||||
submitBulkRequest(e) {
|
||||
const form = e.target.closest("form");
|
||||
const scope = e.params.scope
|
||||
this.#addHiddenFormInputsForSelectedIds(form, `${scope}[transaction_ids][]`, this.selectedIdsValue)
|
||||
form.requestSubmit()
|
||||
}
|
||||
|
||||
togglePageSelection(e) {
|
||||
if (e.target.checked) {
|
||||
this.#selectAll()
|
||||
} else {
|
||||
this.deselectAll()
|
||||
}
|
||||
}
|
||||
|
||||
toggleGroupSelection(e) {
|
||||
const group = this.groupTargets.find(group => group.contains(e.target))
|
||||
|
||||
this.#rowsForGroup(group).forEach(row => {
|
||||
if (e.target.checked) {
|
||||
this.#addToSelection(row.dataset.id)
|
||||
} else {
|
||||
this.#removeFromSelection(row.dataset.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
toggleRowSelection(e) {
|
||||
if (e.target.checked) {
|
||||
this.#addToSelection(e.target.dataset.id)
|
||||
} else {
|
||||
this.#removeFromSelection(e.target.dataset.id)
|
||||
}
|
||||
}
|
||||
|
||||
deselectAll() {
|
||||
this.selectedIdsValue = []
|
||||
}
|
||||
|
||||
selectedIdsValueChanged() {
|
||||
this.#updateView()
|
||||
}
|
||||
|
||||
#addHiddenFormInputsForSelectedIds(form, paramName, transactionIds) {
|
||||
this.#resetFormInputs(form, paramName);
|
||||
|
||||
transactionIds.forEach(id => {
|
||||
const input = document.createElement("input");
|
||||
input.type = 'hidden'
|
||||
input.name = paramName
|
||||
input.value = id
|
||||
form.appendChild(input)
|
||||
})
|
||||
}
|
||||
|
||||
#resetFormInputs(form, paramName) {
|
||||
const existingInputs = form.querySelectorAll(`input[name='${paramName}']`);
|
||||
existingInputs.forEach((input) => input.remove());
|
||||
}
|
||||
|
||||
#rowsForGroup(group) {
|
||||
return this.rowTargets.filter(row => group.contains(row))
|
||||
}
|
||||
|
||||
#addToSelection(idToAdd) {
|
||||
this.selectedIdsValue = Array.from(
|
||||
new Set([...this.selectedIdsValue, idToAdd])
|
||||
)
|
||||
}
|
||||
|
||||
#removeFromSelection(idToRemove) {
|
||||
this.selectedIdsValue = this.selectedIdsValue.filter(id => id !== idToRemove)
|
||||
}
|
||||
|
||||
#selectAll() {
|
||||
this.selectedIdsValue = this.rowTargets.map(t => t.dataset.id)
|
||||
}
|
||||
|
||||
#updateView = () => {
|
||||
this.#updateSelectionBar()
|
||||
this.#updateGroups()
|
||||
this.#updateRows()
|
||||
}
|
||||
|
||||
#updateSelectionBar() {
|
||||
const count = this.selectedIdsValue.length
|
||||
this.selectionBarTextTarget.innerText = `${count} ${this.#pluralizedResourceName()} selected`
|
||||
this.selectionBarTarget.hidden = count === 0
|
||||
this.selectionBarTarget.querySelector("input[type='checkbox']").checked = count > 0
|
||||
}
|
||||
|
||||
#pluralizedResourceName() {
|
||||
return `${this.resourceValue}${this.selectedIdsValue.length === 1 ? "" : "s"}`
|
||||
}
|
||||
|
||||
#updateGroups() {
|
||||
this.groupTargets.forEach(group => {
|
||||
const rows = this.rowTargets.filter(row => group.contains(row))
|
||||
const groupSelected = rows.length > 0 && rows.every(row => this.selectedIdsValue.includes(row.dataset.id))
|
||||
group.querySelector("input[type='checkbox']").checked = groupSelected
|
||||
})
|
||||
}
|
||||
|
||||
#updateRows() {
|
||||
this.rowTargets.forEach(row => {
|
||||
row.checked = this.selectedIdsValue.includes(row.dataset.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "replacementCategoryField", "submitButton" ]
|
||||
static targets = ["replacementField", "submitButton"]
|
||||
static classes = [ "dangerousAction", "safeAction" ]
|
||||
static values = {
|
||||
submitTextWhenReplacing: String,
|
||||
@@ -9,7 +9,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
updateSubmitButton() {
|
||||
if (this.replacementCategoryFieldTarget.value) {
|
||||
if (this.replacementFieldTarget.value) {
|
||||
this.submitButtonTarget.value = this.submitTextWhenReplacingValue
|
||||
this.#markSafe()
|
||||
} else {
|
||||
@@ -8,7 +8,7 @@ export default class extends Controller {
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-24 h-24 rounded-full object-cover" />`;
|
||||
this.imagePreviewTarget.innerHTML = `<img src="${e.target.result}" alt="Preview" class="w-full h-full rounded-full object-cover" />`;
|
||||
this.templateTarget.classList.add("hidden");
|
||||
this.clearBtnTarget.classList.remove("hidden");
|
||||
};
|
||||
|
||||
@@ -2,10 +2,12 @@ class Account < ApplicationRecord
|
||||
include Syncable
|
||||
include Monetizable
|
||||
|
||||
broadcasts_refreshes
|
||||
|
||||
validates :family, presence: true
|
||||
|
||||
broadcasts_refreshes
|
||||
belongs_to :family
|
||||
belongs_to :institution, optional: true
|
||||
has_many :balances, dependent: :destroy
|
||||
has_many :valuations, dependent: :destroy
|
||||
has_many :transactions, dependent: :destroy
|
||||
@@ -19,17 +21,18 @@ class Account < ApplicationRecord
|
||||
scope :assets, -> { where(classification: "asset") }
|
||||
scope :liabilities, -> { where(classification: "liability") }
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
scope :ungrouped, -> { where(institution_id: nil) }
|
||||
|
||||
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[name id]
|
||||
end
|
||||
|
||||
def balance_on(date)
|
||||
balances.where("date <= ?", date).order(date: :desc).first&.balance
|
||||
end
|
||||
|
||||
def favorable_direction
|
||||
classification == "asset" ? "up" : "down"
|
||||
end
|
||||
|
||||
# e.g. Wise, Revolut accounts that have transactions in multiple currencies
|
||||
def multi_currency?
|
||||
currencies = [ valuations.pluck(:currency), transactions.pluck(:currency) ].flatten.uniq
|
||||
@@ -84,4 +87,20 @@ class Account < ApplicationRecord
|
||||
|
||||
grouped_accounts
|
||||
end
|
||||
|
||||
def self.create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil)
|
||||
account = self.new(attributes.except(:accountable_type))
|
||||
account.accountable = Accountable.from_type(attributes[:accountable_type])&.new
|
||||
|
||||
# Always build the initial valuation
|
||||
account.valuations.build(date: Date.current, value: attributes[:balance], currency: account.currency)
|
||||
|
||||
# Conditionally build the optional start valuation
|
||||
if start_date.present? && start_balance.present?
|
||||
account.valuations.build(date: start_date, value: start_balance, currency: account.currency)
|
||||
end
|
||||
|
||||
account.save!
|
||||
account
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
class Account::Balance::Calculator
|
||||
attr_reader :daily_balances, :errors, :warnings
|
||||
|
||||
@daily_balances = []
|
||||
@errors = []
|
||||
@warnings = []
|
||||
|
||||
def initialize(account, options = {})
|
||||
@daily_balances = []
|
||||
@errors = []
|
||||
@warnings = []
|
||||
@account = account
|
||||
@calc_start_date = [ options[:calc_start_date], @account.effective_start_date ].compact.max
|
||||
end
|
||||
@@ -43,34 +42,50 @@ class Account::Balance::Calculator
|
||||
|
||||
private
|
||||
def convert_balances_to_family_currency
|
||||
rates = ExchangeRate.get_rate_series(
|
||||
rates = ExchangeRate.get_rates(
|
||||
@account.currency,
|
||||
@account.family.currency,
|
||||
@calc_start_date..Date.current
|
||||
).to_a
|
||||
|
||||
@daily_balances.map do |balance|
|
||||
rate = rates.find { |rate| rate.date == balance[:date] }
|
||||
raise "Rate for #{@account.currency} to #{@account.family.currency} on #{balance[:date]} not found" if rate.nil?
|
||||
converted_balance = balance[:balance] * rate.rate
|
||||
# Abort conversion if some required rates are missing
|
||||
if rates.length != @daily_balances.length
|
||||
@errors << :sync_message_missing_rates
|
||||
return []
|
||||
end
|
||||
|
||||
@daily_balances.map.with_index do |balance, index|
|
||||
converted_balance = balance[:balance] * rates[index].rate
|
||||
{ date: balance[:date], balance: converted_balance, currency: @account.family.currency, updated_at: Time.current }
|
||||
end
|
||||
end
|
||||
|
||||
# For calculation, all transactions and valuations need to be normalized to the same currency (the account's primary currency)
|
||||
def normalize_entries_to_account_currency(entries, value_key)
|
||||
entries.map do |entry|
|
||||
currency = entry.currency
|
||||
date = entry.date
|
||||
value = entry.send(value_key)
|
||||
grouped_entries = entries.group_by(&:currency)
|
||||
normalized_entries = []
|
||||
|
||||
grouped_entries.each do |currency, entries|
|
||||
if currency != @account.currency
|
||||
value = ExchangeRate.convert(value:, from: currency, to: @account.currency, date:)
|
||||
currency = @account.currency
|
||||
dates = entries.map(&:date).uniq
|
||||
rates = ExchangeRate.get_rates(currency, @account.currency, dates).to_a
|
||||
if rates.length != dates.length
|
||||
@errors << :sync_message_missing_rates
|
||||
else
|
||||
entries.each do |entry|
|
||||
## There can be several entries on the same date so we cannot rely on indeces
|
||||
rate = rates.find { |rate| rate.date == entry.date }
|
||||
value = entry.send(value_key)
|
||||
value *= rate.rate
|
||||
normalized_entries << entry.attributes.merge(value_key.to_s => value, "currency" => currency)
|
||||
end
|
||||
end
|
||||
else
|
||||
normalized_entries.concat(entries)
|
||||
end
|
||||
|
||||
entry.attributes.merge(value_key.to_s => value, "currency" => currency)
|
||||
end
|
||||
|
||||
normalized_entries
|
||||
end
|
||||
|
||||
def normalized_valuations
|
||||
@@ -92,8 +107,8 @@ class Account::Balance::Calculator
|
||||
return @account.balance_on(@calc_start_date)
|
||||
end
|
||||
|
||||
oldest_valuation_date = normalized_valuations.first&.dig("date")
|
||||
oldest_transaction_date = normalized_transactions.first&.dig("date")
|
||||
oldest_valuation_date = normalized_valuations.first&.date
|
||||
oldest_transaction_date = normalized_transactions.first&.date
|
||||
oldest_entry_date = [ oldest_valuation_date, oldest_transaction_date ].compact.min
|
||||
|
||||
if oldest_entry_date.present? && oldest_entry_date == oldest_valuation_date
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::Credit < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::Crypto < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::Depository < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::Loan < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::OtherAsset < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::OtherLiability < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::Property < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,88 +1,85 @@
|
||||
module Account::Syncable
|
||||
extend ActiveSupport::Concern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def sync_later(start_date = nil)
|
||||
AccountSyncJob.perform_later(self, start_date)
|
||||
def sync_later(start_date = nil)
|
||||
AccountSyncJob.perform_later(self, start_date)
|
||||
end
|
||||
|
||||
def sync(start_date = nil)
|
||||
update!(status: "syncing")
|
||||
|
||||
sync_exchange_rates
|
||||
|
||||
calc_start_date = start_date - 1.day if start_date.present? && self.balance_on(start_date - 1.day).present?
|
||||
|
||||
calculator = Account::Balance::Calculator.new(self, { calc_start_date: })
|
||||
calculator.calculate
|
||||
self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique)
|
||||
self.balances.where("date < ?", effective_start_date).delete_all
|
||||
new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance]
|
||||
|
||||
update!(status: "ok", last_sync_date: Date.today, balance: new_balance, sync_errors: calculator.errors, sync_warnings: calculator.warnings)
|
||||
rescue => e
|
||||
update!(status: "error", sync_errors: [ :sync_message_unknown_error ])
|
||||
logger.error("Failed to sync account #{id}: #{e.message}")
|
||||
end
|
||||
|
||||
def can_sync?
|
||||
# Skip account sync if account is not active or the sync process is already running
|
||||
return false unless is_active
|
||||
return false if syncing?
|
||||
# If last_sync_date is blank (i.e. the account has never been synced before) allow syncing
|
||||
return true if last_sync_date.blank?
|
||||
|
||||
# If last_sync_date is not today, allow syncing
|
||||
last_sync_date != Date.today
|
||||
end
|
||||
|
||||
# The earliest date we can calculate a balance for
|
||||
def effective_start_date
|
||||
first_valuation_date = self.valuations.order(:date).pluck(:date).first
|
||||
first_transaction_date = self.transactions.order(:date).pluck(:date).first
|
||||
|
||||
[ first_valuation_date, first_transaction_date&.prev_day ].compact.min || Date.current
|
||||
end
|
||||
|
||||
# Finds all the rate pairs that are required to calculate balances for an account and syncs them
|
||||
def sync_exchange_rates
|
||||
rate_candidates = []
|
||||
|
||||
if multi_currency?
|
||||
transactions_in_foreign_currency = self.transactions.where.not(currency: self.currency).pluck(:currency, :date).uniq
|
||||
transactions_in_foreign_currency.each do |currency, date|
|
||||
rate_candidates << { date: date, from_currency: currency, to_currency: self.currency }
|
||||
end
|
||||
end
|
||||
|
||||
def sync(start_date = nil)
|
||||
update!(status: "syncing")
|
||||
|
||||
sync_exchange_rates
|
||||
|
||||
calc_start_date = start_date - 1.day if start_date.present? && self.balance_on(start_date - 1.day).present?
|
||||
|
||||
calculator = Account::Balance::Calculator.new(self, { calc_start_date: })
|
||||
calculator.calculate
|
||||
self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique)
|
||||
self.balances.where("date < ?", effective_start_date).delete_all
|
||||
new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance]
|
||||
self.balance = new_balance
|
||||
self.save!
|
||||
|
||||
update!(status: "ok", last_sync_date: Date.today)
|
||||
rescue => e
|
||||
update!(status: "error")
|
||||
Rails.logger.error("Failed to sync account #{id}: #{e.message}")
|
||||
if foreign_currency?
|
||||
(effective_start_date..Date.current).each do |date|
|
||||
rate_candidates << { date: date, from_currency: self.currency, to_currency: self.family.currency }
|
||||
end
|
||||
end
|
||||
|
||||
def can_sync?
|
||||
# Skip account sync if account is not active or the sync process is already running
|
||||
return false unless is_active
|
||||
return false if syncing?
|
||||
# If last_sync_date is blank (i.e. the account has never been synced before) allow syncing
|
||||
return true if last_sync_date.blank?
|
||||
existing_rates = ExchangeRate.where(
|
||||
base_currency: rate_candidates.map { |rc| rc[:from_currency] },
|
||||
converted_currency: rate_candidates.map { |rc| rc[:to_currency] },
|
||||
date: rate_candidates.map { |rc| rc[:date] }
|
||||
).pluck(:base_currency, :converted_currency, :date)
|
||||
|
||||
# If last_sync_date is not today, allow syncing
|
||||
last_sync_date != Date.today
|
||||
# Convert to a set for faster lookup
|
||||
existing_rates_set = existing_rates.map { |er| [ er[0], er[1], er[2].to_s ] }.to_set
|
||||
|
||||
rate_candidates.each do |rate_candidate|
|
||||
rc_from = rate_candidate[:from_currency]
|
||||
rc_to = rate_candidate[:to_currency]
|
||||
rc_date = rate_candidate[:date]
|
||||
|
||||
next if existing_rates_set.include?([ rc_from, rc_to, rc_date.to_s ])
|
||||
|
||||
logger.info "Fetching exchange rate from provider for account #{self.name}: #{self.id} (#{rc_from} to #{rc_to} on #{rc_date})"
|
||||
ExchangeRate.find_rate_or_fetch from: rc_from, to: rc_to, date: rc_date
|
||||
end
|
||||
|
||||
# The earliest date we can calculate a balance for
|
||||
def effective_start_date
|
||||
first_valuation_date = self.valuations.order(:date).pluck(:date).first
|
||||
first_transaction_date = self.transactions.order(:date).pluck(:date).first
|
||||
|
||||
[ first_valuation_date, first_transaction_date&.prev_day ].compact.min || Date.current
|
||||
end
|
||||
|
||||
# Finds all the rate pairs that are required to calculate balances for an account and syncs them
|
||||
def sync_exchange_rates
|
||||
rate_candidates = []
|
||||
|
||||
if multi_currency?
|
||||
transactions_in_foreign_currency = self.transactions.where.not(currency: self.currency).pluck(:currency, :date).uniq
|
||||
transactions_in_foreign_currency.each do |currency, date|
|
||||
rate_candidates << { date: date, from_currency: currency, to_currency: self.currency }
|
||||
end
|
||||
end
|
||||
|
||||
if foreign_currency?
|
||||
(effective_start_date..Date.current).each do |date|
|
||||
rate_candidates << { date: date, from_currency: self.currency, to_currency: self.family.currency }
|
||||
end
|
||||
end
|
||||
|
||||
existing_rates = ExchangeRate.where(
|
||||
base_currency: rate_candidates.map { |rc| rc[:from_currency] },
|
||||
converted_currency: rate_candidates.map { |rc| rc[:to_currency] },
|
||||
date: rate_candidates.map { |rc| rc[:date] }
|
||||
).pluck(:base_currency, :converted_currency, :date)
|
||||
|
||||
# Convert to a set for faster lookup
|
||||
existing_rates_set = existing_rates.map { |er| [ er[0], er[1], er[2].to_s ] }.to_set
|
||||
|
||||
rate_candidates.each do |rate_candidate|
|
||||
rc_from = rate_candidate[:from_currency]
|
||||
rc_to = rate_candidate[:to_currency]
|
||||
rc_date = rate_candidate[:date]
|
||||
|
||||
next if existing_rates_set.include?([ rc_from, rc_to, rc_date.to_s ])
|
||||
|
||||
logger.info "Fetching exchange rate from provider for account #{self.name}: #{self.id} (#{rc_from} to #{rc_to} on #{rc_date})"
|
||||
rate = ExchangeRate.find_rate_or_fetch from: rc_from, to: rc_to, date: rc_date
|
||||
ExchangeRate.create! base_currency: rc_from, converted_currency: rc_to, date: rc_date, rate: rate if rate
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
126
app/models/account/transaction.rb
Normal file
126
app/models/account/transaction.rb
Normal file
@@ -0,0 +1,126 @@
|
||||
class Account::Transaction < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
monetize :amount
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :transfer, optional: true, class_name: "Account::Transfer"
|
||||
belongs_to :category, optional: true
|
||||
belongs_to :merchant, optional: true
|
||||
has_many :taggings, as: :taggable, dependent: :destroy
|
||||
has_many :tags, through: :taggings
|
||||
accepts_nested_attributes_for :taggings, allow_destroy: true
|
||||
|
||||
validates :name, :date, :amount, :account, presence: true
|
||||
|
||||
scope :ordered, -> { order(date: :desc) }
|
||||
scope :active, -> { where(excluded: false) }
|
||||
scope :inflows, -> { where("amount <= 0") }
|
||||
scope :outflows, -> { where("amount > 0") }
|
||||
scope :by_name, ->(name) { where("account_transactions.name ILIKE ?", "%#{name}%") }
|
||||
scope :with_categories, ->(categories) { joins(:category).where(categories: { name: categories }) }
|
||||
scope :with_accounts, ->(accounts) { joins(:account).where(accounts: { name: accounts }) }
|
||||
scope :with_account_ids, ->(account_ids) { joins(:account).where(accounts: { id: account_ids }) }
|
||||
scope :with_merchants, ->(merchants) { joins(:merchant).where(merchants: { name: merchants }) }
|
||||
scope :on_or_after_date, ->(date) { where("account_transactions.date >= ?", date) }
|
||||
scope :on_or_before_date, ->(date) { where("account_transactions.date <= ?", date) }
|
||||
scope :with_converted_amount, ->(currency = Current.family.currency) {
|
||||
# Join with exchange rates to convert the amount to the given currency
|
||||
# If no rate is available, exclude the transaction from the results
|
||||
select(
|
||||
"account_transactions.*",
|
||||
"account_transactions.amount * COALESCE(er.rate, 1) AS converted_amount"
|
||||
)
|
||||
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_transactions.date = er.date AND account_transactions.currency = er.base_currency AND er.converted_currency = ?", currency ]))
|
||||
.where("er.rate IS NOT NULL OR account_transactions.currency = ?", currency)
|
||||
}
|
||||
|
||||
def inflow?
|
||||
amount <= 0
|
||||
end
|
||||
|
||||
def outflow?
|
||||
amount > 0
|
||||
end
|
||||
|
||||
def transfer?
|
||||
marked_as_transfer
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
if destroyed?
|
||||
sync_start_date = previous_transaction_date
|
||||
else
|
||||
sync_start_date = [ date_previously_was, date ].compact.min
|
||||
end
|
||||
|
||||
account.sync_later(sync_start_date)
|
||||
end
|
||||
|
||||
class << self
|
||||
def income_total(currency = "USD")
|
||||
inflows.reject(&:transfer?).select { |t| t.currency == currency }.sum(&:amount_money)
|
||||
end
|
||||
|
||||
def expense_total(currency = "USD")
|
||||
outflows.reject(&:transfer?).select { |t| t.currency == currency }.sum(&:amount_money)
|
||||
end
|
||||
|
||||
def mark_transfers!
|
||||
update_all marked_as_transfer: true
|
||||
|
||||
# Attempt to "auto match" and save a transfer if 2 transactions selected
|
||||
Account::Transfer.new(transactions: all).save if all.count == 2
|
||||
end
|
||||
|
||||
def daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Sum spending and income for each day in the period with the given currency
|
||||
select(
|
||||
"gs.date",
|
||||
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
|
||||
)
|
||||
.from(transactions.with_converted_amount(currency).where(marked_as_transfer: false), :t)
|
||||
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ]))
|
||||
.group("gs.date")
|
||||
end
|
||||
|
||||
def daily_rolling_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Extend the period to include the rolling window
|
||||
period_with_rolling = period.extend_backward(period.date_range.count.days)
|
||||
|
||||
# Aggregate the rolling sum of spending and income based on daily totals
|
||||
rolling_totals = from(daily_totals(transactions, period: period_with_rolling, currency: currency))
|
||||
.select(
|
||||
"*",
|
||||
sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
|
||||
sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
|
||||
)
|
||||
.order("date")
|
||||
|
||||
# Trim the results to the original period
|
||||
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
|
||||
end
|
||||
|
||||
def search(params)
|
||||
query = all.includes(:transfer)
|
||||
query = query.by_name(params[:search]) if params[:search].present?
|
||||
query = query.with_categories(params[:categories]) if params[:categories].present?
|
||||
query = query.with_accounts(params[:accounts]) if params[:accounts].present?
|
||||
query = query.with_account_ids(params[:account_ids]) if params[:account_ids].present?
|
||||
query = query.with_merchants(params[:merchants]) if params[:merchants].present?
|
||||
query = query.on_or_after_date(params[:start_date]) if params[:start_date].present?
|
||||
query = query.on_or_before_date(params[:end_date]) if params[:end_date].present?
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def previous_transaction_date
|
||||
self.account
|
||||
.transactions
|
||||
.where("date < ?", date)
|
||||
.order(date: :desc)
|
||||
.first&.date
|
||||
end
|
||||
end
|
||||
62
app/models/account/transfer.rb
Normal file
62
app/models/account/transfer.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
class Account::Transfer < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify
|
||||
|
||||
validate :net_zero_flows, if: :single_currency_transfer?
|
||||
validate :transaction_count, :from_different_accounts, :all_transactions_marked
|
||||
|
||||
def inflow_transaction
|
||||
transactions.find { |t| t.inflow? }
|
||||
end
|
||||
|
||||
def outflow_transaction
|
||||
transactions.find { |t| t.outflow? }
|
||||
end
|
||||
|
||||
def destroy_and_remove_marks!
|
||||
transaction do
|
||||
transactions.each do |t|
|
||||
t.update! marked_as_transfer: false
|
||||
end
|
||||
|
||||
destroy!
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:)
|
||||
outflow = from_account.transactions.build(amount: amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true)
|
||||
inflow = to_account.transactions.build(amount: -amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true)
|
||||
|
||||
new transactions: [ outflow, inflow ]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def single_currency_transfer?
|
||||
transactions.map(&:currency).uniq.size == 1
|
||||
end
|
||||
|
||||
def transaction_count
|
||||
unless transactions.size == 2
|
||||
errors.add :transactions, "must have exactly 2 transactions"
|
||||
end
|
||||
end
|
||||
|
||||
def from_different_accounts
|
||||
accounts = transactions.map(&:account_id).uniq
|
||||
errors.add :transactions, "must be from different accounts" if accounts.size < transactions.size
|
||||
end
|
||||
|
||||
def net_zero_flows
|
||||
unless transactions.sum(&:amount).zero?
|
||||
errors.add :transactions, "must have an inflow and outflow that net to zero"
|
||||
end
|
||||
end
|
||||
|
||||
def all_transactions_marked
|
||||
unless transactions.all?(&:marked_as_transfer)
|
||||
errors.add :transactions, "must be marked as transfer"
|
||||
end
|
||||
end
|
||||
end
|
||||
48
app/models/account/valuation.rb
Normal file
48
app/models/account/valuation.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
class Account::Valuation < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
monetize :value
|
||||
|
||||
belongs_to :account
|
||||
|
||||
validates :account, :date, :value, presence: true
|
||||
validates :date, uniqueness: { scope: :account_id }
|
||||
|
||||
scope :chronological, -> { order(:date) }
|
||||
scope :reverse_chronological, -> { order(date: :desc) }
|
||||
|
||||
def trend
|
||||
@trend ||= create_trend
|
||||
end
|
||||
|
||||
def oldest?
|
||||
account.valuations.chronological.limit(1).pluck(:date).first == self.date
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
if destroyed?
|
||||
sync_start_date = previous_valuation&.date
|
||||
else
|
||||
sync_start_date = [ date_previously_was, date ].compact.min
|
||||
end
|
||||
|
||||
account.sync_later(sync_start_date)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def previous_valuation
|
||||
@previous_valuation ||= self.account
|
||||
.valuations
|
||||
.where("date < ?", date)
|
||||
.order(date: :desc)
|
||||
.first
|
||||
end
|
||||
|
||||
def create_trend
|
||||
TimeSeries::Trend.new \
|
||||
current: self.value,
|
||||
previous: previous_valuation&.value,
|
||||
favorable_direction: account.favorable_direction
|
||||
end
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::Vehicle < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class Transaction::Category < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify
|
||||
class Category < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||
belongs_to :family
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
@@ -23,16 +23,8 @@ class Transaction::Category < ApplicationRecord
|
||||
{ internal_category: "home_improvement", color: COLORS[7] }
|
||||
]
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[name id]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[]
|
||||
end
|
||||
|
||||
def self.create_default_categories(family)
|
||||
if family.transaction_categories.size > 0
|
||||
if family.categories.size > 0
|
||||
raise ArgumentError, "Family already has some categories"
|
||||
end
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
module Accountable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
ASSET_TYPES = %w[ Account::Depository Account::Investment Account::Crypto Account::OtherAsset Account::Property Account::Vehicle ]
|
||||
LIABILITY_TYPES = %w[ Account::Credit Account::Loan Account::OtherLiability ]
|
||||
ASSET_TYPES = %w[ Depository Investment Crypto Property Vehicle OtherAsset ]
|
||||
LIABILITY_TYPES = %w[ CreditCard Loan OtherLiability ]
|
||||
TYPES = ASSET_TYPES + LIABILITY_TYPES
|
||||
|
||||
def self.from_type(type)
|
||||
return nil unless types.include?(type) || TYPES.include?(type)
|
||||
"Account::#{type.demodulize}".constantize
|
||||
return nil unless TYPES.include?(type)
|
||||
type.constantize
|
||||
end
|
||||
|
||||
def self.by_classification
|
||||
{ assets: ASSET_TYPES, liabilities: LIABILITY_TYPES }
|
||||
end
|
||||
|
||||
def self.types(classification = nil)
|
||||
types = classification ? (classification.to_sym == :asset ? ASSET_TYPES : LIABILITY_TYPES) : TYPES
|
||||
types.map { |type| type.demodulize }
|
||||
end
|
||||
|
||||
def self.classification(type)
|
||||
ASSET_TYPES.include?(type) ? :asset : :liability
|
||||
end
|
||||
|
||||
included do
|
||||
has_one :account, as: :accountable, touch: true
|
||||
end
|
||||
|
||||
3
app/models/credit_card.rb
Normal file
3
app/models/credit_card.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class CreditCard < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
3
app/models/crypto.rb
Normal file
3
app/models/crypto.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Crypto < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
3
app/models/depository.rb
Normal file
3
app/models/depository.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Depository < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -12,11 +12,11 @@ class ExchangeRate < ApplicationRecord
|
||||
end
|
||||
|
||||
def find_rate_or_fetch(from:, to:, date:)
|
||||
find_rate(from:, to:, date:) || fetch_rate_from_provider(from:, to:, date:).tap(&:save!)
|
||||
find_rate(from:, to:, date:) || fetch_rate_from_provider(from:, to:, date:)&.tap(&:save!)
|
||||
end
|
||||
|
||||
def get_rate_series(from, to, date_range)
|
||||
where(base_currency: from, converted_currency: to, date: date_range).order(:date)
|
||||
def get_rates(from, to, dates)
|
||||
where(base_currency: from, converted_currency: to, date: dates).order(:date)
|
||||
end
|
||||
|
||||
def convert(value:, from:, to:, date:)
|
||||
|
||||
@@ -5,6 +5,8 @@ module ExchangeRate::Provided
|
||||
class_methods do
|
||||
private
|
||||
def fetch_rate_from_provider(from:, to:, date:)
|
||||
return nil unless exchange_rates_provider.configured?
|
||||
|
||||
response = exchange_rates_provider.fetch_exchange_rate \
|
||||
from: Money::Currency.new(from).iso_code,
|
||||
to: Money::Currency.new(to).iso_code,
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
class Family < ApplicationRecord
|
||||
has_many :users, dependent: :destroy
|
||||
has_many :tags, dependent: :destroy
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :transactions, through: :accounts
|
||||
has_many :institutions, dependent: :destroy
|
||||
has_many :transactions, through: :accounts, class_name: "Account::Transaction"
|
||||
has_many :imports, through: :accounts
|
||||
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"
|
||||
has_many :transaction_merchants, dependent: :destroy, class_name: "Transaction::Merchant"
|
||||
has_many :categories, dependent: :destroy
|
||||
has_many :merchants, dependent: :destroy
|
||||
|
||||
def snapshot(period = Period.all)
|
||||
query = accounts.active.joins(:balances)
|
||||
.where("account_balances.currency = ?", self.currency)
|
||||
.select(
|
||||
"account_balances.currency",
|
||||
"account_balances.date",
|
||||
"SUM(CASE WHEN accounts.classification = 'liability' THEN account_balances.balance ELSE 0 END) AS liabilities",
|
||||
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance ELSE 0 END) AS assets",
|
||||
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance WHEN accounts.classification = 'liability' THEN -account_balances.balance ELSE 0 END) AS net_worth",
|
||||
)
|
||||
.group("account_balances.date, account_balances.currency")
|
||||
.order("account_balances.date")
|
||||
.where("account_balances.currency = ?", self.currency)
|
||||
.select(
|
||||
"account_balances.currency",
|
||||
"account_balances.date",
|
||||
"SUM(CASE WHEN accounts.classification = 'liability' THEN account_balances.balance ELSE 0 END) AS liabilities",
|
||||
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance ELSE 0 END) AS assets",
|
||||
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance WHEN accounts.classification = 'liability' THEN -account_balances.balance ELSE 0 END) AS net_worth",
|
||||
)
|
||||
.group("account_balances.date, account_balances.currency")
|
||||
.order("account_balances.date")
|
||||
|
||||
query = query.where("account_balances.date >= ?", period.date_range.begin) if period.date_range.begin
|
||||
query = query.where("account_balances.date <= ?", period.date_range.end) if period.date_range.end
|
||||
@@ -33,15 +35,16 @@ class Family < ApplicationRecord
|
||||
def snapshot_account_transactions
|
||||
period = Period.last_30_days
|
||||
results = accounts.active.joins(:transactions)
|
||||
.select(
|
||||
"accounts.*",
|
||||
"COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-amount) FILTER (WHERE amount < 0), 0) AS income"
|
||||
)
|
||||
.where("transactions.date >= ?", period.date_range.begin)
|
||||
.where("transactions.date <= ?", period.date_range.end)
|
||||
.group("id")
|
||||
.to_a
|
||||
.select(
|
||||
"accounts.*",
|
||||
"COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-amount) FILTER (WHERE amount < 0), 0) AS income"
|
||||
)
|
||||
.where("account_transactions.date >= ?", period.date_range.begin)
|
||||
.where("account_transactions.date <= ?", period.date_range.end)
|
||||
.where("account_transactions.marked_as_transfer = ?", false)
|
||||
.group("id")
|
||||
.to_a
|
||||
|
||||
results.each do |r|
|
||||
r.define_singleton_method(:savings_rate) do
|
||||
@@ -57,7 +60,7 @@ class Family < ApplicationRecord
|
||||
end
|
||||
|
||||
def snapshot_transactions
|
||||
rolling_totals = Transaction.daily_rolling_totals(transactions, period: Period.last_30_days, currency: self.currency)
|
||||
rolling_totals = Account::Transaction.daily_rolling_totals(transactions, period: Period.last_30_days, currency: self.currency)
|
||||
|
||||
spending = []
|
||||
income = []
|
||||
|
||||
@@ -11,6 +11,8 @@ class Import < ApplicationRecord
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
FALLBACK_TRANSACTION_NAME = "Imported transaction"
|
||||
|
||||
def publish_later
|
||||
ImportJob.perform_later(self)
|
||||
end
|
||||
@@ -36,7 +38,7 @@ class Import < ApplicationRecord
|
||||
end
|
||||
|
||||
def get_selected_header_for_field(field)
|
||||
column_mappings&.dig(field) || field.key
|
||||
column_mappings&.dig(field.key) || field.key
|
||||
end
|
||||
|
||||
def update_csv!(row_idx:, col_idx:, value:)
|
||||
@@ -54,6 +56,8 @@ class Import < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
self.account.sync
|
||||
|
||||
update!(status: "complete")
|
||||
rescue => e
|
||||
update!(status: "failed")
|
||||
@@ -108,14 +112,27 @@ class Import < ApplicationRecord
|
||||
|
||||
def generate_transactions
|
||||
transactions = []
|
||||
category_cache = {}
|
||||
tag_cache = {}
|
||||
|
||||
csv.table.each do |row|
|
||||
category = account.family.transaction_categories.find_or_initialize_by(name: row["category"])
|
||||
category_name = row["category"].presence
|
||||
tag_strings = row["tags"].presence&.split("|") || []
|
||||
tags = []
|
||||
|
||||
tag_strings.each do |tag_string|
|
||||
tags << tag_cache[tag_string] ||= account.family.tags.find_or_initialize_by(name: tag_string)
|
||||
end
|
||||
|
||||
category = category_cache[category_name] ||= account.family.categories.find_or_initialize_by(name: category_name) if category_name.present?
|
||||
|
||||
txn = account.transactions.build \
|
||||
name: row["name"] || "Imported transaction",
|
||||
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
|
||||
date: Date.iso8601(row["date"]),
|
||||
category: category,
|
||||
amount: BigDecimal(row["amount"]) * -1 # User inputs amounts with opposite signage of our internal representation
|
||||
tags: tags,
|
||||
amount: BigDecimal(row["amount"]) * -1, # User inputs amounts with opposite signage of our internal representation
|
||||
currency: account.currency
|
||||
|
||||
transactions << txn
|
||||
end
|
||||
@@ -131,18 +148,25 @@ class Import < ApplicationRecord
|
||||
|
||||
name_field = Import::Field.new \
|
||||
key: "name",
|
||||
label: "Name"
|
||||
label: "Name",
|
||||
is_optional: true
|
||||
|
||||
category_field = Import::Field.new \
|
||||
key: "category",
|
||||
label: "Category"
|
||||
label: "Category",
|
||||
is_optional: true
|
||||
|
||||
tags_field = Import::Field.new \
|
||||
key: "tags",
|
||||
label: "Tags",
|
||||
is_optional: true
|
||||
|
||||
amount_field = Import::Field.new \
|
||||
key: "amount",
|
||||
label: "Amount",
|
||||
validator: ->(value) { Import::Field.bigdecimal_validator(value) }
|
||||
|
||||
[ date_field, name_field, category_field, amount_field ]
|
||||
[ date_field, name_field, category_field, tags_field, amount_field ]
|
||||
end
|
||||
|
||||
def define_column_mapping_keys
|
||||
|
||||
@@ -15,12 +15,17 @@ class Import::Field
|
||||
|
||||
attr_reader :key, :label, :validator
|
||||
|
||||
def initialize(key:, label:, validator: nil)
|
||||
def initialize(key:, label:, is_optional: false, validator: nil)
|
||||
@key = key.to_s
|
||||
@label = label
|
||||
@is_optional = is_optional
|
||||
@validator = validator
|
||||
end
|
||||
|
||||
def optional?
|
||||
@is_optional
|
||||
end
|
||||
|
||||
def define_validator(validator = nil, &block)
|
||||
@validator = validator || block
|
||||
end
|
||||
|
||||
7
app/models/institution.rb
Normal file
7
app/models/institution.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class Institution < ApplicationRecord
|
||||
belongs_to :family
|
||||
has_many :accounts, dependent: :nullify
|
||||
has_one_attached :logo
|
||||
|
||||
scope :alphabetically, -> { order(name: :asc) }
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Account::Investment < ApplicationRecord
|
||||
class Investment < ApplicationRecord
|
||||
include Accountable
|
||||
|
||||
SUBTYPES = [
|
||||
3
app/models/loan.rb
Normal file
3
app/models/loan.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Loan < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
10
app/models/merchant.rb
Normal file
10
app/models/merchant.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class Merchant < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||
belongs_to :family
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
end
|
||||
3
app/models/other_asset.rb
Normal file
3
app/models/other_asset.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class OtherAsset < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
3
app/models/other_liability.rb
Normal file
3
app/models/other_liability.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class OtherLiability < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
3
app/models/property.rb
Normal file
3
app/models/property.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Property < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -40,6 +40,26 @@ class Provider::Github
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_latest_releases_notes
|
||||
begin
|
||||
Rails.cache.fetch("latest_github_releases_notes", expires_in: 2.hours) do
|
||||
releases = Octokit.releases(repo)
|
||||
releases.map do |release|
|
||||
{
|
||||
avatar: release.author.avatar_url,
|
||||
name: release.name,
|
||||
published_at: release.published_at,
|
||||
body: Octokit.markdown(release.body, mode: "gfm", context: repo)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to fetch latest GitHub releases notes: #{e.message}"
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def repo
|
||||
"#{owner}/#{name}"
|
||||
|
||||
@@ -5,6 +5,10 @@ class Provider::Synth
|
||||
@api_key = api_key || ENV["SYNTH_API_KEY"]
|
||||
end
|
||||
|
||||
def configured?
|
||||
@api_key.present?
|
||||
end
|
||||
|
||||
def fetch_exchange_rate(from:, to:, date:)
|
||||
retrying Provider::Base.known_transient_errors do |on_last_attempt|
|
||||
response = Faraday.get("#{base_url}/rates/historical") do |req|
|
||||
|
||||
25
app/models/tag.rb
Normal file
25
app/models/tag.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class Tag < ApplicationRecord
|
||||
belongs_to :family
|
||||
has_many :taggings, dependent: :destroy
|
||||
has_many :transactions, through: :taggings, source: :taggable, source_type: "Account::Transaction"
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :family }
|
||||
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
UNCATEGORIZED_COLOR = "#737373"
|
||||
|
||||
def replace_and_destroy!(replacement)
|
||||
transaction do
|
||||
raise ActiveRecord::RecordInvalid, "Replacement tag cannot be the same as the tag being destroyed" if replacement == self
|
||||
|
||||
if replacement
|
||||
taggings.update_all tag_id: replacement.id
|
||||
end
|
||||
|
||||
destroy!
|
||||
end
|
||||
end
|
||||
end
|
||||
4
app/models/tagging.rb
Normal file
4
app/models/tagging.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class Tagging < ApplicationRecord
|
||||
belongs_to :tag
|
||||
belongs_to :taggable, polymorphic: true
|
||||
end
|
||||
@@ -1,16 +1,15 @@
|
||||
class TimeSeries::Trend
|
||||
include ActiveModel::Validations
|
||||
|
||||
attr_reader :current, :previous
|
||||
|
||||
delegate :favorable_direction, to: :series
|
||||
attr_reader :current, :previous, :favorable_direction
|
||||
|
||||
validate :values_must_be_of_same_type, :values_must_be_of_known_type
|
||||
|
||||
def initialize(current:, previous:, series: nil)
|
||||
def initialize(current:, previous:, series: nil, favorable_direction: nil)
|
||||
@current = current
|
||||
@previous = previous
|
||||
@series = series
|
||||
@favorable_direction = get_favorable_direction(favorable_direction)
|
||||
|
||||
validate!
|
||||
end
|
||||
@@ -25,6 +24,17 @@ class TimeSeries::Trend
|
||||
end.inquiry
|
||||
end
|
||||
|
||||
def color
|
||||
case direction
|
||||
when "up"
|
||||
favorable_direction.down? ? red_hex : green_hex
|
||||
when "down"
|
||||
favorable_direction.down? ? green_hex : red_hex
|
||||
else
|
||||
gray_hex
|
||||
end
|
||||
end
|
||||
|
||||
def value
|
||||
if previous.nil?
|
||||
current.is_a?(Money) ? Money.new(0) : 0
|
||||
@@ -56,8 +66,21 @@ class TimeSeries::Trend
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :series
|
||||
|
||||
def red_hex
|
||||
"#F13636" # red-500
|
||||
end
|
||||
|
||||
def green_hex
|
||||
"#10A861" # green-600
|
||||
end
|
||||
|
||||
def gray_hex
|
||||
"#737373" # gray-500
|
||||
end
|
||||
|
||||
def values_must_be_of_same_type
|
||||
unless current.class == previous.class || [ previous, current ].any?(&:nil?)
|
||||
errors.add :current, "must be of the same type as previous"
|
||||
@@ -90,4 +113,9 @@ class TimeSeries::Trend
|
||||
obj
|
||||
end
|
||||
end
|
||||
|
||||
def get_favorable_direction(favorable_direction)
|
||||
direction = favorable_direction.presence || series&.favorable_direction
|
||||
(direction.presence_in(TimeSeries::DIRECTIONS) || "up").inquiry
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
class Transaction < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :category, optional: true
|
||||
belongs_to :merchant, optional: true
|
||||
|
||||
validates :name, :date, :amount, :account, presence: true
|
||||
|
||||
monetize :amount
|
||||
|
||||
scope :inflows, -> { where("amount <= 0") }
|
||||
scope :outflows, -> { where("amount > 0") }
|
||||
scope :active, -> { where(excluded: false) }
|
||||
scope :with_converted_amount, ->(currency = Current.family.currency) {
|
||||
# Join with exchange rates to convert the amount to the given currency
|
||||
# If no rate is available, exclude the transaction from the results
|
||||
select(
|
||||
"transactions.*",
|
||||
"transactions.amount * COALESCE(er.rate, 1) AS converted_amount"
|
||||
)
|
||||
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON transactions.date = er.date AND transactions.currency = er.base_currency AND er.converted_currency = ?", currency ]))
|
||||
.where("er.rate IS NOT NULL OR transactions.currency = ?", currency)
|
||||
}
|
||||
|
||||
def self.daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Sum spending and income for each day in the period with the given currency
|
||||
select(
|
||||
"gs.date",
|
||||
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
|
||||
)
|
||||
.from(transactions.with_converted_amount(currency), :t)
|
||||
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ]))
|
||||
.group("gs.date")
|
||||
end
|
||||
|
||||
def self.daily_rolling_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Extend the period to include the rolling window
|
||||
period_with_rolling = period.extend_backward(period.date_range.count.days)
|
||||
|
||||
# Aggregate the rolling sum of spending and income based on daily totals
|
||||
rolling_totals = from(daily_totals(transactions, period: period_with_rolling, currency: currency))
|
||||
.select(
|
||||
"*",
|
||||
sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
|
||||
sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
|
||||
)
|
||||
.order("date")
|
||||
|
||||
# Trim the results to the original period
|
||||
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
|
||||
end
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[name amount date]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[category merchant account]
|
||||
end
|
||||
|
||||
def self.build_filter_list(params, family)
|
||||
filters = []
|
||||
|
||||
date_filters = { gteq: nil, lteq: nil }
|
||||
|
||||
if params
|
||||
params.each do |key, value|
|
||||
next if value.blank?
|
||||
|
||||
case key
|
||||
when "account_id_in"
|
||||
value.each do |account_id|
|
||||
filters << { type: "account", value: family.accounts.find(account_id), original: { key: key, value: account_id } }
|
||||
end
|
||||
when "category_id_in"
|
||||
value.each do |category_id|
|
||||
filters << { type: "category", value: family.transaction_categories.find(category_id), original: { key: key, value: category_id } }
|
||||
end
|
||||
when "merchant_id_in"
|
||||
value.each do |merchant_id|
|
||||
filters << { type: "merchant", value: family.transaction_merchants.find(merchant_id), original: { key: key, value: merchant_id } }
|
||||
end
|
||||
when "category_name_or_merchant_name_or_account_name_or_name_cont"
|
||||
filters << { type: "search", value: value, original: { key: key, value: nil } }
|
||||
when "date_gteq"
|
||||
date_filters[:gteq] = value
|
||||
when "date_lteq"
|
||||
date_filters[:lteq] = value
|
||||
end
|
||||
end
|
||||
|
||||
unless date_filters.values.compact.empty?
|
||||
filters << { type: "date_range", value: date_filters, original: { key: "date_range", value: nil } }
|
||||
end
|
||||
end
|
||||
|
||||
filters
|
||||
end
|
||||
end
|
||||
@@ -1,18 +0,0 @@
|
||||
class Transaction::Merchant < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify
|
||||
belongs_to :family
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[name id]
|
||||
end
|
||||
|
||||
def self.ransackable_associations(auth_object = nil)
|
||||
%w[]
|
||||
end
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
class Valuation < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
belongs_to :account
|
||||
validates :account, :date, :value, presence: true
|
||||
monetize :value
|
||||
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
|
||||
def self.to_series
|
||||
TimeSeries.from_collection all, :value_money
|
||||
end
|
||||
end
|
||||
3
app/models/vehicle.rb
Normal file
3
app/models/vehicle.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class Vehicle < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
|
Before Width: | Height: | Size: 653 B After Width: | Height: | Size: 653 B |
1
app/views/account/transaction/rows/show.html.erb
Normal file
1
app/views/account/transaction/rows/show.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
<%= render "account/transactions/transaction", transaction: @transaction %>
|
||||
@@ -1,6 +1,7 @@
|
||||
<% content_for :sidebar do %>
|
||||
<%= render "settings/nav" %>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-gray-900 text-xl font-medium mb-4">Rules</h1>
|
||||
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
|
||||
@@ -9,7 +10,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<%= previous_setting("Merchants", transaction_merchants_path) %>
|
||||
<%= previous_setting("Merchants", merchants_path) %>
|
||||
<%= next_setting("Imports", imports_path) %>
|
||||
</div>
|
||||
</div>
|
||||
4
app/views/account/transactions/_empty.html.erb
Normal file
4
app/views/account/transactions/_empty.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="flex flex-col items-center justify-center py-40">
|
||||
<p class="text-gray-500 mb-2"><%= t(".title") %></p>
|
||||
<p class="text-gray-400 max-w-xs text-center"><%= t(".description") %></p>
|
||||
</div>
|
||||
5
app/views/account/transactions/_loading.html.erb
Normal file
5
app/views/account/transactions/_loading.html.erb
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||
<div class="p-5 flex justify-center items-center">
|
||||
<%= tag.p t(".loading"), class: "text-gray-500 animate-pulse text-sm" %>
|
||||
</div>
|
||||
</div>
|
||||
40
app/views/account/transactions/_selection_bar.html.erb
Normal file
40
app/views/account/transactions/_selection_bar.html.erb
Normal file
@@ -0,0 +1,40 @@
|
||||
<div class="fixed bottom-6 z-10 flex items-center justify-between rounded-xl bg-gray-900 px-4 text-sm text-white w-[420px] py-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= check_box_tag "transaction_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %>
|
||||
|
||||
<p data-bulk-select-target="selectionBarText"></p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 text-gray-500">
|
||||
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
|
||||
|
||||
<%= form_with url: mark_transfers_transactions_path,
|
||||
builder: ActionView::Helpers::FormBuilder,
|
||||
scope: "bulk_update",
|
||||
data: {
|
||||
turbo_frame: "_top",
|
||||
turbo_confirm: {
|
||||
title: t(".mark_transfers"),
|
||||
body: t(".mark_transfers_message"),
|
||||
accept: t(".mark_transfers_confirm"),
|
||||
}
|
||||
} do |f| %>
|
||||
<button id="bulk-transfer-btn" type="button" data-bulk-select-scope-param="bulk_update" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Mark as transfer">
|
||||
<%= lucide_icon "arrow-right-left", class: "w-5 group-hover:text-white" %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= link_to bulk_edit_transactions_path,
|
||||
class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md",
|
||||
title: "Edit",
|
||||
data: { turbo_frame: "bulk_transaction_edit_drawer" } do %>
|
||||
<%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %>
|
||||
<% end %>
|
||||
|
||||
<%= form_with url: bulk_delete_transactions_path, builder: ActionView::Helpers::FormBuilder, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
|
||||
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
|
||||
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
89
app/views/account/transactions/_transaction.html.erb
Normal file
89
app/views/account/transactions/_transaction.html.erb
Normal file
@@ -0,0 +1,89 @@
|
||||
<%# locals: (transaction:, selectable: true, editable: true, short: false, show_tags: false) %>
|
||||
<%= turbo_frame_tag dom_id(transaction), class: "grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4" do %>
|
||||
|
||||
<% name_col_span = transaction.transfer? ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
|
||||
<div class="pr-10 flex items-center gap-4 <%= name_col_span %>">
|
||||
<% if selectable %>
|
||||
<%= check_box_tag dom_id(transaction, "selection"),
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
data: { id: transaction.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
|
||||
<% end %>
|
||||
|
||||
<div class="max-w-full">
|
||||
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
|
||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
|
||||
<%= transaction.name[0].upcase %>
|
||||
</div>
|
||||
|
||||
<div class="truncate text-gray-900">
|
||||
<% if transaction.new_record? %>
|
||||
<%= content_tag :p, transaction.name %>
|
||||
<% else %>
|
||||
<%= link_to transaction.name,
|
||||
account_transaction_path(transaction.account, transaction),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if unconfirmed_transfer?(transaction) %>
|
||||
<% if editable %>
|
||||
<%= form_with url: unmark_transfers_transactions_path, builder: ActionView::Helpers::FormBuilder, class: "flex items-center", data: {
|
||||
turbo_confirm: {
|
||||
title: t(".remove_transfer"),
|
||||
body: t(".remove_transfer_body"),
|
||||
accept: t(".remove_transfer_confirm"),
|
||||
},
|
||||
turbo_frame: "_top"
|
||||
} do |f| %>
|
||||
<%= f.hidden_field "bulk_update[transaction_ids][]", value: transaction.id %>
|
||||
<%= f.button class: "flex items-center justify-center group", title: "Remove transfer" do %>
|
||||
<%= lucide_icon "arrow-left-right", class: "group-hover:hidden text-gray-500 w-4 h-4" %>
|
||||
<%= lucide_icon "unlink", class: "hidden group-hover:inline-block text-gray-900 w-4 h-4" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= lucide_icon "arrow-left-right", class: "text-gray-500 w-4 h-4" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% unless transaction.transfer? %>
|
||||
<% unless short %>
|
||||
<div class="flex items-center gap-1 <%= show_tags ? "col-span-6" : "col-span-3" %>">
|
||||
<% if editable %>
|
||||
<%= render "categories/menu", transaction: transaction %>
|
||||
<% else %>
|
||||
<%= render "categories/badge", category: transaction.category %>
|
||||
<% end %>
|
||||
|
||||
<% if show_tags %>
|
||||
<% transaction.tags.each do |tag| %>
|
||||
<%= render partial: "tags/badge", locals: { tag: tag } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% unless show_tags %>
|
||||
<%= tag.div class: short ? "col-span-4" : "col-span-3" do %>
|
||||
<% if transaction.new_record? %>
|
||||
<%= tag.p transaction.account.name %>
|
||||
<% else %>
|
||||
<%= link_to transaction.account.name,
|
||||
account_path(transaction.account, tab: "transactions"),
|
||||
data: { turbo_frame: "_top" },
|
||||
class: "hover:underline" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="col-span-2 ml-auto">
|
||||
<%= content_tag :p,
|
||||
format_money(-transaction.amount_money),
|
||||
class: ["text-green-600": transaction.inflow?] %>
|
||||
</div>
|
||||
<% end %>
|
||||
21
app/views/account/transactions/_transaction_group.html.erb
Normal file
21
app/views/account/transactions/_transaction_group.html.erb
Normal file
@@ -0,0 +1,21 @@
|
||||
<%# locals: (date:, transactions:, transfers: [], selectable: true, **transaction_opts) %>
|
||||
<div id="date-group-<%= date %>" class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
|
||||
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<div class="flex pl-0.5 items-center gap-4">
|
||||
<% if selectable %>
|
||||
<%= check_box_tag "#{date}_transactions_selection",
|
||||
class: ["maybe-checkbox maybe-checkbox--light", "hidden": transactions.count == 0],
|
||||
id: "selection_transaction_#{date}",
|
||||
data: { action: "bulk-select#toggleGroupSelection" } %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.span "#{date.strftime('%b %d, %Y')} · #{transactions.size + (transfers.size * 2)}" %>
|
||||
</div>
|
||||
|
||||
<%= totals_by_currency(collection: transactions, money_method: :amount_money, negate: true) %>
|
||||
</div>
|
||||
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
||||
<%= render transactions, selectable:, **transaction_opts.except(:selectable) %>
|
||||
<%= render transfers %>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user