Compare commits
213 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0e0c2bf62 | ||
|
|
fa08f027c7 | ||
|
|
b200b71284 | ||
|
|
ef0f910b9b | ||
|
|
e9f42c1a65 | ||
|
|
e51806b98b | ||
|
|
47523f64c2 | ||
|
|
d0bc959bee | ||
|
|
cdbca5aff3 | ||
|
|
41f9e23f8c | ||
|
|
12123449b7 | ||
|
|
a70c6666dc | ||
|
|
1bd5397701 | ||
|
|
37d5c149ba | ||
|
|
744ffb68aa | ||
|
|
34e03c2d6a | ||
|
|
b002a41b35 | ||
|
|
c6bdf49f10 | ||
|
|
de5a2e55b3 | ||
|
|
538b00712c | ||
|
|
2e56f5726e | ||
|
|
3c9cdb16f9 | ||
|
|
6d4c871f85 | ||
|
|
dd915c42ed | ||
|
|
0447d47a53 | ||
|
|
42dec4014e | ||
|
|
6767aaed1d | ||
|
|
bef335c631 | ||
|
|
3ffb6cb62b | ||
|
|
cea90252c8 | ||
|
|
36cccefb2a | ||
|
|
cc6bf6e961 | ||
|
|
48092cb704 | ||
|
|
cf23453d93 | ||
|
|
f1d0a62ac7 | ||
|
|
3089e3c81d | ||
|
|
0593d8fb7e | ||
|
|
a8ea207d47 | ||
|
|
c225eb6d03 | ||
|
|
9b148316bc | ||
|
|
8e7fcfd0b4 | ||
|
|
c3314e62d1 | ||
|
|
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 | ||
|
|
5fa34b4111 | ||
|
|
22e6919eb5 | ||
|
|
ac46c0c5a9 | ||
|
|
0d0f766ca1 | ||
|
|
ddf26cd5e5 | ||
|
|
31ef3d85f5 | ||
|
|
1bbfdee463 | ||
|
|
45ae4a9737 | ||
|
|
3d9ff3ad2a | ||
|
|
daf7ff8ef4 | ||
|
|
5ed2c47c20 | ||
|
|
25a2156c8f | ||
|
|
9509a568ac | ||
|
|
d4857d9f5b | ||
|
|
943972690e | ||
|
|
b448446fbe | ||
|
|
61fae96832 | ||
|
|
2aee8e3027 | ||
|
|
fac995b87e | ||
|
|
d240c59d7f | ||
|
|
6b0ef3a471 | ||
|
|
1108e45596 | ||
|
|
9ede14d23b | ||
|
|
38d50fbb1e | ||
|
|
ee433ed7c8 | ||
|
|
79789bd696 | ||
|
|
1c2950462f | ||
|
|
62b7ada5e2 | ||
|
|
16e5ffaed8 | ||
|
|
2c1dcb8649 | ||
|
|
b977d0f623 | ||
|
|
930dc26828 | ||
|
|
0616d3e2b7 | ||
|
|
9bae455f18 | ||
|
|
4f508cd151 | ||
|
|
9563ac6334 | ||
|
|
75cdddc6ca | ||
|
|
5dfbba403a | ||
|
|
98df7ccb11 | ||
|
|
4c5f8263bc | ||
|
|
dc024d63b0 | ||
|
|
19ee773d9b | ||
|
|
55cb1ae5bd | ||
|
|
6fdb8e8d69 | ||
|
|
f0480e7ab7 | ||
|
|
1a565137fb | ||
|
|
e65e61c974 | ||
|
|
66c49a37ef | ||
|
|
9549182462 | ||
|
|
7f491f5064 | ||
|
|
93953499a6 | ||
|
|
e7fe1b5a4b | ||
|
|
8ea7b54fe8 | ||
|
|
da5021b6b0 | ||
|
|
be21d2b4fd | ||
|
|
5a5f13b46b | ||
|
|
ad4de99f1a | ||
|
|
b3f8ab78d9 | ||
|
|
461fa672ff | ||
|
|
1f6e83ee91 | ||
|
|
8a29725562 | ||
|
|
49b603f478 | ||
|
|
070084078a | ||
|
|
594bd6282f | ||
|
|
4d255e5670 | ||
|
|
11d00a648a | ||
|
|
ce1840d846 | ||
|
|
fc3ade392a | ||
|
|
fe2a2ac3f9 | ||
|
|
5be1ced19e | ||
|
|
c46662c99f | ||
|
|
0277bc94f3 | ||
|
|
0a1fa525d5 | ||
|
|
f5f624881f | ||
|
|
4708e85da3 | ||
|
|
9bda7efc3f | ||
|
|
39d57a167e | ||
|
|
cd8d741fe1 | ||
|
|
a22c7a0e9c | ||
|
|
5516b03b6e | ||
|
|
3672835ba1 | ||
|
|
be288afcd4 | ||
|
|
81115a9bed | ||
|
|
eee07a4d6c | ||
|
|
fc631e698d |
@@ -1,27 +1,5 @@
|
||||
# ARG RUBY_VERSION=3.3.0
|
||||
# FROM ruby:${RUBY_VERSION}-slim-bullseye
|
||||
|
||||
# TODO - Uncomment the lines above when 3.3.1 is released.
|
||||
# This is a temporary fix for a bug found here (https://stackoverflow.com/questions/77725755/segmentation-fault-during-rails-assetsprecompile-on-apple-silicon-m3-with-rub)
|
||||
|
||||
FROM debian:bullseye-slim as base
|
||||
|
||||
# Install dependencies for building Ruby
|
||||
RUN apt-get update && apt-get install -y build-essential wget autoconf
|
||||
|
||||
# Install ruby-install for installing Ruby
|
||||
RUN wget https://github.com/postmodern/ruby-install/releases/download/v0.9.3/ruby-install-0.9.3.tar.gz \
|
||||
&& tar -xzvf ruby-install-0.9.3.tar.gz \
|
||||
&& cd ruby-install-0.9.3/ \
|
||||
&& make install
|
||||
|
||||
# Install Ruby 3.3.0 with the https://github.com/ruby/ruby/pull/9371 patch
|
||||
RUN ruby-install -p https://github.com/ruby/ruby/pull/9371.diff ruby 3.3.0
|
||||
|
||||
# Make the Ruby binary available on the PATH
|
||||
ENV PATH="/opt/rubies/ruby-3.3.0/bin:${PATH}"
|
||||
|
||||
# End TODO
|
||||
ARG RUBY_VERSION=3.3.1
|
||||
FROM ruby:${RUBY_VERSION}-slim-bullseye
|
||||
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get -y install --no-install-recommends \
|
||||
|
||||
36
.env.example
36
.env.example
@@ -1,3 +1,7 @@
|
||||
# Custom port config
|
||||
# For users who have other applications listening at 3000, this allows them to set a value puma will listen to.
|
||||
PORT=
|
||||
|
||||
# Exchange Rate API
|
||||
# This is used to convert between different currencies in the app. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
|
||||
SYNTH_API_KEY=
|
||||
@@ -9,7 +13,10 @@ SMTP_ADDRESS=
|
||||
SMTP_PORT=465
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
TLS=true
|
||||
SMTP_TLS_ENABLED=true
|
||||
|
||||
# Email Configuration
|
||||
EMAIL_SENDER=
|
||||
|
||||
# Database Configuration
|
||||
DB_HOST=localhost # May need to be changed to `DB_HOST=db` if using devcontainer
|
||||
@@ -36,6 +43,13 @@ SELF_HOSTING_ENABLED=false
|
||||
# `localhost` (or unset) is used for local development and testing
|
||||
HOSTING_PLATFORM=localhost
|
||||
|
||||
# Secret key used to encrypt credentials (https://api.rubyonrails.org/v7.1.3.2/classes/Rails/Application.html#method-i-secret_key_base)
|
||||
# Has to be a random string, generated eg. by running `openssl rand -hex 64`
|
||||
SECRET_KEY_BASE=secret-value
|
||||
|
||||
# Disable enforcing SSL connections
|
||||
# DISABLE_SSL=true
|
||||
|
||||
# ======================================================================================================
|
||||
# Upgrades Module - responsible for triggering upgrade alerts, prompts, and auto-upgrade functionality
|
||||
# ======================================================================================================
|
||||
@@ -44,7 +58,7 @@ HOSTING_PLATFORM=localhost
|
||||
# UPGRADES_MODE: Controls how the app will upgrade. `manual` means the user must manually upgrade the app. `auto` means the app will upgrade automatically (great for self-hosting)
|
||||
# UPGRADES_TARGET: Controls what the app will upgrade to. `release` means the app will upgrade to the latest release. `commit` means the app will upgrade to the latest commit.
|
||||
#
|
||||
UPGRADES_ENABLED=false # unless editing the flow, you should keep this `false` locally in development
|
||||
UPGRADES_ENABLED=false # unless editing the flow, you should keep this `false` locally in development
|
||||
UPGRADES_MODE=manual # `manual` or `auto`
|
||||
UPGRADES_TARGET=release # `release` or `commit`
|
||||
|
||||
@@ -53,6 +67,22 @@ UPGRADES_TARGET=release # `release` or `commit`
|
||||
# Git Repository Module - responsible for fetching latest commit data for upgrades
|
||||
# ======================================================================================================
|
||||
#
|
||||
GITHUB_REPO_OWNER=maybe-finance
|
||||
GITHUB_REPO_OWNER=maybe-finance
|
||||
GITHUB_REPO_NAME=maybe
|
||||
GITHUB_REPO_BRANCH=main
|
||||
|
||||
# ======================================================================================================
|
||||
# Active Storage Configuration - responsible for storing file uploads
|
||||
# ======================================================================================================
|
||||
#
|
||||
# * Defaults to disk storage but you can also use Amazon S3, Google Cloud Storage, or Microsoft Azure Storage.
|
||||
# * Set the appropriate environment variables to use these services.
|
||||
# * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips
|
||||
#
|
||||
# Amazon S3
|
||||
# ==========
|
||||
# ACTIVE_STORAGE_SERVICE=amazon
|
||||
# S3_ACCESS_KEY_ID=
|
||||
# S3_SECRET_ACCESS_KEY=
|
||||
# S3_REGION= # defaults to `us-east-1` if not set
|
||||
# S3_BUCKET=
|
||||
|
||||
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.
|
||||
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: 'Bug: '
|
||||
labels: ":bug: Bug"
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots / Recordings**
|
||||
If applicable, add screenshots or short video recordings to help show the bug in more detail.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem 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**
|
||||
27
.github/workflows/ci.yml
vendored
27
.github/workflows/ci.yml
vendored
@@ -1,9 +1,7 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
scan_ruby:
|
||||
@@ -61,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
|
||||
@@ -71,12 +73,6 @@ jobs:
|
||||
- 5432:5432
|
||||
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
|
||||
# redis:
|
||||
# image: redis
|
||||
# ports:
|
||||
# - 6379:6379
|
||||
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
|
||||
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
|
||||
@@ -90,17 +86,18 @@ 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
|
||||
# REDIS_URL: redis://localhost:6379/0
|
||||
- name: DB setup and smoke test
|
||||
run: |
|
||||
bin/rails db:create
|
||||
bin/rails db:schema:load
|
||||
bin/rails test:all
|
||||
bin/rails db:seed
|
||||
|
||||
- name: Unit and integration tests
|
||||
run: bin/rails test
|
||||
|
||||
- name: System tests
|
||||
run: DISABLE_PARALLELIZATION=true bin/rails test:system
|
||||
|
||||
- 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
|
||||
73
.github/workflows/publish.yml
vendored
Normal file
73
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
build:
|
||||
name: Build docker image
|
||||
needs: [ ci ]
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
flavor: latest=auto
|
||||
tags: |
|
||||
type=sha,format=long
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
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,linux/arm/v7
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
# https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#adding-a-description-to-multi-arch-images
|
||||
outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=A multi-arch Docker image for the Maybe Rails app
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -50,3 +50,11 @@
|
||||
|
||||
# Ignore .devcontainer files
|
||||
compose-dev.yaml
|
||||
|
||||
# Ignore asdf ruby version file
|
||||
.tool-versions
|
||||
|
||||
# Ignore GCP keyfile
|
||||
gcp-storage-keyfile.json
|
||||
|
||||
coverage
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.3.0
|
||||
3.3.1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
|
||||
ARG RUBY_VERSION=3.3.0
|
||||
ARG RUBY_VERSION=3.3.1
|
||||
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
|
||||
|
||||
# Rails app lives here
|
||||
|
||||
32
Gemfile
32
Gemfile
@@ -3,11 +3,10 @@ source "https://rubygems.org"
|
||||
ruby file: ".ruby-version"
|
||||
|
||||
# Rails
|
||||
gem "rails", github: "rails/rails", branch: "main"
|
||||
gem "rails", github: "rails/rails", branch: "7-2-stable"
|
||||
|
||||
# Drivers
|
||||
gem "pg", "~> 1.5"
|
||||
gem "redis", ">= 4.0.1"
|
||||
|
||||
# Deployment
|
||||
gem "puma", ">= 5.0"
|
||||
@@ -26,37 +25,42 @@ gem "turbo-rails"
|
||||
# Background Jobs
|
||||
gem "good_job"
|
||||
|
||||
# Search
|
||||
gem "ransack"
|
||||
|
||||
# Error logging
|
||||
gem "stackprof"
|
||||
gem "sentry-ruby"
|
||||
gem "sentry-rails"
|
||||
gem "rails-settings-cached"
|
||||
gem "octokit"
|
||||
|
||||
# Active Storage
|
||||
gem "aws-sdk-s3", require: false
|
||||
gem "image_processing", ">= 1.2"
|
||||
|
||||
# Other
|
||||
gem "bcrypt", "~> 3.1.7"
|
||||
gem "inline_svg"
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
gem "bcrypt", "~> 3.1"
|
||||
gem "faraday"
|
||||
gem "faraday-retry"
|
||||
gem "inline_svg"
|
||||
gem "octokit"
|
||||
gem "pagy"
|
||||
gem "rails-settings-cached"
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
gem "csv"
|
||||
gem "redcarpet"
|
||||
|
||||
group :development, :test do
|
||||
gem "debug", platforms: %i[ mri windows ]
|
||||
gem "brakeman", require: false
|
||||
gem "rubocop-rails-omakase", require: false
|
||||
gem "dotenv-rails"
|
||||
gem "letter_opener"
|
||||
gem "i18n-tasks"
|
||||
gem "erb_lint"
|
||||
end
|
||||
|
||||
group :development do
|
||||
gem "web-console"
|
||||
gem "dotenv-rails"
|
||||
gem "hotwire-livereload"
|
||||
gem "letter_opener"
|
||||
gem "ruby-lsp-rails"
|
||||
gem "web-console"
|
||||
gem "faker"
|
||||
end
|
||||
|
||||
group :test do
|
||||
@@ -65,4 +69,6 @@ group :test do
|
||||
gem "mocha"
|
||||
gem "vcr"
|
||||
gem "webmock"
|
||||
gem "climate_control"
|
||||
gem "simplecov", require: false
|
||||
end
|
||||
|
||||
357
Gemfile.lock
357
Gemfile.lock
@@ -1,38 +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/rails/rails.git
|
||||
revision: bad7ff1664fb05cc227d0386ee3cbe4c292efe05
|
||||
branch: main
|
||||
revision: 8035bece705f60e6bddca70ee7d88e935a242bf8
|
||||
branch: 7-2-stable
|
||||
specs:
|
||||
actioncable (7.2.0.alpha)
|
||||
actionpack (= 7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
actioncable (7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
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.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activejob (= 7.2.0.beta3)
|
||||
activerecord (= 7.2.0.beta3)
|
||||
activestorage (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
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.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
actionview (= 7.2.0.beta3)
|
||||
activejob (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
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.beta3)
|
||||
actionview (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
@@ -41,61 +41,62 @@ 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.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activerecord (= 7.2.0.beta3)
|
||||
activestorage (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
actionview (7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
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.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
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.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
activerecord (7.2.0.beta3)
|
||||
activemodel (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
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.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activejob (= 7.2.0.beta3)
|
||||
activerecord (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.0.alpha)
|
||||
activesupport (7.2.0.beta3)
|
||||
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 (>= 1.4.2)
|
||||
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.beta3)
|
||||
actioncable (= 7.2.0.beta3)
|
||||
actionmailbox (= 7.2.0.beta3)
|
||||
actionmailer (= 7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
actiontext (= 7.2.0.beta3)
|
||||
actionview (= 7.2.0.beta3)
|
||||
activejob (= 7.2.0.beta3)
|
||||
activemodel (= 7.2.0.beta3)
|
||||
activerecord (= 7.2.0.beta3)
|
||||
activestorage (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.0.alpha)
|
||||
railties (7.2.0.alpha)
|
||||
actionpack (= 7.2.0.alpha)
|
||||
activesupport (= 7.2.0.alpha)
|
||||
irb
|
||||
railties (= 7.2.0.beta3)
|
||||
railties (7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
@@ -107,22 +108,38 @@ GEM
|
||||
addressable (2.8.6)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.952.0)
|
||||
aws-sdk-core (3.201.1)
|
||||
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.88.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.156.0)
|
||||
aws-sdk-core (~> 3, >= 3.201.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.8.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
better_html (2.0.2)
|
||||
better_html (2.1.1)
|
||||
actionview (>= 6.0)
|
||||
activesupport (>= 6.0)
|
||||
ast (~> 2.0)
|
||||
erubi (~> 1.4)
|
||||
parser (>= 2.4)
|
||||
smart_properties
|
||||
bigdecimal (3.1.7)
|
||||
bigdecimal (3.1.8)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.3)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (6.1.2)
|
||||
racc
|
||||
builder (3.2.4)
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
addressable
|
||||
matrix
|
||||
@@ -133,19 +150,22 @@ GEM
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
childprocess (5.0.0)
|
||||
concurrent-ruby (1.2.3)
|
||||
climate_control (1.2.0)
|
||||
concurrent-ruby (1.3.3)
|
||||
connection_pool (2.4.1)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
csv (3.3.0)
|
||||
date (3.3.4)
|
||||
debug (1.9.2)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
dotenv (3.1.0)
|
||||
dotenv-rails (3.1.0)
|
||||
dotenv (= 3.1.0)
|
||||
docile (1.4.0)
|
||||
dotenv (3.1.2)
|
||||
dotenv-rails (3.1.2)
|
||||
dotenv (= 3.1.2)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.1)
|
||||
erb_lint (0.5.0)
|
||||
@@ -155,38 +175,42 @@ GEM
|
||||
rainbow
|
||||
rubocop
|
||||
smart_properties
|
||||
erubi (1.12.0)
|
||||
erubi (1.13.0)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
faraday (2.9.0)
|
||||
faker (3.4.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.10.0)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
logger
|
||||
faraday-net_http (3.1.0)
|
||||
net-http
|
||||
faraday-retry (2.2.1)
|
||||
faraday (~> 2.0)
|
||||
ffi (1.16.3)
|
||||
fugit (1.10.1)
|
||||
et-orbi (~> 1, >= 1.2.7)
|
||||
fugit (1.11.0)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (3.27.4)
|
||||
activejob (>= 6.0.0)
|
||||
activerecord (>= 6.0.0)
|
||||
concurrent-ruby (>= 1.0.2)
|
||||
fugit (>= 1.1)
|
||||
railties (>= 6.0.0)
|
||||
thor (>= 0.14.1)
|
||||
good_job (4.0.3)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
fugit (>= 1.11.0)
|
||||
railties (>= 6.1.0)
|
||||
thor (>= 1.0.0)
|
||||
hashdiff (1.1.0)
|
||||
highline (3.0.1)
|
||||
hotwire-livereload (1.3.2)
|
||||
hotwire-livereload (1.4.0)
|
||||
actioncable (>= 6.0.0)
|
||||
listen (>= 3.0.0)
|
||||
railties (>= 6.0.0)
|
||||
i18n (1.14.4)
|
||||
i18n (1.14.5)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-tasks (1.0.13)
|
||||
i18n-tasks (1.0.14)
|
||||
activesupport (>= 4.0.2)
|
||||
ast (>= 2.1.0)
|
||||
better_html (>= 1.0, < 3.0)
|
||||
erubi
|
||||
highline (>= 2.0.0)
|
||||
i18n
|
||||
@@ -194,6 +218,9 @@ GEM
|
||||
rails-i18n
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
terminal-table (>= 1.5.1)
|
||||
image_processing (1.12.2)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
importmap-rails (2.0.1)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
@@ -202,12 +229,13 @@ GEM
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.7.2)
|
||||
irb (1.12.0)
|
||||
rdoc
|
||||
irb (1.14.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
json (2.7.1)
|
||||
jmespath (1.6.2)
|
||||
json (2.7.2)
|
||||
language_server-protocol (3.17.0.3)
|
||||
launchy (3.0.0)
|
||||
launchy (3.0.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
letter_opener (1.10.0)
|
||||
@@ -215,6 +243,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)
|
||||
@@ -225,14 +254,15 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.21.2)
|
||||
mocha (2.1.0)
|
||||
minitest (5.24.1)
|
||||
mocha (2.4.0)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.7.2)
|
||||
net-http (0.4.1)
|
||||
uri
|
||||
net-imap (0.4.10)
|
||||
net-imap (0.4.14)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -241,43 +271,42 @@ GEM
|
||||
timeout
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.1)
|
||||
nokogiri (1.16.3-aarch64-linux)
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.6-aarch64-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.3-arm-linux)
|
||||
nokogiri (1.16.6-arm-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.3-arm64-darwin)
|
||||
nokogiri (1.16.6-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.3-x86-linux)
|
||||
nokogiri (1.16.6-x86-linux)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.3-x86_64-darwin)
|
||||
nokogiri (1.16.6-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.3-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.0.2)
|
||||
pagy (8.6.3)
|
||||
parallel (1.24.0)
|
||||
parser (3.3.0.5)
|
||||
parser (3.3.1.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.6)
|
||||
prism (0.24.0)
|
||||
propshaft (0.8.0)
|
||||
prism (0.30.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.4)
|
||||
public_suffix (5.1.0)
|
||||
puma (6.4.2)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.7.3)
|
||||
rack (3.0.10)
|
||||
racc (1.8.0)
|
||||
rack (3.1.7)
|
||||
rack-session (2.0.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
@@ -292,7 +321,7 @@ GEM
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
rails-i18n (7.0.8)
|
||||
rails-i18n (7.0.9)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 6.0.0, < 8)
|
||||
rails-settings-cached (2.9.4)
|
||||
@@ -300,24 +329,20 @@ GEM
|
||||
railties (>= 5.0.0)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
ransack (4.1.1)
|
||||
activerecord (>= 6.1.5)
|
||||
activesupport (>= 6.1.5)
|
||||
i18n
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
rdoc (6.6.3.1)
|
||||
rbs (3.5.2)
|
||||
logger
|
||||
rdoc (6.7.0)
|
||||
psych (>= 4.0.0)
|
||||
redis (5.1.0)
|
||||
redis-client (>= 0.17.0)
|
||||
redis-client (0.19.1)
|
||||
connection_pool
|
||||
regexp_parser (2.9.0)
|
||||
reline (0.5.0)
|
||||
redcarpet (3.6.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.9)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.2.6)
|
||||
rubocop (1.60.2)
|
||||
rexml (3.3.0)
|
||||
strscan
|
||||
rubocop (1.63.5)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
@@ -325,69 +350,79 @@ GEM
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.30.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.30.0)
|
||||
parser (>= 3.2.1.0)
|
||||
rubocop-minitest (0.34.5)
|
||||
rubocop (>= 1.39, < 2.0)
|
||||
rubocop-ast (>= 1.30.0, < 2.0)
|
||||
rubocop-performance (1.20.2)
|
||||
rubocop-ast (1.31.3)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-minitest (0.35.0)
|
||||
rubocop (>= 1.61, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-performance (1.21.0)
|
||||
rubocop (>= 1.48.1, < 2.0)
|
||||
rubocop-ast (>= 1.30.0, < 2.0)
|
||||
rubocop-rails (2.23.1)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails (2.25.0)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.33.0, < 2.0)
|
||||
rubocop-ast (>= 1.30.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-rails-omakase (1.0.0)
|
||||
rubocop
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.16.2)
|
||||
ruby-lsp (0.17.7)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 0.22.0, < 0.25)
|
||||
prism (>= 0.29.0, < 0.31)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.3.5)
|
||||
ruby-lsp (>= 0.16.0, < 0.17.0)
|
||||
sorbet-runtime (>= 0.5.9897)
|
||||
ruby-lsp-rails (0.3.10)
|
||||
ruby-lsp (>= 0.17.2, < 0.18.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.1)
|
||||
ffi (~> 1.12)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
selenium-webdriver (4.19.0)
|
||||
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)
|
||||
sentry-rails (5.17.2)
|
||||
sentry-rails (5.18.1)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.17.2)
|
||||
sentry-ruby (5.17.2)
|
||||
sentry-ruby (~> 5.18.1)
|
||||
sentry-ruby (5.18.1)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.12.3)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11332)
|
||||
sorbet-runtime (0.5.11481)
|
||||
stackprof (0.2.26)
|
||||
stimulus-rails (1.3.3)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.0)
|
||||
tailwindcss-rails (2.3.0)
|
||||
railties (>= 6.0.0)
|
||||
tailwindcss-rails (2.3.0-aarch64-linux)
|
||||
railties (>= 6.0.0)
|
||||
tailwindcss-rails (2.3.0-arm-linux)
|
||||
railties (>= 6.0.0)
|
||||
tailwindcss-rails (2.3.0-arm64-darwin)
|
||||
railties (>= 6.0.0)
|
||||
tailwindcss-rails (2.3.0-x86_64-darwin)
|
||||
railties (>= 6.0.0)
|
||||
tailwindcss-rails (2.3.0-x86_64-linux)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.1)
|
||||
strscan (3.1.0)
|
||||
tailwindcss-rails (2.6.1)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-aarch64-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-arm-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-arm64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-x86_64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.6.1-x86_64-linux)
|
||||
railties (>= 7.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
thor (1.3.1)
|
||||
@@ -407,7 +442,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)
|
||||
@@ -418,7 +453,7 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.6.13)
|
||||
zeitwerk (2.6.16)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
@@ -429,17 +464,23 @@ PLATFORMS
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
bcrypt (~> 3.1.7)
|
||||
aws-sdk-s3
|
||||
bcrypt (~> 3.1)
|
||||
bootsnap
|
||||
brakeman
|
||||
capybara
|
||||
climate_control
|
||||
csv
|
||||
debug
|
||||
dotenv-rails
|
||||
erb_lint
|
||||
faker
|
||||
faraday
|
||||
faraday-retry
|
||||
good_job
|
||||
hotwire-livereload
|
||||
i18n-tasks
|
||||
image_processing (>= 1.2)
|
||||
importmap-rails
|
||||
inline_svg
|
||||
letter_opener
|
||||
@@ -452,13 +493,13 @@ DEPENDENCIES
|
||||
puma (>= 5.0)
|
||||
rails!
|
||||
rails-settings-cached
|
||||
ransack
|
||||
redis (>= 4.0.1)
|
||||
redcarpet
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
selenium-webdriver
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
simplecov
|
||||
stackprof
|
||||
stimulus-rails
|
||||
tailwindcss-rails
|
||||
@@ -469,7 +510,7 @@ DEPENDENCIES
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.0p0
|
||||
ruby 3.3.1p55
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.5
|
||||
2.5.9
|
||||
|
||||
79
README.md
79
README.md
@@ -3,38 +3,46 @@
|
||||
|
||||
# Maybe: The OS for your personal finances
|
||||
|
||||
<b>Get involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybe.co) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
||||
<b>Get
|
||||
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybe.co) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
||||
|
||||
_If you're looking for the previous React codebase, you can find it at [maybe-finance/maybe-archive](https://github.com/maybe-finance/maybe-archive)._
|
||||
_If you're looking for the previous React codebase, you can find it
|
||||
at [maybe-finance/maybe-archive](https://github.com/maybe-finance/maybe-archive)._
|
||||
|
||||
## Backstory
|
||||
|
||||
We spent the better part of 2021/2022 building a personal finance + wealth management app called, Maybe. Very full-featured, including an "Ask an Advisor" feature which connected users with an actual CFP/CFA to help them with their finances (all included in your subscription).
|
||||
We spent the better part of 2021/2022 building a personal finance + wealth
|
||||
management app called, Maybe. Very full-featured, including an "Ask an Advisor"
|
||||
feature which connected users with an actual CFP/CFA to help them with their
|
||||
finances (all included in your subscription).
|
||||
|
||||
The business end of things didn't work out, and so we shut things down mid-2023.
|
||||
|
||||
We spent the better part of $1,000,000 building the app (employees + contractors, data providers/services, infrastructure, etc.).
|
||||
We spent the better part of $1,000,000 building the app (employees +
|
||||
contractors, data providers/services, infrastructure, etc.).
|
||||
|
||||
We're now reviving the product as a fully open-source project. The goal is to let you run the app yourself, for free, and use it to manage your own finances and eventually offer a hosted version of the app for a small monthly fee.
|
||||
We're now reviving the product as a fully open-source project. The goal is to
|
||||
let you run the app yourself, for free, and use it to manage your own finances
|
||||
and eventually offer a hosted version of the app for a small monthly fee.
|
||||
|
||||
## Self Hosting
|
||||
## Maybe Hosting
|
||||
|
||||
You can find [detailed setup guides for self hosting here](docs/self-hosting.md).
|
||||
There are 3 primary ways to use the Maybe app:
|
||||
|
||||
### One-Click Render deploy (recommended)
|
||||
|
||||
<a href="https://render.com/deploy?repo=https://github.com/maybe-finance/maybe">
|
||||
<img src="https://render.com/images/deploy-to-render-button.svg" alt="Deploy to Render" />
|
||||
</a>
|
||||
|
||||
1. Click the button above
|
||||
2. Follow the instructions in the [Render self-hosting guide](docs/self-hosting/render.md)
|
||||
1. Managed (easiest) - _coming soon..._
|
||||
2. [One-click deploy](docs/hosting/one-click-deploy.md)
|
||||
3. [Self-host with Docker](docs/hosting/docker.md)
|
||||
|
||||
## 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 (see `Gemfile`)
|
||||
- Ruby 3.3.1
|
||||
- PostgreSQL >9.3 (ideally, latest stable version)
|
||||
|
||||
After cloning the repo, the basic setup commands are:
|
||||
@@ -49,7 +57,8 @@ bin/dev
|
||||
rake demo_data:reset
|
||||
```
|
||||
|
||||
And visit http://localhost:3000 to see the app. You can use the following credentials to log in (generated by DB seed):
|
||||
And visit http://localhost:3000 to see the app. You can use the following
|
||||
credentials to log in (generated by DB seed):
|
||||
|
||||
- Email: `user@maybe.local`
|
||||
- Password: `password`
|
||||
@@ -60,38 +69,52 @@ For further instructions, see guides below.
|
||||
|
||||
If you'd like multi-currency support, there are a few extra steps to follow.
|
||||
|
||||
1. Sign up for an API key at [Synth](https://synthfinance.com). It's a Maybe product and the free plan is sufficient for basic multi-currency support.
|
||||
1. Sign up for an API key at [Synth](https://synthfinance.com). It's a Maybe
|
||||
product and the free plan is sufficient for basic multi-currency support.
|
||||
2. Add your API key to your `.env` file.
|
||||
|
||||
### Setup Guides
|
||||
|
||||
#### Dev Container (optional)
|
||||
|
||||
This is 100% optional and meant for devs who don't want to worry about installing requirements manually for their platform. You can follow [this guide](https://code.visualstudio.com/docs/devcontainers/containers) to learn more about Dev Containers.
|
||||
This is 100% optional and meant for devs who don't want to worry about
|
||||
installing requirements manually for their platform. You can
|
||||
follow [this guide](https://code.visualstudio.com/docs/devcontainers/containers)
|
||||
to learn more about Dev Containers.
|
||||
|
||||
If you run into `could not connect to server` errors, you may need to change your `.env`'s `DB_HOST` environment variable value to `db` to point to the Postgres container.
|
||||
If you run into `could not connect to server` errors, you may need to change
|
||||
your `.env`'s `DB_HOST` environment variable value to `db` to point to the
|
||||
Postgres container.
|
||||
|
||||
#### Mac
|
||||
|
||||
Please visit our [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide).
|
||||
Please visit
|
||||
our [Mac dev setup guide](https://github.com/maybe-finance/maybe/wiki/Mac-Dev-Setup-Guide).
|
||||
|
||||
#### Linux
|
||||
|
||||
Please visit our [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide).
|
||||
Please visit
|
||||
our [Linux dev setup guide](https://github.com/maybe-finance/maybe/wiki/Linux-Dev-Setup-Guide).
|
||||
|
||||
#### Windows
|
||||
|
||||
Please visit our [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide).
|
||||
Please visit
|
||||
our [Windows dev setup guide](https://github.com/maybe-finance/maybe/wiki/Windows-Dev-Setup-Guide).
|
||||
|
||||
### Testing Emails
|
||||
|
||||
In development, we use `letter_opener` to automatically open emails in your browser. When an email sends locally, a new browser tab will open with a preview.
|
||||
In development, we use `letter_opener` to automatically open emails in your
|
||||
browser. When an email sends locally, a new browser tab will open with a
|
||||
preview.
|
||||
|
||||
## Contributing
|
||||
|
||||
Before contributing, you'll likely find it helpful to [understand context and general vision/direction](https://github.com/maybe-finance/maybe/wiki).
|
||||
Before contributing, you'll likely find it helpful
|
||||
to [understand context and general vision/direction](https://github.com/maybe-finance/maybe/wiki).
|
||||
|
||||
Once you've done that, please visit our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md) to get started!
|
||||
Once you've done that, please visit
|
||||
our [contributing guide](https://github.com/maybe-finance/maybe/blob/main/CONTRIBUTING.md)
|
||||
to get started!
|
||||
|
||||
## Repo Activity
|
||||
|
||||
@@ -99,4 +122,6 @@ Once you've done that, please visit our [contributing guide](https://github.com/
|
||||
|
||||
## Copyright & license
|
||||
|
||||
Maybe is distributed under an [AGPLv3 license](https://github.com/maybe-finance/maybe/blob/main/LICENSE). "Maybe" is a trademark of Maybe Finance, Inc.
|
||||
Maybe is distributed under
|
||||
an [AGPLv3 license](https://github.com/maybe-finance/maybe/blob/main/LICENSE). "
|
||||
Maybe" is a trademark of Maybe Finance, Inc.
|
||||
|
||||
BIN
app/assets/images/apple-logo.png
Normal file
BIN
app/assets/images/apple-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
BIN
app/assets/images/dark-mode-preview.png
Normal file
BIN
app/assets/images/dark-mode-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
app/assets/images/empower-logo.jpeg
Normal file
BIN
app/assets/images/empower-logo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
app/assets/images/light-mode-preview.png
Normal file
BIN
app/assets/images/light-mode-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
app/assets/images/mint-logo.jpeg
Normal file
BIN
app/assets/images/mint-logo.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
BIN
app/assets/images/system-mode-preview.png
Normal file
BIN
app/assets/images/system-mode-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -7,53 +7,89 @@
|
||||
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 bg-white rounded-xl shadow-sm;
|
||||
@apply focus-within:shadow-none focus-within:border-gray-900 focus-within:ring-4 focus-within:ring-gray-100;
|
||||
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full;
|
||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
@apply p-3 pb-0 block text-sm font-medium opacity-50;
|
||||
@apply block text-xs text-gray-500;
|
||||
}
|
||||
|
||||
.form-field__input {
|
||||
@apply p-3 w-full bg-transparent border-none opacity-100;
|
||||
@apply focus:outline-none focus:ring-0 focus:opacity-100;
|
||||
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
|
||||
@apply focus:opacity-100 focus:outline-none focus:ring-0;
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:opacity-50;
|
||||
}
|
||||
|
||||
.form-field__radio {
|
||||
@apply text-gray-900;
|
||||
}
|
||||
|
||||
.form-field__submit {
|
||||
@apply w-full p-3 text-center text-white bg-black rounded-lg 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");
|
||||
}
|
||||
|
||||
select[multiple="multiple"] {
|
||||
@apply py-2 pr-2 space-y-0.5;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option {
|
||||
@apply p-2 rounded-md;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option:checked {
|
||||
@apply bg-gray-50;
|
||||
@apply after:content-['\2713'] after:float-right after:text-gray-500;
|
||||
}
|
||||
|
||||
.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 */
|
||||
|
||||
105
app/controllers/account/entries_controller.rb
Normal file
105
app/controllers/account/entries_controller.rb
Normal file
@@ -0,0 +1,105 @@
|
||||
class Account::EntriesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_account
|
||||
before_action :set_entry, only: %i[ edit update show destroy ]
|
||||
|
||||
def transactions
|
||||
@transaction_entries = @account.entries.account_transactions.reverse_chronological
|
||||
end
|
||||
|
||||
def valuations
|
||||
@valuation_entries = @account.entries.account_valuations.reverse_chronological
|
||||
end
|
||||
|
||||
def new
|
||||
@entry = @account.entries.build.tap do |entry|
|
||||
if params[:entryable_type]
|
||||
entry.entryable = Account::Entryable.from_type(params[:entryable_type]).new
|
||||
else
|
||||
entry.entryable = Account::Valuation.new
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
@entry = @account.entries.build(entry_params_with_defaults(entry_params))
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
redirect_to account_path(@account), notice: t(".success", name: @entry.entryable_name_short.upcase_first)
|
||||
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[:alert] = @entry.errors.full_messages.to_sentence
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@entry.assign_attributes entry_params
|
||||
@entry.amount = amount if nature.present?
|
||||
@entry.save!
|
||||
@entry.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def destroy
|
||||
@entry.destroy!
|
||||
@entry.sync_account_later
|
||||
redirect_back_or_to account_url(@entry.account), notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = @account.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def permitted_entryable_attributes
|
||||
entryable_type = @entry ? @entry.entryable_class.to_s : params[:account_entry][:entryable_type]
|
||||
|
||||
case entryable_type
|
||||
when "Account::Transaction"
|
||||
[ :id, :notes, :excluded, :category_id, :merchant_id, tag_ids: [] ]
|
||||
else
|
||||
[ :id ]
|
||||
end
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: permitted_entryable_attributes)
|
||||
end
|
||||
|
||||
def amount
|
||||
if nature.income?
|
||||
entry_params[:amount].to_d.abs * -1
|
||||
else
|
||||
entry_params[:amount].to_d.abs
|
||||
end
|
||||
end
|
||||
|
||||
def nature
|
||||
params[:account_entry][:nature].to_s.inquiry
|
||||
end
|
||||
|
||||
# entryable_type is required here because Rails expects both of these params in this exact order (potential upstream bug)
|
||||
def entry_params_with_defaults(params)
|
||||
params.with_defaults(entryable_type: params[:entryable_type], entryable_attributes: {})
|
||||
end
|
||||
end
|
||||
10
app/controllers/account/logos_controller.rb
Normal file
10
app/controllers/account/logos_controller.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class Account::LogosController < ApplicationController
|
||||
def show
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
render_placeholder
|
||||
end
|
||||
|
||||
def render_placeholder
|
||||
render formats: :svg
|
||||
end
|
||||
end
|
||||
45
app/controllers/account/transfers_controller.rb
Normal file
45
app/controllers/account/transfers_controller.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
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
|
||||
@transfer.entries.each(&:sync_account_later)
|
||||
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[:alert] = @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
|
||||
@@ -1,50 +1,62 @@
|
||||
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 ]
|
||||
|
||||
def index
|
||||
@institutions = Current.family.institutions
|
||||
@accounts = Current.family.accounts.ungrouped.alphabetically
|
||||
end
|
||||
|
||||
def summary
|
||||
snapshot = Current.family.snapshot(@period)
|
||||
@net_worth_series = snapshot[:net_worth_series]
|
||||
@asset_series = snapshot[:asset_series]
|
||||
@liability_series = snapshot[:liability_series]
|
||||
@accounts = Current.family.accounts
|
||||
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
end
|
||||
|
||||
def list
|
||||
render layout: false
|
||||
end
|
||||
|
||||
def new
|
||||
@account = Account.new(
|
||||
balance: nil,
|
||||
accountable: Accountable.from_type(params[:type])&.new
|
||||
)
|
||||
@account = Account.new(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: t(".success") }),
|
||||
turbo_stream.replace("account_#{@account.id}", partial: "accounts/account", locals: { account: @account })
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
render "edit", status: :unprocessable_entity
|
||||
Account.transaction do
|
||||
@account.update! account_params.except(:accountable_type, :balance)
|
||||
@account.update_balance!(account_params[:balance]) if account_params[:balance]
|
||||
end
|
||||
@account.sync_later
|
||||
redirect_back_or_to account_path(@account), notice: t(".success")
|
||||
end
|
||||
|
||||
def create
|
||||
@account = Current.family.accounts.build(account_params.except(:accountable_type))
|
||||
@account.accountable = Accountable.from_type(account_params[:accountable_type])&.new
|
||||
|
||||
if @account.save
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
else
|
||||
render "new", status: :unprocessable_entity
|
||||
end
|
||||
@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]
|
||||
@account.sync_later
|
||||
redirect_back_or_to account_path(@account), notice: t(".success")
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
redirect_back_or_to accounts_path, alert: e.record.errors.full_messages.to_sentence
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -53,23 +65,23 @@ 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: t(".success") })
|
||||
end
|
||||
unless @account.syncing?
|
||||
@account.sync_later
|
||||
end
|
||||
end
|
||||
|
||||
def sync_all
|
||||
Current.family.accounts.active.sync
|
||||
redirect_back_or_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
def set_account
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(:name, :accountable_type, :balance, :currency, :subtype, :is_active)
|
||||
end
|
||||
def account_params
|
||||
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,20 +2,6 @@ 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
|
||||
|
||||
47
app/controllers/categories_controller.rb
Normal file
47
app/controllers/categories_controller.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
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.categories.alphabetically
|
||||
end
|
||||
|
||||
def new
|
||||
@category = Current.family.categories.new color: Category::COLORS.sample
|
||||
end
|
||||
|
||||
def create
|
||||
Category.transaction do
|
||||
category = Current.family.categories.create!(category_params)
|
||||
@transaction.update!(category_id: category.id) if @transaction
|
||||
end
|
||||
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@category.update! category_params
|
||||
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_category
|
||||
@category = Current.family.categories.find(params[:id])
|
||||
end
|
||||
|
||||
def set_transaction
|
||||
if params[:transaction_id].present?
|
||||
@transaction = Current.family.transactions.find(params[:transaction_id])
|
||||
end
|
||||
end
|
||||
|
||||
def category_params
|
||||
params.require(:category).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
26
app/controllers/category/deletions_controller.rb
Normal file
26
app/controllers/category/deletions_controller.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
class Category::DeletionsController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_category
|
||||
before_action :set_replacement_category, only: :create
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
@category.replace_and_destroy! @replacement_category
|
||||
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def set_category
|
||||
@category = Current.family.categories.find(params[:category_id])
|
||||
end
|
||||
|
||||
def set_replacement_category
|
||||
if params[:replacement_category_id].present?
|
||||
@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
|
||||
@@ -7,6 +7,6 @@ module SelfHostable
|
||||
|
||||
private
|
||||
def self_hosted?
|
||||
ENV["SELF_HOSTING_ENABLED"] == "true"
|
||||
Rails.configuration.app_mode.self_hosted?
|
||||
end
|
||||
end
|
||||
|
||||
6
app/controllers/currencies_controller.rb
Normal file
6
app/controllers/currencies_controller.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class CurrenciesController < ApplicationController
|
||||
def show
|
||||
currency = Money::Currency.all_instances.find { |currency| currency.iso_code == params[:id] }
|
||||
render json: currency.as_json.merge({ step: currency.step })
|
||||
end
|
||||
end
|
||||
11
app/controllers/help/articles_controller.rb
Normal file
11
app/controllers/help/articles_controller.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class Help::ArticlesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
def show
|
||||
@article = Help::Article.find(params[:id])
|
||||
|
||||
unless @article
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
end
|
||||
118
app/controllers/imports_controller.rb
Normal file
118
app/controllers/imports_controller.rb
Normal file
@@ -0,0 +1,118 @@
|
||||
require "ostruct"
|
||||
|
||||
class ImportsController < ApplicationController
|
||||
before_action :set_import, except: %i[ index new create ]
|
||||
|
||||
def index
|
||||
@imports = Current.family.imports
|
||||
render layout: "with_sidebar"
|
||||
end
|
||||
|
||||
def new
|
||||
account = Current.family.accounts.find_by(id: params[:account_id])
|
||||
@import = Import.new account: account
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
account = Current.family.accounts.find(params[:import][:account_id])
|
||||
|
||||
@import.update! account: account
|
||||
redirect_to load_import_path(@import), notice: t(".import_updated")
|
||||
end
|
||||
|
||||
def create
|
||||
account = Current.family.accounts.find(params[:import][:account_id])
|
||||
@import = Import.create!(account: account)
|
||||
|
||||
redirect_to load_import_path(@import), notice: t(".import_created")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@import.destroy!
|
||||
redirect_to imports_url, notice: t(".import_destroyed"), status: :see_other
|
||||
end
|
||||
|
||||
def load
|
||||
end
|
||||
|
||||
def upload_csv
|
||||
begin
|
||||
@import.raw_csv_str = import_params[:raw_csv_str].read
|
||||
rescue NoMethodError
|
||||
flash.now[:alert] = "Please select a file to upload"
|
||||
render :load, status: :unprocessable_entity and return
|
||||
end
|
||||
if @import.save
|
||||
redirect_to configure_import_path(@import), notice: t(".import_loaded")
|
||||
else
|
||||
flash.now[:alert] = @import.errors.full_messages.to_sentence
|
||||
render :load, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def load_csv
|
||||
if @import.update(import_params)
|
||||
redirect_to configure_import_path(@import), notice: t(".import_loaded")
|
||||
else
|
||||
flash.now[:alert] = @import.errors.full_messages.to_sentence
|
||||
render :load, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def configure
|
||||
unless @import.loaded?
|
||||
redirect_to load_import_path(@import), alert: t(".invalid_csv")
|
||||
end
|
||||
end
|
||||
|
||||
def update_mappings
|
||||
@import.update! import_params(@import.expected_fields.map(&:key))
|
||||
redirect_to clean_import_path(@import), notice: t(".column_mappings_saved")
|
||||
end
|
||||
|
||||
def clean
|
||||
unless @import.loaded?
|
||||
redirect_to load_import_path(@import), alert: t(".invalid_csv")
|
||||
end
|
||||
end
|
||||
|
||||
def update_csv
|
||||
update_params = import_params[:csv_update]
|
||||
|
||||
@import.update_csv! \
|
||||
row_idx: update_params[:row_idx],
|
||||
col_idx: update_params[:col_idx],
|
||||
value: update_params[:value]
|
||||
|
||||
render :clean
|
||||
end
|
||||
|
||||
def confirm
|
||||
unless @import.cleaned?
|
||||
redirect_to clean_import_path(@import), alert: t(".invalid_data")
|
||||
end
|
||||
end
|
||||
|
||||
def publish
|
||||
if @import.valid?
|
||||
@import.publish_later
|
||||
redirect_to imports_path, notice: t(".import_published")
|
||||
else
|
||||
flash.now[:error] = t(".invalid_data")
|
||||
render :confirm, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_import
|
||||
@import = Current.family.imports.find(params[:id])
|
||||
end
|
||||
|
||||
def import_params(permitted_mappings = nil)
|
||||
params.require(:import).permit(:raw_csv_str, column_mappings: permitted_mappings, csv_update: [ :row_idx, :col_idx, :value ])
|
||||
end
|
||||
end
|
||||
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
|
||||
@@ -1,4 +1,6 @@
|
||||
class PagesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
include Filterable
|
||||
|
||||
def dashboard
|
||||
@@ -6,6 +8,35 @@ class PagesController < ApplicationController
|
||||
@net_worth_series = snapshot[:net_worth_series]
|
||||
@asset_series = snapshot[:asset_series]
|
||||
@liability_series = snapshot[:liability_series]
|
||||
@account_groups = Current.family.accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
|
||||
snapshot_transactions = Current.family.snapshot_transactions
|
||||
@income_series = snapshot_transactions[:income_series]
|
||||
@spending_series = snapshot_transactions[:spending_series]
|
||||
@savings_rate_series = snapshot_transactions[:savings_rate_series]
|
||||
|
||||
snapshot_account_transactions = Current.family.snapshot_account_transactions
|
||||
@top_spenders = snapshot_account_transactions[:top_spenders]
|
||||
@top_earners = snapshot_account_transactions[:top_earners]
|
||||
@top_savers = snapshot_account_transactions[:top_savers]
|
||||
|
||||
@accounts = Current.family.accounts
|
||||
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
@transaction_entries = Current.family.entries.account_transactions.limit(6).reverse_chronological
|
||||
|
||||
# TODO: Placeholders for trendlines
|
||||
placeholder_series_data = 10.times.map do |i|
|
||||
{ date: Date.current - i.days, value: Money.new(0) }
|
||||
end
|
||||
@investing_series = TimeSeries.new(placeholder_series_data)
|
||||
end
|
||||
|
||||
def changelog
|
||||
@releases_notes = Provider::Github.new.fetch_latest_releases_notes
|
||||
end
|
||||
|
||||
def feedback
|
||||
end
|
||||
|
||||
def invites
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@ class PasswordResetsController < ApplicationController
|
||||
|
||||
layout "auth"
|
||||
|
||||
before_action :set_user_by_token, only: :update
|
||||
before_action :set_user_by_token, only: %i[ edit update ]
|
||||
|
||||
def new
|
||||
end
|
||||
@@ -20,6 +20,7 @@ class PasswordResetsController < ApplicationController
|
||||
end
|
||||
|
||||
def edit
|
||||
@user = User.new
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -13,9 +13,10 @@ class RegistrationsController < ApplicationController
|
||||
def create
|
||||
family = Family.new
|
||||
@user.family = family
|
||||
@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
|
||||
|
||||
7
app/controllers/settings/billings_controller.rb
Normal file
7
app/controllers/settings/billings_controller.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class Settings::BillingsController < SettingsController
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
end
|
||||
end
|
||||
71
app/controllers/settings/hostings_controller.rb
Normal file
71
app/controllers/settings/hostings_controller.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
class Settings::HostingsController < SettingsController
|
||||
before_action :verify_hosting_mode
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def update
|
||||
if all_updates_valid?
|
||||
hosting_params.keys.each do |key|
|
||||
Setting.send("#{key}=", hosting_params[key].strip)
|
||||
end
|
||||
|
||||
redirect_to settings_hosting_path, notice: t(".success")
|
||||
else
|
||||
flash.now[:error] = @errors.first.message
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def send_test_email
|
||||
unless Setting.smtp_settings_populated?
|
||||
flash[:alert] = t(".missing_smtp_setting_error")
|
||||
render(:show, status: :unprocessable_entity)
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
NotificationMailer.with(user: Current.user).test_email.deliver_now
|
||||
rescue => _e
|
||||
flash[:alert] = t(".error")
|
||||
render :show, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
redirect_to settings_hosting_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
def all_updates_valid?
|
||||
@errors = ActiveModel::Errors.new(Setting)
|
||||
hosting_params.keys.each do |key|
|
||||
setting = Setting.new(var: key)
|
||||
setting.value = hosting_params[key].strip
|
||||
|
||||
unless setting.valid?
|
||||
@errors.merge!(setting.errors)
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@errors.empty?
|
||||
end
|
||||
|
||||
def hosting_params
|
||||
permitted_params = params.require(:setting).permit(:render_deploy_hook, :upgrades_mode, :email_sender, :app_domain, :smtp_host, :smtp_port, :smtp_username, :smtp_password)
|
||||
|
||||
result = {}
|
||||
result[:upgrades_mode] = permitted_params[:upgrades_mode] == "manual" ? "manual" : "auto" if permitted_params.key?(:upgrades_mode)
|
||||
result[:render_deploy_hook] = permitted_params[:render_deploy_hook] if permitted_params.key?(:render_deploy_hook)
|
||||
result[:upgrades_target] = permitted_params[:upgrades_mode] unless permitted_params[:upgrades_mode] == "manual" if permitted_params.key?(:upgrades_mode)
|
||||
result.merge!(permitted_params.slice(:email_sender, :app_domain, :smtp_host, :smtp_port, :smtp_username, :smtp_password))
|
||||
result
|
||||
end
|
||||
|
||||
def verify_hosting_mode
|
||||
head :not_found unless self_hosted?
|
||||
end
|
||||
end
|
||||
7
app/controllers/settings/notifications_controller.rb
Normal file
7
app/controllers/settings/notifications_controller.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class Settings::NotificationsController < SettingsController
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
end
|
||||
end
|
||||
26
app/controllers/settings/preferences_controller.rb
Normal file
26
app/controllers/settings/preferences_controller.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
class Settings::PreferencesController < SettingsController
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
preference_params_with_family = preference_params
|
||||
|
||||
if Current.family && preference_params[:family_attributes]
|
||||
family_attributes = preference_params[:family_attributes].merge({ id: Current.family.id })
|
||||
preference_params_with_family[:family_attributes] = family_attributes
|
||||
end
|
||||
|
||||
if Current.user.update(preference_params_with_family)
|
||||
redirect_to settings_preferences_path, notice: t(".success")
|
||||
else
|
||||
redirect_to settings_preferences_path, notice: t(".success")
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def preference_params
|
||||
params.require(:user).permit(family_attributes: [ :id, :currency ])
|
||||
end
|
||||
end
|
||||
39
app/controllers/settings/profiles_controller.rb
Normal file
39
app/controllers/settings/profiles_controller.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
class Settings::ProfilesController < SettingsController
|
||||
def show
|
||||
end
|
||||
|
||||
def update
|
||||
user_params_with_family = user_params
|
||||
|
||||
if params[:user][:delete_profile_image] == "true"
|
||||
Current.user.profile_image.purge
|
||||
end
|
||||
|
||||
if Current.family && user_params_with_family[:family_attributes]
|
||||
family_attributes = user_params_with_family[:family_attributes].merge({ id: Current.family.id })
|
||||
user_params_with_family[:family_attributes] = family_attributes
|
||||
end
|
||||
|
||||
if Current.user.update(user_params_with_family)
|
||||
redirect_to settings_profile_path, notice: t(".success")
|
||||
else
|
||||
redirect_to settings_profile_path, alert: t(".file_size_error")
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if Current.user.deactivate
|
||||
logout
|
||||
redirect_to root_path, notice: t(".success")
|
||||
else
|
||||
redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:first_name, :last_name, :profile_image,
|
||||
family_attributes: [ :name, :id ])
|
||||
end
|
||||
end
|
||||
7
app/controllers/settings/securities_controller.rb
Normal file
7
app/controllers/settings/securities_controller.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class Settings::SecuritiesController < SettingsController
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
end
|
||||
end
|
||||
@@ -1,46 +0,0 @@
|
||||
class Settings::SelfHostingController < ApplicationController
|
||||
before_action :verify_self_hosting_enabled
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if all_updates_valid?
|
||||
self_hosting_params.keys.each do |key|
|
||||
Setting.send("#{key}=", self_hosting_params[key].strip)
|
||||
end
|
||||
|
||||
redirect_to edit_settings_self_hosting_path, notice: t(".success")
|
||||
else
|
||||
flash.now[:error] = @errors.first.message
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def all_updates_valid?
|
||||
@errors = ActiveModel::Errors.new(Setting)
|
||||
self_hosting_params.keys.each do |key|
|
||||
setting = Setting.new(var: key)
|
||||
setting.value = self_hosting_params[key].strip
|
||||
|
||||
unless setting.valid?
|
||||
@errors.merge!(setting.errors)
|
||||
end
|
||||
end
|
||||
|
||||
if self_hosting_params[:upgrades_mode] == "auto" && self_hosting_params[:render_deploy_hook].blank?
|
||||
@errors.add(:render_deploy_hook, t("settings.self_hosting.update.render_deploy_hook_error"))
|
||||
end
|
||||
|
||||
@errors.empty?
|
||||
end
|
||||
|
||||
def self_hosting_params
|
||||
params.require(:setting).permit(:render_deploy_hook, :upgrades_mode, :upgrades_target)
|
||||
end
|
||||
|
||||
def verify_self_hosting_enabled
|
||||
head :not_found unless self_hosted?
|
||||
end
|
||||
end
|
||||
@@ -1,26 +1,3 @@
|
||||
class SettingsController < ApplicationController
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
user_params_with_family = user_params
|
||||
|
||||
if Current.family
|
||||
family_attributes = user_params_with_family[:family_attributes].merge({ id: Current.family.id })
|
||||
user_params_with_family[:family_attributes] = family_attributes
|
||||
end
|
||||
|
||||
if Current.user.update(user_params_with_family)
|
||||
redirect_to root_path, notice: "Profile updated successfully."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:first_name, :last_name, :email, :password, :password_confirmation,
|
||||
family_attributes: [ :name, :id, :currency ])
|
||||
end
|
||||
layout "with_sidebar"
|
||||
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,34 +0,0 @@
|
||||
class Transactions::CategoriesController < ApplicationController
|
||||
before_action :set_category, only: [ :update, :destroy ]
|
||||
|
||||
def create
|
||||
if Current.family.transaction_categories.create(category_params)
|
||||
redirect_to transactions_path, notice: t(".success")
|
||||
else
|
||||
render transactions_path, status: :unprocessable_entity, notice: t(".error")
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @category.update(category_params)
|
||||
redirect_to transactions_path, notice: t(".success")
|
||||
else
|
||||
render transactions_path, status: :unprocessable_entity, notice: t(".error")
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@category.destroy!
|
||||
redirect_to transactions_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_category
|
||||
@category = Current.family.transaction_categories.find(params[:id])
|
||||
end
|
||||
|
||||
def category_params
|
||||
params.require(:transaction_category).permit(:name, :name, :color)
|
||||
end
|
||||
end
|
||||
@@ -1,125 +1,104 @@
|
||||
class TransactionsController < ApplicationController
|
||||
before_action :set_transaction, only: %i[ show edit update destroy ]
|
||||
layout "with_sidebar"
|
||||
|
||||
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.entries.account_transactions.search(@q).reverse_chronological
|
||||
@pagy, @transaction_entries = pagy(result, items: params[:per_page] || "50")
|
||||
|
||||
@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] || {}
|
||||
updated_params = delete_search_param(current_params, params[:remove_param], value: params[:remove_param_value])
|
||||
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
|
||||
end
|
||||
|
||||
def edit
|
||||
@entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e|
|
||||
if params[:account_id]
|
||||
e.account = Current.family.accounts.find(params[:account_id])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
account = Current.family.accounts.find(params[:transaction][:account_id])
|
||||
@entry = Current.family
|
||||
.accounts
|
||||
.find(params[:account_entry][:account_id])
|
||||
.entries
|
||||
.create!(transaction_entry_params.merge(amount: amount))
|
||||
|
||||
@transaction = account.transactions.build(transaction_params)
|
||||
|
||||
respond_to do |format|
|
||||
if @transaction.save
|
||||
@transaction.account.sync_later
|
||||
format.html { redirect_to transactions_url, notice: t(".success") }
|
||||
else
|
||||
format.html { render :new, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
@entry.sync_account_later
|
||||
redirect_back_or_to account_path(@entry.account), notice: t(".success")
|
||||
end
|
||||
|
||||
def update
|
||||
respond_to do |format|
|
||||
if @transaction.update(transaction_params)
|
||||
@transaction.account.sync_later
|
||||
|
||||
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: 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
|
||||
def bulk_delete
|
||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transaction.destroy!
|
||||
@transaction.account.sync_later
|
||||
def bulk_edit
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to transactions_url, notice: t(".success") }
|
||||
end
|
||||
def bulk_update
|
||||
updated = Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.bulk_update!(bulk_update_params)
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
|
||||
end
|
||||
|
||||
def mark_transfers
|
||||
Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.mark_transfers!
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def unmark_transfers
|
||||
Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.update_all marked_as_transfer: false
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def rules
|
||||
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
|
||||
|
||||
def amount
|
||||
if nature.income?
|
||||
transaction_entry_params[:amount].to_d * -1
|
||||
else
|
||||
params.delete(key)
|
||||
transaction_entry_params[:amount].to_d
|
||||
end
|
||||
|
||||
params
|
||||
end
|
||||
|
||||
def ransack_session_key
|
||||
:ransack_transactions_q
|
||||
def nature
|
||||
params[:account_entry][:nature].to_s.inquiry
|
||||
end
|
||||
|
||||
# Use callbacks to share common setup or constraints between actions.
|
||||
def set_transaction
|
||||
@transaction = Transaction.find(params[:id])
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
end
|
||||
|
||||
# Only allow a list of trusted parameters through.
|
||||
def transaction_params
|
||||
params.require(:transaction).permit(:name, :date, :amount, :currency, :notes, :excluded, :category_id)
|
||||
def bulk_update_params
|
||||
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: [])
|
||||
end
|
||||
|
||||
def transaction_entry_params
|
||||
params.require(:account_entry)
|
||||
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: [ :category_id ])
|
||||
.with_defaults(entryable_type: "Account::Transaction", entryable_attributes: {})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
class ValuationsController < ApplicationController
|
||||
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: Current.family.currency))
|
||||
if @valuation.save
|
||||
@valuation.account.sync_later
|
||||
|
||||
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
|
||||
@valuation = Valuation.find(params[:id])
|
||||
end
|
||||
|
||||
def update
|
||||
@valuation = Valuation.find(params[:id])
|
||||
if @valuation.update(valuation_params)
|
||||
@valuation.account.sync_later
|
||||
|
||||
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
|
||||
@valuation = Valuation.find(params[:id])
|
||||
@account = @valuation.account
|
||||
@valuation.destroy!
|
||||
@account.sync_later
|
||||
|
||||
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
|
||||
def valuation_params
|
||||
params.require(:valuation).permit(:date, :value)
|
||||
end
|
||||
end
|
||||
39
app/helpers/account/entries_helper.rb
Normal file
39
app/helpers/account/entries_helper.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
module Account::EntriesHelper
|
||||
def permitted_entryable_partial_path(entry, relative_partial_path)
|
||||
"account/entries/entryables/#{permitted_entryable_key(entry)}/#{relative_partial_path}"
|
||||
end
|
||||
|
||||
def unconfirmed_transfer?(entry)
|
||||
entry.marked_as_transfer? && entry.transfer.nil?
|
||||
end
|
||||
|
||||
def transfer_entries(entries)
|
||||
transfers = entries.select { |e| e.transfer_id.present? }
|
||||
transfers.map(&:transfer).uniq
|
||||
end
|
||||
|
||||
def entry_icon(entry, is_oldest: false)
|
||||
if is_oldest
|
||||
"keyboard"
|
||||
elsif entry.trend.direction.up?
|
||||
"arrow-up"
|
||||
elsif entry.trend.direction.down?
|
||||
"arrow-down"
|
||||
else
|
||||
"minus"
|
||||
end
|
||||
end
|
||||
|
||||
def entry_style(entry, is_oldest: false)
|
||||
color = is_oldest ? "#D444F1" : entry.trend.color
|
||||
|
||||
mixed_hex_styles(color)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permitted_entryable_key(entry)
|
||||
permitted_entryable_paths = %w[transaction valuation]
|
||||
entry.entryable_name_short.presence_in(permitted_entryable_paths)
|
||||
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
|
||||
@@ -7,6 +7,10 @@ module AccountsHelper
|
||||
class_mapping(accountable_type)[:text]
|
||||
end
|
||||
|
||||
def accountable_fill_class(accountable_type)
|
||||
class_mapping(accountable_type)[:fill]
|
||||
end
|
||||
|
||||
def accountable_bg_class(accountable_type)
|
||||
class_mapping(accountable_type)[:bg]
|
||||
end
|
||||
@@ -15,18 +19,22 @@ module AccountsHelper
|
||||
class_mapping(accountable_type)[:bg_transparent]
|
||||
end
|
||||
|
||||
def accountable_color(accountable_type)
|
||||
class_mapping(accountable_type)[:hex]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def class_mapping(accountable_type)
|
||||
{
|
||||
"Account::Credit" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10" },
|
||||
"Account::Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10" },
|
||||
"Account::OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10" },
|
||||
"Account::Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10" },
|
||||
"Account::Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10" },
|
||||
"Account::OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10" },
|
||||
"Account::Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10" },
|
||||
"Account::Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10" }
|
||||
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10" })
|
||||
"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
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
|
||||
def initialize(object_name, object, template, options)
|
||||
options[:html] ||= {}
|
||||
options[:html][:class] ||= "space-y-4"
|
||||
|
||||
super(object_name, object, template, options)
|
||||
end
|
||||
|
||||
(field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]).each do |selector|
|
||||
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
||||
def #{selector}(method, options = {})
|
||||
default_options = { class: "form-field__input" }
|
||||
merged_options = default_options.merge(options)
|
||||
|
||||
return super(method, merged_options) unless options[:label]
|
||||
|
||||
@template.form_field_tag do
|
||||
label(method, *label_args(options)) +
|
||||
super(method, merged_options.except(:label))
|
||||
end
|
||||
end
|
||||
RUBY_EVAL
|
||||
end
|
||||
|
||||
# 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)
|
||||
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
|
||||
money_currency_method = :currency
|
||||
|
||||
readonly_currency = options[:readonly_currency] || false
|
||||
|
||||
default_options = {
|
||||
class: "form-field__input",
|
||||
value: money&.amount,
|
||||
placeholder: Money.new(0, money&.currency || Money.default_currency).format
|
||||
}
|
||||
|
||||
merged_options = default_options.merge(options)
|
||||
|
||||
grouped_options = currency_options_for_select
|
||||
selected_currency = money&.currency&.iso_code
|
||||
|
||||
@template.form_field_tag do
|
||||
(label(method, *label_args(options)).to_s if options[:label]) +
|
||||
@template.tag.div(class: "flex items-center") do
|
||||
number_field(money_amount_method, merged_options.except(:label)) +
|
||||
grouped_select(money_currency_method, grouped_options, { selected: selected_currency, disabled: readonly_currency }, class: "ml-auto form-field__input w-fit pr-8")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def grouped_select(method, grouped_choices, options = {}, html_options = {})
|
||||
default_options = { class: "form-field__input" }
|
||||
merged_html_options = default_options.merge(html_options)
|
||||
|
||||
label_html = label(method, *label_args(options)).to_s if options[:label]
|
||||
select_html = @template.grouped_collection_select(@object_name, method, grouped_choices, :last, :first, :last, :first, options, merged_html_options)
|
||||
|
||||
@template.content_tag(:div, class: "flex items-center") do
|
||||
label_html.to_s.html_safe + select_html
|
||||
end
|
||||
end
|
||||
|
||||
def select(method, choices, options = {}, html_options = {})
|
||||
default_options = { class: "form-field__input" }
|
||||
merged_options = default_options.merge(html_options)
|
||||
|
||||
return super(method, choices, options, merged_options) unless options[:label]
|
||||
|
||||
@template.form_field_tag do
|
||||
label(method, *label_args(options)) +
|
||||
super(method, choices, options, merged_options.except(:label))
|
||||
end
|
||||
end
|
||||
|
||||
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
|
||||
default_options = { class: "form-field__input" }
|
||||
merged_options = default_options.merge(html_options)
|
||||
|
||||
return super(method, collection, value_method, text_method, options, merged_options) unless options[:label]
|
||||
|
||||
@template.form_field_tag do
|
||||
label(method, *label_args(options)) +
|
||||
super(method, collection, value_method, text_method, options, merged_options.except(:label))
|
||||
end
|
||||
end
|
||||
|
||||
def submit(value = nil, options = {})
|
||||
value, options = nil, value if value.is_a?(Hash)
|
||||
default_options = { class: "form-field__submit" }
|
||||
merged_options = default_options.merge(options)
|
||||
super(value, merged_options)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def currency_options_for_select
|
||||
popular_currencies = Money::Currency.popular.map { |currency| [ currency.iso_code, currency.iso_code ] }
|
||||
all_currencies = Money::Currency.all_instances.map { |currency| [ currency.iso_code, currency.iso_code ] }
|
||||
all_other_currencies = all_currencies.reject { |c| popular_currencies.map(&:last).include?(c.last) }.sort_by(&:last)
|
||||
|
||||
{
|
||||
I18n.t("accounts.new.currency.popular") => popular_currencies,
|
||||
I18n.t("accounts.new.currency.all_others") => all_other_currencies
|
||||
}
|
||||
end
|
||||
|
||||
def label_args(options)
|
||||
case options[:label]
|
||||
when Array
|
||||
options[:label]
|
||||
when String
|
||||
[ options[:label], { class: "form-field__label" } ]
|
||||
when Hash
|
||||
[ nil, options[:label] ]
|
||||
else
|
||||
[ nil, { class: "form-field__label" } ]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,58 +13,93 @@ module ApplicationHelper
|
||||
name.underscore
|
||||
end
|
||||
|
||||
def notification(text, **options, &block)
|
||||
content = tag.p(text)
|
||||
content = capture &block if block_given?
|
||||
|
||||
render partial: "shared/notification", locals: { type: options[:type], content: content }
|
||||
def family_notifications_stream
|
||||
turbo_stream_from [ Current.family, :notifications ] if Current.family
|
||||
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
|
||||
def modal(&block)
|
||||
def render_flash_notifications
|
||||
notifications = flash.flat_map do |type, message_or_messages|
|
||||
Array(message_or_messages).map do |message|
|
||||
render partial: "shared/notification", locals: { type: type, message: message }
|
||||
end
|
||||
end
|
||||
|
||||
safe_join(notifications)
|
||||
end
|
||||
|
||||
##
|
||||
# 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: content }
|
||||
render partial: "shared/modal", locals: { content:, classes: options[:classes] }
|
||||
end
|
||||
|
||||
def account_groups
|
||||
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: Period.last_30_days).values_at(:assets, :liabilities)
|
||||
##
|
||||
# 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 }
|
||||
def sidebar_link_to(name, path, options = {})
|
||||
is_current = current_page?(path) || (request.path.start_with?(path) && path != "/")
|
||||
|
||||
classes = [
|
||||
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm font-medium text-gray-500",
|
||||
(is_current ? "bg-white text-gray-900 shadow-xs border-alpha-black-50" : "hover:bg-gray-100 border-transparent")
|
||||
].compact.join(" ")
|
||||
|
||||
link_to path, **options.merge(class: classes), aria: { current: ("page" if current_page?(path)) } do
|
||||
concat(lucide_icon(options[:icon], class: "w-5 h-5")) if options[:icon]
|
||||
concat(name)
|
||||
end
|
||||
end
|
||||
|
||||
def sidebar_link_to(name, path, options = {})
|
||||
base_class_names = [ "block", "border", "border-transparent", "rounded-xl", "-ml-2", "p-2", "text-sm", "font-medium", "text-gray-500", "flex", "items-center" ]
|
||||
hover_class_names = [ "hover:bg-white", "hover:border-alpha-black-50", "hover:text-gray-900", "hover:shadow-xs" ]
|
||||
current_page_class_names = [ "bg-white", "border-alpha-black-50", "text-gray-900", "shadow-xs" ]
|
||||
def mixed_hex_styles(hex)
|
||||
color = hex || "#1570EF" # blue-600
|
||||
|
||||
link_class_names = if current_page?(path) || (request.path.start_with?(path) && path != "/")
|
||||
base_class_names.delete("border-transparent")
|
||||
base_class_names + hover_class_names + current_page_class_names
|
||||
else
|
||||
base_class_names + hover_class_names
|
||||
end
|
||||
<<-STYLE.strip
|
||||
background-color: color-mix(in srgb, #{color} 5%, white);
|
||||
border-color: color-mix(in srgb, #{color} 10%, white);
|
||||
color: #{color};
|
||||
STYLE
|
||||
end
|
||||
|
||||
merged_options = options.reverse_merge(class: link_class_names.join(" ")).except(:icon)
|
||||
def circle_logo(name, hex: nil, size: "md")
|
||||
render partial: "shared/circle_logo", locals: { name: name, hex: hex, size: size }
|
||||
end
|
||||
|
||||
link_to path, merged_options do
|
||||
lucide_icon(options[:icon], class: "w-5 h-5 mr-2") + name
|
||||
end
|
||||
def return_to_path(params, fallback = root_path)
|
||||
uri = URI.parse(params[:return_to] || fallback)
|
||||
uri.relative? ? uri.path : root_path
|
||||
end
|
||||
|
||||
def trend_styles(trend)
|
||||
fallback = { bg_class: "bg-gray-500/5", text_class: "text-gray-500", symbol: "", icon: "minus" }
|
||||
return fallback if trend.nil? || trend.direction == "flat"
|
||||
return fallback if trend.nil? || trend.direction.flat?
|
||||
|
||||
bg_class, text_class, symbol, icon = case trend.direction
|
||||
when "up"
|
||||
trend.type == "liability" ? [ "bg-red-500/5", "text-red-500", "+", "arrow-up" ] : [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ]
|
||||
trend.favorable_direction.down? ? [ "bg-red-500/5", "text-red-500", "+", "arrow-up" ] : [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ]
|
||||
when "down"
|
||||
trend.type == "liability" ? [ "bg-green-500/5", "text-green-500", "-", "arrow-down" ] : [ "bg-red-500/5", "text-red-500", "-", "arrow-down" ]
|
||||
trend.favorable_direction.down? ? [ "bg-green-500/5", "text-green-500", "-", "arrow-down" ] : [ "bg-red-500/5", "text-red-500", "-", "arrow-down" ]
|
||||
when "flat"
|
||||
[ "bg-gray-500/5", "text-gray-500", "", "minus" ]
|
||||
else
|
||||
@@ -108,4 +143,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)
|
||||
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
|
||||
@@ -1,5 +1,79 @@
|
||||
module FormsHelper
|
||||
def form_field_tag(&)
|
||||
tag.div class: "form-field", &
|
||||
def styled_form_with(**options, &block)
|
||||
options[:builder] = StyledFormBuilder
|
||||
form_with(**options, &block)
|
||||
end
|
||||
|
||||
def form_field_tag(options = {}, &block)
|
||||
options[:class] = [ "form-field", options[:class] ].compact.join(" ")
|
||||
tag.div(**options, &block)
|
||||
end
|
||||
|
||||
def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false)
|
||||
form.label name, for: form.field_id(name, value), class: "group has-[:disabled]:cursor-not-allowed" do
|
||||
concat radio_tab_contents(label:, icon:)
|
||||
concat form.radio_button(name, value, checked:, disabled:, class: "hidden")
|
||||
end
|
||||
end
|
||||
|
||||
def period_select(form:, selected:, classes: "border border-alpha-black-100 shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-gray-900 focus:outline-none focus:ring-0")
|
||||
periods_for_select = [ [ "7D", "last_7_days" ], [ "1M", "last_30_days" ], [ "1Y", "last_365_days" ], [ "All", "all" ] ]
|
||||
form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" })
|
||||
end
|
||||
|
||||
def money_with_currency_field(form, money_method, options = {})
|
||||
render partial: "shared/money_field", locals: {
|
||||
form: form,
|
||||
money_method: money_method,
|
||||
default_currency: options[:default_currency] || "USD",
|
||||
disable_currency: options[:disable_currency] || false,
|
||||
hide_currency: options[:hide_currency] || false,
|
||||
label: options[:label] || "Amount"
|
||||
}
|
||||
end
|
||||
|
||||
def money_field(form, method, options = {})
|
||||
value = form.object.send(method)
|
||||
|
||||
currency = value&.currency || Money::Currency.new(options[:default_currency] || "USD")
|
||||
|
||||
# See "Monetizable" concern
|
||||
money_amount_method = method.to_s.chomp("_money").to_sym
|
||||
|
||||
money_options = {
|
||||
value: value&.amount,
|
||||
placeholder: 100,
|
||||
min: -99999999999999,
|
||||
max: 99999999999999,
|
||||
step: currency.step
|
||||
}
|
||||
|
||||
merged_options = options.merge(money_options)
|
||||
|
||||
form.number_field money_amount_method, merged_options
|
||||
end
|
||||
|
||||
def currency_select_full(form, method, options = {}, html_options = {}, &block)
|
||||
choices = currencies_for_select.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }
|
||||
form.select method, choices, options, html_options, &block
|
||||
end
|
||||
|
||||
def currency_select(form, method, options = {}, html_options = {}, &block)
|
||||
choices = currencies_for_select.map(&:iso_code)
|
||||
form.select method, choices, options, html_options, &block
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def currencies_for_select
|
||||
Money::Currency.all_instances
|
||||
.sort_by(&:priority)
|
||||
end
|
||||
|
||||
def radio_tab_contents(label:, icon:)
|
||||
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm") do
|
||||
concat lucide_icon(icon, class: "w-5 h-5")
|
||||
concat tag.span(label, class: "group-has-[:checked]:font-semibold")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
19
app/helpers/imports_helper.rb
Normal file
19
app/helpers/imports_helper.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
module ImportsHelper
|
||||
def table_corner_class(row_idx, col_idx, rows, cols)
|
||||
return "rounded-tl-xl" if row_idx == 0 && col_idx == 0
|
||||
return "rounded-tr-xl" if row_idx == 0 && col_idx == cols.size - 1
|
||||
return "rounded-bl-xl" if row_idx == rows.size - 1 && col_idx == 0
|
||||
return "rounded-br-xl" if row_idx == rows.size - 1 && col_idx == cols.size - 1
|
||||
""
|
||||
end
|
||||
|
||||
def nav_steps(import = Import.new)
|
||||
[
|
||||
{ name: "Select", complete: import.persisted?, path: import.persisted? ? edit_import_path(import) : new_import_path },
|
||||
{ name: "Import", complete: import.loaded?, path: import.persisted? ? load_import_path(import) : nil },
|
||||
{ name: "Setup", complete: import.configured?, path: import.persisted? ? configure_import_path(import) : nil },
|
||||
{ name: "Clean", complete: import.cleaned?, path: import.persisted? ? clean_import_path(import) : nil },
|
||||
{ name: "Confirm", complete: import.complete?, path: import.persisted? ? confirm_import_path(import) : nil }
|
||||
]
|
||||
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
|
||||
38
app/helpers/menus_helper.rb
Normal file
38
app/helpers/menus_helper.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
module MenusHelper
|
||||
def contextual_menu(&block)
|
||||
tag.div class: "relative cursor-pointer", data: { controller: "menu" } do
|
||||
concat contextual_menu_icon
|
||||
concat contextual_menu_content(&block)
|
||||
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
|
||||
lucide_icon "more-horizontal", class: "w-5 h-5 text-gray-500"
|
||||
end
|
||||
end
|
||||
|
||||
def contextual_menu_content(&block)
|
||||
tag.div class: "absolute z-10 top-10 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden", data: { menu_target: "content" } do
|
||||
capture(&block)
|
||||
end
|
||||
end
|
||||
end
|
||||
2
app/helpers/settings/hosting_helper.rb
Normal file
2
app/helpers/settings/hosting_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module Settings::HostingHelper
|
||||
end
|
||||
@@ -1,2 +0,0 @@
|
||||
module Settings::SelfHostingHelper
|
||||
end
|
||||
14
app/helpers/settings_helper.rb
Normal file
14
app/helpers/settings_helper.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
module SettingsHelper
|
||||
def next_setting(title, path)
|
||||
render partial: "settings/nav_link_large", locals: { path: path, direction: "next", title: title }
|
||||
end
|
||||
|
||||
def previous_setting(title, path)
|
||||
render partial: "settings/nav_link_large", locals: { path: path, direction: "previous", title: title }
|
||||
end
|
||||
|
||||
def settings_section(title:, subtitle: nil, &block)
|
||||
content = capture(&block)
|
||||
render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content }
|
||||
end
|
||||
end
|
||||
55
app/helpers/styled_form_builder.rb
Normal file
55
app/helpers/styled_form_builder.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
# Fields that visually inherit from "text field"
|
||||
class_attribute :text_field_helpers, default: field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]
|
||||
|
||||
# Wraps "text" inputs with custom structure + base styles
|
||||
text_field_helpers.each do |selector|
|
||||
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
||||
def #{selector}(method, options = {})
|
||||
input_html = label_html(method, options) + super(method, merged_options(options))
|
||||
input_html = apply_form_field_wrapper(input_html) unless options[:inline]
|
||||
input_html
|
||||
end
|
||||
RUBY_EVAL
|
||||
end
|
||||
|
||||
def radio_button(method, tag_value, options = {})
|
||||
super(method, tag_value, merged_options(options, "form-field__radio"))
|
||||
end
|
||||
|
||||
def select(method, choices, options = {}, html_options = {})
|
||||
input_html = label_html(method, options) + super(method, choices, options, merged_options(html_options))
|
||||
input_html = apply_form_field_wrapper(input_html, class: "pr-0") unless options[:inline]
|
||||
input_html
|
||||
end
|
||||
|
||||
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
|
||||
input_html = label_html(method, options) + super(method, collection, value_method, text_method, options, merged_options(html_options))
|
||||
input_html = apply_form_field_wrapper(input_html, class: "pr-0") unless options[:inline]
|
||||
input_html
|
||||
end
|
||||
|
||||
def submit(value = nil, options = {})
|
||||
value, options = nil, value if value.is_a?(Hash)
|
||||
super(value, merged_options(options, "form-field__submit"))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_form_field_wrapper(input_html, **options)
|
||||
@template.form_field_tag(**options) do
|
||||
input_html
|
||||
end
|
||||
end
|
||||
|
||||
def merged_options(options, default_class = "form-field__input")
|
||||
combined_classes = options.fetch(:class, "") + " #{default_class}"
|
||||
style_options = { class: combined_classes }
|
||||
non_custom_options = options.except(:class, :label, :inline)
|
||||
style_options.merge(non_custom_options)
|
||||
end
|
||||
|
||||
def label_html(method, options)
|
||||
options[:label] ? label(method, options[:label], class: "form-field__label") : "".html_safe
|
||||
end
|
||||
end
|
||||
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,2 +0,0 @@
|
||||
module Transactions::CategoriesHelper
|
||||
end
|
||||
@@ -1,2 +1,37 @@
|
||||
module TransactionsHelper
|
||||
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
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
module UpgradesHelper
|
||||
def upgrade_notification
|
||||
def get_upgrade_for_notification(user, upgrades_mode)
|
||||
return nil unless ENV["UPGRADES_ENABLED"] == "true"
|
||||
|
||||
completed_upgrade = Upgrader.completed_upgrade
|
||||
return completed_upgrade if completed_upgrade && Current.user.last_alerted_upgrade_commit_sha != completed_upgrade.commit_sha
|
||||
return completed_upgrade if completed_upgrade && user.last_alerted_upgrade_commit_sha != completed_upgrade.commit_sha
|
||||
|
||||
available_upgrade = Upgrader.available_upgrade
|
||||
if available_upgrade && Setting.upgrades_mode == "manual" && Current.user.last_prompted_upgrade_commit_sha != available_upgrade.commit_sha
|
||||
if available_upgrade && upgrades_mode == "manual" && user.last_prompted_upgrade_commit_sha != available_upgrade.commit_sha
|
||||
available_upgrade
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
module ValuationsHelper
|
||||
end
|
||||
17
app/helpers/value_groups_helper.rb
Normal file
17
app/helpers/value_groups_helper.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
module ValueGroupsHelper
|
||||
def value_group_pie_data(value_group)
|
||||
value_group.children
|
||||
.map do |child|
|
||||
{
|
||||
label: to_accountable_title(Accountable.from_type(child.name)),
|
||||
percent_of_total: child.percent_of_total.round(1).to_f,
|
||||
value: child.sum.amount.to_f,
|
||||
currency: child.sum.currency.iso_code,
|
||||
bg_color: accountable_bg_class(child.name),
|
||||
fill_color: accountable_fill_class(child.name)
|
||||
}
|
||||
end
|
||||
.filter { |child| child[:value] > 0 }
|
||||
.to_json
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@ Turbo.setConfirmMethod((message) => {
|
||||
const dialog = document.getElementById("turbo-confirm");
|
||||
|
||||
try {
|
||||
const { title, body, accept } = JSON.parse(message);
|
||||
const { title, body, accept, acceptClass } = JSON.parse(message);
|
||||
|
||||
if (title) {
|
||||
document.getElementById("turbo-confirm-title").innerHTML = title;
|
||||
@@ -23,6 +23,10 @@ Turbo.setConfirmMethod((message) => {
|
||||
if (accept) {
|
||||
document.getElementById("turbo-confirm-accept").innerHTML = accept;
|
||||
}
|
||||
|
||||
if (acceptClass) {
|
||||
document.getElementById("turbo-confirm-accept").className = acceptClass;
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById("turbo-confirm-title").innerText = message;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,25 @@ import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
// By default, auto-submit is "opt-in" to avoid unexpected behavior. Each `auto` target
|
||||
// will trigger a form submission when the input event is triggered.
|
||||
// will trigger a form submission when the configured event is triggered.
|
||||
static targets = ["auto"];
|
||||
static values = {
|
||||
triggerEvent: { type: String, default: "input" },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.autoTargets.forEach((element) => {
|
||||
element.addEventListener("input", this.handleInput);
|
||||
const event =
|
||||
element.dataset.autosubmitTriggerEvent || this.triggerEventValue;
|
||||
element.addEventListener(event, this.handleInput);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.autoTargets.forEach((element) => {
|
||||
element.removeEventListener("input", this.handleInput);
|
||||
const event =
|
||||
element.dataset.autosubmitTriggerEvent || this.triggerEventValue;
|
||||
element.removeEventListener(event, this.handleInput);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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}[entry_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)
|
||||
})
|
||||
}
|
||||
}
|
||||
59
app/javascript/controllers/color_select_controller.js
Normal file
59
app/javascript/controllers/color_select_controller.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "input", "decoration" ]
|
||||
static values = { selection: String }
|
||||
|
||||
connect() {
|
||||
this.#renderOptions()
|
||||
}
|
||||
|
||||
select({ target }) {
|
||||
this.selectionValue = target.dataset.value
|
||||
}
|
||||
|
||||
selectionValueChanged() {
|
||||
this.#options.forEach(option => {
|
||||
if (option.dataset.value === this.selectionValue) {
|
||||
this.#check(option)
|
||||
this.inputTarget.value = this.selectionValue
|
||||
} else {
|
||||
this.#uncheck(option)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#renderOptions() {
|
||||
this.#options.forEach(option => option.style.backgroundColor = option.dataset.value)
|
||||
}
|
||||
|
||||
#check(option) {
|
||||
option.setAttribute("aria-checked", "true")
|
||||
option.style.boxShadow = `0px 0px 0px 4px ${hexToRGBA(option.dataset.value, 0.2)}`
|
||||
this.decorationTarget.style.backgroundColor = option.dataset.value
|
||||
}
|
||||
|
||||
#uncheck(option) {
|
||||
option.setAttribute("aria-checked", "false")
|
||||
option.style.boxShadow = "none"
|
||||
}
|
||||
|
||||
get #options() {
|
||||
return Array.from(this.element.querySelectorAll("[role='radio']"))
|
||||
}
|
||||
}
|
||||
|
||||
function hexToRGBA(hex, alpha = 1) {
|
||||
hex = hex.replace(/^#/, '');
|
||||
|
||||
if (hex.length === 8) {
|
||||
alpha = parseInt(hex.slice(6, 8), 16) / 255;
|
||||
hex = hex.slice(0, 6);
|
||||
}
|
||||
|
||||
let r = parseInt(hex.slice(0, 2), 16);
|
||||
let g = parseInt(hex.slice(2, 4), 16);
|
||||
let b = parseInt(hex.slice(4, 6), 16);
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
94
app/javascript/controllers/csv_upload_controller.js
Normal file
94
app/javascript/controllers/csv_upload_controller.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "preview", "submit", "filename", "filesize"]
|
||||
|
||||
connect() {
|
||||
this.submitTarget.disabled = true
|
||||
}
|
||||
|
||||
addFile(event) {
|
||||
const file = event.target.files[0]
|
||||
this._fileAdded(file)
|
||||
}
|
||||
|
||||
dragover(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.classList.add("bg-gray-100")
|
||||
}
|
||||
|
||||
dragleave(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.classList.remove("bg-gray-100")
|
||||
}
|
||||
|
||||
drop(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.classList.remove("bg-gray-100")
|
||||
|
||||
const file = event.dataTransfer.files[0]
|
||||
if (file && this._isCSVFile(file)) {
|
||||
this._setFileInput(file);
|
||||
this._fileAdded(file)
|
||||
} else {
|
||||
this.previewTarget.classList.add("text-red-500")
|
||||
this.previewTarget.textContent = "Only CSV files are allowed."
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
_fetchFileSize(size) {
|
||||
let fileSize = '';
|
||||
if (size < 1024 * 1024) {
|
||||
fileSize = (size / 1024).toFixed(2) + ' KB'; // Convert bytes to KB
|
||||
} else {
|
||||
fileSize = (size / (1024 * 1024)).toFixed(2) + ' MB'; // Convert bytes to MB
|
||||
}
|
||||
return fileSize;
|
||||
}
|
||||
|
||||
_fileAdded(file) {
|
||||
const fileSizeLimit = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
if (file) {
|
||||
if (file.size > fileSizeLimit) {
|
||||
this.previewTarget.classList.add("text-red-500")
|
||||
this.previewTarget.textContent = "File size exceeds the limit of 5MB"
|
||||
return
|
||||
}
|
||||
|
||||
this.submitTarget.classList.remove([
|
||||
"bg-alpha-black-25",
|
||||
"text-gray",
|
||||
"cursor-not-allowed",
|
||||
]);
|
||||
this.submitTarget.classList.add(
|
||||
"bg-gray-900",
|
||||
"text-white",
|
||||
"cursor-pointer",
|
||||
);
|
||||
this.submitTarget.disabled = false;
|
||||
this.previewTarget.innerHTML = document.querySelector("#template-preview").innerHTML;
|
||||
this.previewTarget.classList.remove("text-red-500")
|
||||
this.previewTarget.classList.add("text-gray-900")
|
||||
this.filenameTarget.textContent = file.name;
|
||||
this.filesizeTarget.textContent = this._fetchFileSize(file.size);
|
||||
}
|
||||
}
|
||||
|
||||
_isCSVFile(file) {
|
||||
const acceptedTypes = ["text/csv", "application/csv", ".csv"]
|
||||
const extension = file.name.split('.').pop().toLowerCase()
|
||||
return acceptedTypes.includes(file.type) || extension === "csv"
|
||||
}
|
||||
|
||||
_setFileInput(file) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
this.inputTarget.files = dataTransfer.files;
|
||||
}
|
||||
}
|
||||
30
app/javascript/controllers/deletion_controller.js
Normal file
30
app/javascript/controllers/deletion_controller.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["replacementField", "submitButton"]
|
||||
static classes = [ "dangerousAction", "safeAction" ]
|
||||
static values = {
|
||||
submitTextWhenReplacing: String,
|
||||
submitTextWhenNotReplacing: String
|
||||
}
|
||||
|
||||
updateSubmitButton() {
|
||||
if (this.replacementFieldTarget.value) {
|
||||
this.submitButtonTarget.value = this.submitTextWhenReplacingValue
|
||||
this.#markSafe()
|
||||
} else {
|
||||
this.submitButtonTarget.value = this.submitTextWhenNotReplacingValue
|
||||
this.#markDangerous()
|
||||
}
|
||||
}
|
||||
|
||||
#markSafe() {
|
||||
this.submitButtonTarget.classList.remove(...this.dangerousActionClasses)
|
||||
this.submitButtonTarget.classList.add(...this.safeActionClasses)
|
||||
}
|
||||
|
||||
#markDangerous() {
|
||||
this.submitButtonTarget.classList.remove(...this.safeActionClasses)
|
||||
this.submitButtonTarget.classList.add(...this.dangerousActionClasses)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { install, uninstall } from "@github/hotkey"
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import { install, uninstall } from "@github/hotkey";
|
||||
|
||||
// Connects to data-controller="hotkey"
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
install(this.element)
|
||||
install(this.element);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
uninstall(this.element)
|
||||
uninstall(this.element);
|
||||
}
|
||||
|
||||
navigateBack(event) {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import tailwindColors from "@maybe/tailwindcolors";
|
||||
import * as d3 from "d3";
|
||||
|
||||
// Connects to data-controller="line-chart"
|
||||
export default class extends Controller {
|
||||
static values = { series: Object };
|
||||
|
||||
connect() {
|
||||
this.renderChart(this.seriesValue);
|
||||
document.addEventListener("turbo:load", this.renderChart);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener("turbo:load", this.renderChart);
|
||||
}
|
||||
|
||||
renderChart = () => {
|
||||
const data = this.prepareData(this.seriesValue);
|
||||
this.drawChart(data);
|
||||
};
|
||||
|
||||
trendStyles(trendDirection) {
|
||||
return {
|
||||
up: {
|
||||
icon: "↑",
|
||||
color: tailwindColors.success,
|
||||
},
|
||||
down: {
|
||||
icon: "↓",
|
||||
color: tailwindColors.error,
|
||||
},
|
||||
flat: {
|
||||
icon: "→",
|
||||
color: tailwindColors.gray[500],
|
||||
},
|
||||
}[trendDirection];
|
||||
}
|
||||
|
||||
prepareData(series) {
|
||||
return series.values.map((b) => ({
|
||||
date: new Date(b.date + "T00:00:00"),
|
||||
value: +b.value.amount,
|
||||
styles: this.trendStyles(b.trend.direction),
|
||||
trend: b.trend,
|
||||
formatted: {
|
||||
value: Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: b.value.currency || "USD",
|
||||
}).format(b.value.amount),
|
||||
change: Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: b.value.currency || "USD",
|
||||
signDisplay: "always",
|
||||
}).format(b.trend.value.amount),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
drawChart(data) {
|
||||
const chartContainer = d3.select(this.element);
|
||||
|
||||
// Clear any existing chart
|
||||
chartContainer.selectAll("svg").remove();
|
||||
|
||||
const initialDimensions = {
|
||||
width: chartContainer.node().clientWidth,
|
||||
height: chartContainer.node().clientHeight,
|
||||
};
|
||||
|
||||
const svg = chartContainer
|
||||
.append("svg")
|
||||
.attr("width", initialDimensions.width)
|
||||
.attr("height", initialDimensions.height)
|
||||
.attr("viewBox", [
|
||||
0,
|
||||
0,
|
||||
initialDimensions.width,
|
||||
initialDimensions.height,
|
||||
])
|
||||
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
|
||||
|
||||
if (data.length === 1) {
|
||||
this.renderEmpty(svg, initialDimensions);
|
||||
return;
|
||||
}
|
||||
|
||||
const margin = { top: 20, right: 1, bottom: 30, left: 1 },
|
||||
width = +svg.attr("width") - margin.left - margin.right,
|
||||
height = +svg.attr("height") - margin.top - margin.bottom,
|
||||
g = svg
|
||||
.append("g")
|
||||
.attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// X-Axis
|
||||
const x = d3
|
||||
.scaleTime()
|
||||
.rangeRound([0, width])
|
||||
.domain(d3.extent(data, (d) => d.date));
|
||||
|
||||
const PADDING = 0.15; // 15% padding on top and bottom of data
|
||||
const dataMin = d3.min(data, (d) => d.value);
|
||||
const dataMax = d3.max(data, (d) => d.value);
|
||||
const padding = (dataMax - dataMin) * PADDING;
|
||||
|
||||
// Y-Axis
|
||||
const y = d3
|
||||
.scaleLinear()
|
||||
.rangeRound([height, 0])
|
||||
.domain([dataMin - padding, dataMax + padding]);
|
||||
|
||||
// X-Axis labels
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${height})`)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(x)
|
||||
.tickValues([data[0].date, data[data.length - 1].date])
|
||||
.tickSize(0)
|
||||
.tickFormat(d3.timeFormat("%b %Y"))
|
||||
)
|
||||
.select(".domain")
|
||||
.remove();
|
||||
|
||||
g.selectAll(".tick text")
|
||||
.style("fill", tailwindColors.gray[500])
|
||||
.style("font-size", "12px")
|
||||
.style("font-weight", "500")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dx", (d, i) => {
|
||||
// We know we only have 2 values
|
||||
return i === 0 ? "5em" : "-5em";
|
||||
})
|
||||
.attr("dy", "0em");
|
||||
|
||||
// Line
|
||||
const line = d3
|
||||
.line()
|
||||
.x((d) => x(d.date))
|
||||
.y((d) => y(d.value));
|
||||
|
||||
g.append("path")
|
||||
.datum(data)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", tailwindColors.green[500])
|
||||
.attr("stroke-linejoin", "round")
|
||||
.attr("stroke-linecap", "round")
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("class", "line-chart-path")
|
||||
.attr("d", line);
|
||||
|
||||
const tooltip = d3
|
||||
.select("#lineChart")
|
||||
.append("div")
|
||||
.style("position", "absolute")
|
||||
.style("padding", "8px")
|
||||
.style("font", "14px Inter, sans-serif")
|
||||
.style("background", tailwindColors.white)
|
||||
.style("border", `1px solid ${tailwindColors["alpha-black"][100]}`)
|
||||
.style("border-radius", "10px")
|
||||
.style("pointer-events", "none")
|
||||
.style("opacity", 0); // Starts as hidden
|
||||
|
||||
// Helper to find the closest data point to the mouse
|
||||
const bisectDate = d3.bisector(function (d) {
|
||||
return d.date;
|
||||
}).left;
|
||||
|
||||
// Create an invisible rectangle that captures mouse events (regular SVG elements don't capture mouse events by default)
|
||||
g.append("rect")
|
||||
.attr("width", width)
|
||||
.attr("height", height)
|
||||
.attr("fill", "none")
|
||||
.attr("pointer-events", "all")
|
||||
// When user hovers over the chart, show the tooltip and a circle at the closest data point
|
||||
.on("mousemove", (event) => {
|
||||
tooltip.style("opacity", 1);
|
||||
|
||||
const tooltipWidth = 250; // Estimate or dynamically calculate the tooltip width
|
||||
const pageWidth = document.body.clientWidth;
|
||||
const tooltipX = event.pageX + 10;
|
||||
const overflowX = tooltipX + tooltipWidth - pageWidth;
|
||||
|
||||
const [xPos] = d3.pointer(event);
|
||||
|
||||
const x0 = bisectDate(data, x.invert(xPos), 1);
|
||||
const d0 = data[x0 - 1];
|
||||
const d1 = data[x0];
|
||||
const d = xPos - x(d0.date) > x(d1.date) - xPos ? d1 : d0;
|
||||
|
||||
// Adjust tooltip position based on overflow
|
||||
const adjustedX =
|
||||
overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX;
|
||||
|
||||
g.selectAll(".data-point-circle").remove(); // Remove existing circles to ensure only one is shown at a time
|
||||
g.append("circle")
|
||||
.attr("class", "data-point-circle")
|
||||
.attr("cx", x(d.date))
|
||||
.attr("cy", y(d.value))
|
||||
.attr("r", 8)
|
||||
.attr("fill", tailwindColors.green[500])
|
||||
.attr("fill-opacity", "0.1")
|
||||
.attr("pointer-events", "none");
|
||||
|
||||
g.append("circle")
|
||||
.attr("class", "data-point-circle")
|
||||
.attr("cx", x(d.date))
|
||||
.attr("cy", y(d.value))
|
||||
.attr("r", 3)
|
||||
.attr("fill", tailwindColors.green[500])
|
||||
.attr("pointer-events", "none");
|
||||
|
||||
tooltip
|
||||
.html(
|
||||
`<div style="margin-bottom: 4px; color: ${
|
||||
tailwindColors.gray[500]
|
||||
}">${d3.timeFormat("%b %d, %Y")(d.date)}</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<svg width="10" height="10">
|
||||
<circle cx="5" cy="5" r="4" stroke="${
|
||||
d.styles.color
|
||||
}" fill="transparent" stroke-width="1"></circle>
|
||||
</svg>
|
||||
${d.formatted.value} <span style="color: ${
|
||||
d.styles.color
|
||||
};">${d.formatted.change} (${d.trend.percent}%)</span>
|
||||
</div>`
|
||||
)
|
||||
.style("left", adjustedX + "px")
|
||||
.style("top", event.pageY - 10 + "px");
|
||||
|
||||
g.selectAll(".guideline").remove(); // Remove existing line to ensure only one is shown at a time
|
||||
g.append("line")
|
||||
.attr("class", "guideline")
|
||||
.attr("x1", x(d.date))
|
||||
.attr("y1", 0)
|
||||
.attr("x2", x(d.date))
|
||||
.attr("y2", height)
|
||||
.attr("stroke", tailwindColors.gray[300])
|
||||
.attr("stroke-dasharray", "4, 4");
|
||||
})
|
||||
.on("mouseout", () => {
|
||||
g.selectAll(".guideline").remove();
|
||||
g.selectAll(".data-point-circle").remove();
|
||||
tooltip.style("opacity", 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Dot in middle of chart as placeholder for empty chart
|
||||
renderEmpty(svg, { width, height }) {
|
||||
svg
|
||||
.append("line")
|
||||
.attr("x1", width / 2)
|
||||
.attr("y1", 0)
|
||||
.attr("x2", width / 2)
|
||||
.attr("y2", height)
|
||||
.attr("stroke", tailwindColors.gray[300])
|
||||
.attr("stroke-dasharray", "4, 4");
|
||||
|
||||
svg
|
||||
.append("circle")
|
||||
.attr("cx", width / 2)
|
||||
.attr("cy", height / 2)
|
||||
.attr("r", 4)
|
||||
.style("fill", tailwindColors.gray[400]);
|
||||
|
||||
svg.selectAll(".tick").remove();
|
||||
svg.selectAll(".domain").remove();
|
||||
}
|
||||
}
|
||||
32
app/javascript/controllers/merchant_avatar_controller.js
Normal file
32
app/javascript/controllers/merchant_avatar_controller.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import {Controller} from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="merchant-avatar"
|
||||
// Used by the transaction merchant form to show a preview of what the avatar will look like
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"name",
|
||||
"color",
|
||||
"avatar"
|
||||
];
|
||||
|
||||
connect() {
|
||||
this.nameTarget.addEventListener("input", this.handleNameChange);
|
||||
this.colorTarget.addEventListener("input", this.handleColorChange);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.nameTarget.removeEventListener("input", this.handleNameChange);
|
||||
this.colorTarget.removeEventListener("input", this.handleColorChange);
|
||||
}
|
||||
|
||||
handleNameChange = (e) => {
|
||||
this.avatarTarget.textContent = (e.currentTarget.value?.[0] || "?").toUpperCase();
|
||||
}
|
||||
|
||||
handleColorChange = (e) => {
|
||||
const color = e.currentTarget.value;
|
||||
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 5%, white)`;
|
||||
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
|
||||
this.avatarTarget.style.color = color;
|
||||
}
|
||||
}
|
||||
26
app/javascript/controllers/money_field_controller.js
Normal file
26
app/javascript/controllers/money_field_controller.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import { CurrenciesService } from "services/currencies_service";
|
||||
|
||||
// Connects to data-controller="money-field"
|
||||
// when currency select change, update the input value with the correct placeholder and step
|
||||
export default class extends Controller {
|
||||
static targets = ["amount", "currency", "symbol"];
|
||||
|
||||
handleCurrencyChange(e) {
|
||||
const selectedCurrency = e.target.value;
|
||||
this.updateAmount(selectedCurrency);
|
||||
}
|
||||
|
||||
updateAmount(currency) {
|
||||
(new CurrenciesService).get(currency).then((currency) => {
|
||||
console.log(currency)
|
||||
this.amountTarget.step = currency.step;
|
||||
|
||||
if (isFinite(this.amountTarget.value)) {
|
||||
this.amountTarget.value = parseFloat(this.amountTarget.value).toFixed(currency.default_precision)
|
||||
}
|
||||
|
||||
this.symbolTarget.innerText = currency.symbol;
|
||||
});
|
||||
}
|
||||
}
|
||||
176
app/javascript/controllers/pie_chart_controller.js
Normal file
176
app/javascript/controllers/pie_chart_controller.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import * as d3 from "d3";
|
||||
|
||||
// Connects to data-controller="pie-chart"
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
data: Array,
|
||||
label: String,
|
||||
};
|
||||
|
||||
#d3SvgMemo = null;
|
||||
#d3GroupMemo = null;
|
||||
#d3ContentMemo = null;
|
||||
#d3ViewboxWidth = 200;
|
||||
#d3ViewboxHeight = 200;
|
||||
|
||||
connect() {
|
||||
this.#draw();
|
||||
document.addEventListener("turbo:load", this.#redraw);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#teardown();
|
||||
document.removeEventListener("turbo:load", this.#redraw);
|
||||
}
|
||||
|
||||
#redraw = () => {
|
||||
this.#teardown();
|
||||
this.#draw();
|
||||
};
|
||||
|
||||
#teardown() {
|
||||
this.#d3SvgMemo = null;
|
||||
this.#d3GroupMemo = null;
|
||||
this.#d3ContentMemo = null;
|
||||
this.#d3Container.selectAll("*").remove();
|
||||
}
|
||||
|
||||
#draw() {
|
||||
this.#d3Container.attr("class", "relative");
|
||||
this.#d3Content.html(this.#contentSummaryTemplate(this.dataValue));
|
||||
|
||||
const pie = d3
|
||||
.pie()
|
||||
.value((d) => d.percent_of_total)
|
||||
.padAngle(0.06);
|
||||
|
||||
const arc = d3
|
||||
.arc()
|
||||
.innerRadius(this.#radius - 8)
|
||||
.outerRadius(this.#radius)
|
||||
.cornerRadius(2);
|
||||
|
||||
const arcs = this.#d3Group
|
||||
.selectAll("arc")
|
||||
.data(pie(this.dataValue))
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "arc");
|
||||
|
||||
const paths = arcs
|
||||
.append("path")
|
||||
.attr("class", (d) => d.data.fill_color)
|
||||
.attr("d", arc);
|
||||
|
||||
paths
|
||||
.on("mouseover", (event) => {
|
||||
this.#d3Svg.selectAll(".arc path").attr("class", "fill-gray-200");
|
||||
d3.select(event.target).attr("class", (d) => d.data.fill_color);
|
||||
this.#d3ContentMemo.html(
|
||||
this.#contentDetailTemplate(d3.select(event.target).datum().data),
|
||||
);
|
||||
})
|
||||
.on("mouseout", () => {
|
||||
this.#d3Svg
|
||||
.selectAll(".arc path")
|
||||
.attr("class", (d) => d.data.fill_color);
|
||||
this.#d3ContentMemo.html(this.#contentSummaryTemplate(this.dataValue));
|
||||
});
|
||||
}
|
||||
|
||||
#contentSummaryTemplate(data) {
|
||||
const total = data.reduce((acc, cur) => acc + cur.value, 0);
|
||||
const currency = data[0].currency;
|
||||
|
||||
return `${this.#currencyValue({
|
||||
value: total,
|
||||
currency,
|
||||
})} <span class="text-xs">${this.labelValue}</span>`;
|
||||
}
|
||||
|
||||
#contentDetailTemplate(datum) {
|
||||
return `
|
||||
<span>${this.#currencyValue(datum)}</span>
|
||||
<div class="flex flex-row text-xs gap-2 items-center">
|
||||
<div class="w-[10px] h-[10px] rounded-full ${datum.bg_color}"></div>
|
||||
<span>${datum.label}</span>
|
||||
<span>${datum.percent_of_total}%</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
#currencyValue(datum) {
|
||||
const formattedValue = Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: datum.currency,
|
||||
currencyDisplay: "narrowSymbol",
|
||||
}).format(datum.value);
|
||||
|
||||
const firstDigitIndex = formattedValue.search(/\d/);
|
||||
const currencyPrefix = formattedValue.substring(0, firstDigitIndex);
|
||||
const mainPart = formattedValue.substring(firstDigitIndex);
|
||||
const [integerPart, fractionalPart] = mainPart.split(".");
|
||||
|
||||
return `<p class="text-gray-500 -space-x-0.5">${currencyPrefix}<span class="text-xl text-gray-900 font-medium">${integerPart}</span>.${fractionalPart}</p>`;
|
||||
}
|
||||
|
||||
get #radius() {
|
||||
return Math.min(this.#d3ViewboxWidth, this.#d3ViewboxHeight) / 2;
|
||||
}
|
||||
|
||||
get #d3Container() {
|
||||
return d3.select(this.element);
|
||||
}
|
||||
|
||||
get #d3Svg() {
|
||||
if (this.#d3SvgMemo) {
|
||||
return this.#d3SvgMemo;
|
||||
} else {
|
||||
return (this.#d3SvgMemo = this.#createMainSvg());
|
||||
}
|
||||
}
|
||||
|
||||
get #d3Group() {
|
||||
if (this.#d3GroupMemo) {
|
||||
return this.#d3GroupMemo;
|
||||
} else {
|
||||
return (this.#d3GroupMemo = this.#createMainGroup());
|
||||
}
|
||||
}
|
||||
|
||||
get #d3Content() {
|
||||
if (this.#d3ContentMemo) {
|
||||
return this.#d3ContentMemo;
|
||||
} else {
|
||||
return (this.#d3ContentMemo = this.#createContent());
|
||||
}
|
||||
}
|
||||
|
||||
#createMainSvg() {
|
||||
return this.#d3Container
|
||||
.append("svg")
|
||||
.attr("width", "100%")
|
||||
.attr("class", "relative aspect-1")
|
||||
.attr("viewBox", [0, 0, this.#d3ViewboxWidth, this.#d3ViewboxHeight]);
|
||||
}
|
||||
|
||||
#createMainGroup() {
|
||||
return this.#d3Svg
|
||||
.append("g")
|
||||
.attr(
|
||||
"transform",
|
||||
`translate(${this.#d3ViewboxWidth / 2},${this.#d3ViewboxHeight / 2})`,
|
||||
);
|
||||
}
|
||||
|
||||
#createContent() {
|
||||
this.#d3ContentMemo = this.#d3Container
|
||||
.append("div")
|
||||
.attr(
|
||||
"class",
|
||||
"absolute inset-0 w-full text-center flex flex-col items-center justify-center",
|
||||
);
|
||||
return this.#d3ContentMemo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["imagePreview", "fileField", "deleteField", "clearBtn", "template"]
|
||||
|
||||
preview(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
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");
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.deleteFieldTarget.value = true;
|
||||
this.fileFieldTarget.value = null;
|
||||
this.templateTarget.classList.remove("hidden");
|
||||
this.imagePreviewTarget.innerHTML = this.templateTarget.innerHTML;
|
||||
this.clearBtnTarget.classList.add("hidden");
|
||||
this.element.submit();
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
/**
|
||||
* A custom "select" element that follows accessibility patterns of a native select element.
|
||||
*
|
||||
* - If you need to display arbitrary content including non-clickable items, links, buttons, and forms, use the "popover" controller instead.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static classes = ["active"];
|
||||
static targets = ["option", "button", "list", "input", "buttonText"];
|
||||
static values = { selected: String };
|
||||
|
||||
initialize() {
|
||||
this.show = false;
|
||||
|
||||
const selectedElement = this.optionTargets.find(
|
||||
(option) => option.dataset.value === this.selectedValue
|
||||
);
|
||||
if (selectedElement) {
|
||||
this.updateAriaAttributesAndClasses(selectedElement);
|
||||
this.syncButtonTextWithInput();
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.syncButtonTextWithInput();
|
||||
if (this.hasButtonTarget) {
|
||||
this.buttonTarget.addEventListener("click", this.toggleList);
|
||||
}
|
||||
this.element.addEventListener("keydown", this.handleKeydown);
|
||||
document.addEventListener("click", this.handleOutsideClick);
|
||||
this.element.addEventListener("turbo:load", this.handleTurboLoad);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("keydown", this.handleKeydown);
|
||||
document.removeEventListener("click", this.handleOutsideClick);
|
||||
this.element.removeEventListener("turbo:load", this.handleTurboLoad);
|
||||
|
||||
if (this.hasButtonTarget) {
|
||||
this.buttonTarget.removeEventListener("click", this.toggleList);
|
||||
}
|
||||
}
|
||||
|
||||
selectedValueChanged() {
|
||||
this.syncButtonTextWithInput();
|
||||
}
|
||||
|
||||
handleOutsideClick = (event) => {
|
||||
if (this.show && !this.element.contains(event.target)) {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
handleTurboLoad = () => {
|
||||
this.close();
|
||||
this.syncButtonTextWithInput();
|
||||
};
|
||||
|
||||
handleKeydown = (event) => {
|
||||
switch (event.key) {
|
||||
case " ":
|
||||
case "Enter":
|
||||
event.preventDefault(); // Prevent the default action to avoid scrolling
|
||||
if (
|
||||
this.hasButtonTarget &&
|
||||
document.activeElement === this.buttonTarget
|
||||
) {
|
||||
this.toggleList();
|
||||
} else {
|
||||
this.selectOption(event);
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
event.preventDefault(); // Prevent the default action to avoid scrolling
|
||||
this.focusNextOption();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault(); // Prevent the default action to avoid scrolling
|
||||
this.focusPreviousOption();
|
||||
break;
|
||||
case "Escape":
|
||||
this.close();
|
||||
if (this.hasButtonTarget) {
|
||||
this.buttonTarget.focus(); // Bring focus back to the button
|
||||
}
|
||||
break;
|
||||
case "Tab":
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
focusNextOption() {
|
||||
this.focusOptionInDirection(1);
|
||||
}
|
||||
|
||||
focusPreviousOption() {
|
||||
this.focusOptionInDirection(-1);
|
||||
}
|
||||
|
||||
focusOptionInDirection(direction) {
|
||||
const currentFocusedIndex = this.optionTargets.findIndex(
|
||||
(option) => option === document.activeElement
|
||||
);
|
||||
const optionsCount = this.optionTargets.length;
|
||||
const nextIndex =
|
||||
(currentFocusedIndex + direction + optionsCount) % optionsCount;
|
||||
this.optionTargets[nextIndex].focus();
|
||||
}
|
||||
|
||||
toggleList = () => {
|
||||
if (!this.hasButtonTarget) return; // Ensure button target is present before toggling
|
||||
|
||||
this.show = !this.show;
|
||||
this.listTarget.classList.toggle("hidden", !this.show);
|
||||
this.buttonTarget.setAttribute("aria-expanded", this.show.toString());
|
||||
|
||||
if (this.show) {
|
||||
// Focus the first option or the selected option when the list is shown
|
||||
const selectedOption = this.optionTargets.find(
|
||||
(option) => option.getAttribute("aria-selected") === "true"
|
||||
);
|
||||
(selectedOption || this.optionTargets[0]).focus();
|
||||
}
|
||||
};
|
||||
|
||||
close() {
|
||||
if (this.hasButtonTarget) {
|
||||
this.show = false;
|
||||
this.listTarget.classList.add("hidden");
|
||||
this.buttonTarget.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
}
|
||||
|
||||
selectOption(event) {
|
||||
const selectedOption =
|
||||
event.type === "keydown" ? document.activeElement : event.currentTarget;
|
||||
this.updateAriaAttributesAndClasses(selectedOption);
|
||||
if (this.inputTarget.value !== selectedOption.getAttribute("data-value")) {
|
||||
this.updateInputValueAndEmitEvent(selectedOption);
|
||||
}
|
||||
this.close(); // Close the list after selection
|
||||
}
|
||||
|
||||
updateAriaAttributesAndClasses(selectedOption) {
|
||||
this.optionTargets.forEach((option) => {
|
||||
option.setAttribute("aria-selected", "false");
|
||||
option.setAttribute("tabindex", "-1");
|
||||
option.classList.remove(...this.activeClasses);
|
||||
});
|
||||
selectedOption.classList.add(...this.activeClasses);
|
||||
selectedOption.setAttribute("aria-selected", "true");
|
||||
selectedOption.focus();
|
||||
}
|
||||
|
||||
updateInputValueAndEmitEvent(selectedOption) {
|
||||
// Update the hidden input's value
|
||||
const selectedValue = selectedOption.getAttribute("data-value");
|
||||
this.inputTarget.value = selectedValue;
|
||||
this.syncButtonTextWithInput();
|
||||
|
||||
// Emit an input event for auto-submit functionality
|
||||
const inputEvent = new Event("input", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
this.inputTarget.dispatchEvent(inputEvent);
|
||||
}
|
||||
|
||||
syncButtonTextWithInput() {
|
||||
const matchingOption = this.optionTargets.find(
|
||||
(option) => option.getAttribute("data-value") === this.inputTarget.value
|
||||
);
|
||||
if (matchingOption && this.hasButtonTextTarget) {
|
||||
this.buttonTextTarget.textContent = matchingOption.textContent.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,10 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
select(event) {
|
||||
this.updateClasses(event.target.dataset.id);
|
||||
const element = event.target.closest("[data-id]");
|
||||
if (element) {
|
||||
this.updateClasses(element.dataset.id);
|
||||
}
|
||||
}
|
||||
|
||||
onTurboLoad = () => {
|
||||
|
||||
520
app/javascript/controllers/time_series_chart_controller.js
Normal file
520
app/javascript/controllers/time_series_chart_controller.js
Normal file
@@ -0,0 +1,520 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import tailwindColors from "@maybe/tailwindcolors"
|
||||
import * as d3 from "d3"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
data: Object,
|
||||
strokeWidth: { type: Number, default: 2 },
|
||||
useLabels: { type: Boolean, default: true },
|
||||
useTooltip: { type: Boolean, default: true },
|
||||
usePercentSign: Boolean
|
||||
}
|
||||
|
||||
#d3SvgMemo = null
|
||||
#d3GroupMemo = null
|
||||
#d3Tooltip = null
|
||||
#d3InitialContainerWidth = 0
|
||||
#d3InitialContainerHeight = 0
|
||||
#normalDataPoints = []
|
||||
|
||||
connect() {
|
||||
this.#install()
|
||||
document.addEventListener("turbo:load", this.#reinstall)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#teardown()
|
||||
document.removeEventListener("turbo:load", this.#reinstall)
|
||||
}
|
||||
|
||||
|
||||
#reinstall = () => {
|
||||
this.#teardown()
|
||||
this.#install()
|
||||
}
|
||||
|
||||
#teardown() {
|
||||
this.#d3SvgMemo = null
|
||||
this.#d3GroupMemo = null
|
||||
this.#d3Tooltip = null
|
||||
this.#normalDataPoints = []
|
||||
|
||||
this.#d3Container.selectAll("*").remove()
|
||||
}
|
||||
|
||||
#install() {
|
||||
this.#normalizeDataPoints()
|
||||
this.#rememberInitialContainerSize()
|
||||
this.#draw()
|
||||
}
|
||||
|
||||
|
||||
#normalizeDataPoints() {
|
||||
this.#normalDataPoints = (this.dataValue.values || []).map((d) => ({
|
||||
...d,
|
||||
date: new Date(d.date),
|
||||
value: d.value.amount ? +d.value.amount : +d.value,
|
||||
currency: d.value.currency
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
#rememberInitialContainerSize() {
|
||||
this.#d3InitialContainerWidth = this.#d3Container.node().clientWidth
|
||||
this.#d3InitialContainerHeight = this.#d3Container.node().clientHeight
|
||||
}
|
||||
|
||||
|
||||
#draw() {
|
||||
if (this.#normalDataPoints.length < 2) {
|
||||
this.#drawEmpty()
|
||||
} else {
|
||||
this.#drawChart()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#drawEmpty() {
|
||||
this.#d3Svg.selectAll(".tick").remove()
|
||||
this.#d3Svg.selectAll(".domain").remove()
|
||||
|
||||
this.#drawDashedLineEmptyState()
|
||||
this.#drawCenteredCircleEmptyState()
|
||||
}
|
||||
|
||||
#drawDashedLineEmptyState() {
|
||||
this.#d3Svg
|
||||
.append("line")
|
||||
.attr("x1", this.#d3InitialContainerWidth / 2)
|
||||
.attr("y1", 0)
|
||||
.attr("x2", this.#d3InitialContainerWidth / 2)
|
||||
.attr("y2", this.#d3InitialContainerHeight)
|
||||
.attr("stroke", tailwindColors.gray[300])
|
||||
.attr("stroke-dasharray", "4, 4")
|
||||
}
|
||||
|
||||
#drawCenteredCircleEmptyState() {
|
||||
this.#d3Svg
|
||||
.append("circle")
|
||||
.attr("cx", this.#d3InitialContainerWidth / 2)
|
||||
.attr("cy", this.#d3InitialContainerHeight / 2)
|
||||
.attr("r", 4)
|
||||
.style("fill", tailwindColors.gray[400])
|
||||
}
|
||||
|
||||
|
||||
#drawChart() {
|
||||
this.#drawTrendline()
|
||||
|
||||
if (this.useLabelsValue) {
|
||||
this.#drawXAxisLabels()
|
||||
this.#drawGradientBelowTrendline()
|
||||
}
|
||||
|
||||
if (this.useTooltipValue) {
|
||||
this.#drawTooltip()
|
||||
this.#trackMouseForShowingTooltip()
|
||||
}
|
||||
}
|
||||
|
||||
#drawTrendline() {
|
||||
this.#installTrendlineSplit()
|
||||
|
||||
this.#d3Group
|
||||
.append("path")
|
||||
.datum(this.#normalDataPoints)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", `url(#${this.element.id}-split-gradient)`)
|
||||
.attr("d", this.#d3Line)
|
||||
.attr("stroke-linejoin", "round")
|
||||
.attr("stroke-linecap", "round")
|
||||
.attr("stroke-width", this.strokeWidthValue)
|
||||
}
|
||||
|
||||
#installTrendlineSplit() {
|
||||
const gradient = this.#d3Svg
|
||||
.append("defs")
|
||||
.append("linearGradient")
|
||||
.attr("id", `${this.element.id}-split-gradient`)
|
||||
.attr("gradientUnits", "userSpaceOnUse")
|
||||
.attr("x1", this.#d3XScale.range()[0])
|
||||
.attr("x2", this.#d3XScale.range()[1])
|
||||
|
||||
gradient.append("stop")
|
||||
.attr("class", "start-color")
|
||||
.attr("offset", "0%")
|
||||
.attr("stop-color", this.#trendColor)
|
||||
|
||||
gradient.append("stop")
|
||||
.attr("class", "middle-color")
|
||||
.attr("offset", "100%")
|
||||
.attr("stop-color", this.#trendColor)
|
||||
|
||||
gradient.append("stop")
|
||||
.attr("class", "end-color")
|
||||
.attr("offset", "100%")
|
||||
.attr("stop-color", tailwindColors.gray[300])
|
||||
}
|
||||
|
||||
#setTrendlineSplitAt(percent) {
|
||||
this.#d3Svg
|
||||
.select(`#${this.element.id}-split-gradient`)
|
||||
.select(".middle-color")
|
||||
.attr("offset", `${percent * 100}%`)
|
||||
|
||||
this.#d3Svg
|
||||
.select(`#${this.element.id}-split-gradient`)
|
||||
.select(".end-color")
|
||||
.attr("offset", `${percent * 100}%`)
|
||||
|
||||
this.#d3Svg
|
||||
.select(`#${this.element.id}-trendline-gradient-rect`)
|
||||
.attr("width", this.#d3ContainerWidth * percent)
|
||||
}
|
||||
|
||||
#drawXAxisLabels() {
|
||||
// Add ticks
|
||||
this.#d3Group
|
||||
.append("g")
|
||||
.attr("transform", `translate(0,${this.#d3ContainerHeight})`)
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(this.#d3XScale)
|
||||
.tickValues([ this.#normalDataPoints[0].date, this.#normalDataPoints[this.#normalDataPoints.length - 1].date ])
|
||||
.tickSize(0)
|
||||
.tickFormat(d3.timeFormat("%d %b %Y"))
|
||||
)
|
||||
.select(".domain")
|
||||
.remove()
|
||||
|
||||
// Style ticks
|
||||
this.#d3Group.selectAll(".tick text")
|
||||
.style("fill", tailwindColors.gray[500])
|
||||
.style("font-size", "12px")
|
||||
.style("font-weight", "500")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dx", (_d, i) => {
|
||||
// We know we only have 2 values
|
||||
return i === 0 ? "5em" : "-5em"
|
||||
})
|
||||
.attr("dy", "0em")
|
||||
}
|
||||
|
||||
#drawGradientBelowTrendline() {
|
||||
// Define gradient
|
||||
const gradient = this.#d3Group
|
||||
.append("defs")
|
||||
.append("linearGradient")
|
||||
.attr("id", `${this.element.id}-trendline-gradient`)
|
||||
.attr("gradientUnits", "userSpaceOnUse")
|
||||
.attr("x1", 0)
|
||||
.attr("x2", 0)
|
||||
.attr("y1", this.#d3YScale(d3.max(this.#normalDataPoints, d => d.value)))
|
||||
.attr("y2", this.#d3ContainerHeight)
|
||||
|
||||
gradient
|
||||
.append("stop")
|
||||
.attr("offset", 0)
|
||||
.attr("stop-color", this.#trendColor)
|
||||
.attr("stop-opacity", 0.06)
|
||||
|
||||
gradient
|
||||
.append("stop")
|
||||
.attr("offset", 0.5)
|
||||
.attr("stop-color", this.#trendColor)
|
||||
.attr("stop-opacity", 0)
|
||||
|
||||
// Clip path makes gradient start at the trendline
|
||||
this.#d3Group
|
||||
.append("clipPath")
|
||||
.attr("id", `${this.element.id}-clip-below-trendline`)
|
||||
.append("path")
|
||||
.datum(this.#normalDataPoints)
|
||||
.attr("d", d3.area()
|
||||
.x(d => this.#d3XScale(d.date))
|
||||
.y0(this.#d3ContainerHeight)
|
||||
.y1(d => this.#d3YScale(d.value))
|
||||
)
|
||||
|
||||
// Apply the gradient + clip path
|
||||
this.#d3Group
|
||||
.append("rect")
|
||||
.attr("id", `${this.element.id}-trendline-gradient-rect`)
|
||||
.attr("width", this.#d3ContainerWidth)
|
||||
.attr("height", this.#d3ContainerHeight)
|
||||
.attr("clip-path", `url(#${this.element.id}-clip-below-trendline)`)
|
||||
.style("fill", `url(#${this.element.id}-trendline-gradient)`)
|
||||
}
|
||||
|
||||
|
||||
#drawTooltip() {
|
||||
this.#d3Tooltip = d3
|
||||
.select(`#${this.element.id}`)
|
||||
.append("div")
|
||||
.style("position", "absolute")
|
||||
.style("padding", "8px")
|
||||
.style("font", "14px Inter, sans-serif")
|
||||
.style("background", tailwindColors.white)
|
||||
.style("border", `1px solid ${tailwindColors["alpha-black"][100]}`)
|
||||
.style("border-radius", "10px")
|
||||
.style("pointer-events", "none")
|
||||
.style("opacity", 0) // Starts as hidden
|
||||
}
|
||||
|
||||
#trackMouseForShowingTooltip() {
|
||||
const bisectDate = d3.bisector(d => d.date).left
|
||||
|
||||
this.#d3Group
|
||||
.append("rect")
|
||||
.attr("width", this.#d3ContainerWidth)
|
||||
.attr("height", this.#d3ContainerHeight)
|
||||
.attr("fill", "none")
|
||||
.attr("pointer-events", "all")
|
||||
.on("mousemove", (event) => {
|
||||
const estimatedTooltipWidth = 250
|
||||
const pageWidth = document.body.clientWidth
|
||||
const tooltipX = event.pageX + 10
|
||||
const overflowX = tooltipX + estimatedTooltipWidth - pageWidth
|
||||
const adjustedX = overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX
|
||||
|
||||
const [xPos] = d3.pointer(event)
|
||||
const x0 = bisectDate(this.#normalDataPoints, this.#d3XScale.invert(xPos), 1)
|
||||
const d0 = this.#normalDataPoints[x0 - 1]
|
||||
const d1 = this.#normalDataPoints[x0]
|
||||
const d = xPos - this.#d3XScale(d0.date) > this.#d3XScale(d1.date) - xPos ? d1 : d0
|
||||
const xPercent = this.#d3XScale(d.date) / this.#d3ContainerWidth
|
||||
|
||||
this.#setTrendlineSplitAt(xPercent)
|
||||
|
||||
// Reset
|
||||
this.#d3Group.selectAll(".data-point-circle").remove()
|
||||
this.#d3Group.selectAll(".guideline").remove()
|
||||
|
||||
// Guideline
|
||||
this.#d3Group
|
||||
.append("line")
|
||||
.attr("class", "guideline")
|
||||
.attr("x1", this.#d3XScale(d.date))
|
||||
.attr("y1", 0)
|
||||
.attr("x2", this.#d3XScale(d.date))
|
||||
.attr("y2", this.#d3ContainerHeight)
|
||||
.attr("stroke", tailwindColors.gray[300])
|
||||
.attr("stroke-dasharray", "4, 4")
|
||||
|
||||
// Big circle
|
||||
this.#d3Group
|
||||
.append("circle")
|
||||
.attr("class", "data-point-circle")
|
||||
.attr("cx", this.#d3XScale(d.date))
|
||||
.attr("cy", this.#d3YScale(d.value))
|
||||
.attr("r", 8)
|
||||
.attr("fill", this.#trendColor)
|
||||
.attr("fill-opacity", "0.1")
|
||||
.attr("pointer-events", "none")
|
||||
|
||||
// Small circle
|
||||
this.#d3Group
|
||||
.append("circle")
|
||||
.attr("class", "data-point-circle")
|
||||
.attr("cx", this.#d3XScale(d.date))
|
||||
.attr("cy", this.#d3YScale(d.value))
|
||||
.attr("r", 3)
|
||||
.attr("fill", this.#trendColor)
|
||||
.attr("pointer-events", "none")
|
||||
|
||||
// Render tooltip
|
||||
this.#d3Tooltip
|
||||
.html(this.#tooltipTemplate(d))
|
||||
.style("opacity", 1)
|
||||
.style("z-index", 999)
|
||||
.style("left", adjustedX + "px")
|
||||
.style("top", event.pageY - 10 + "px")
|
||||
})
|
||||
.on("mouseout", (event) => {
|
||||
const hoveringOnGuideline = event.toElement?.classList.contains("guideline")
|
||||
|
||||
if (!hoveringOnGuideline) {
|
||||
this.#d3Group.selectAll(".guideline").remove()
|
||||
this.#d3Group.selectAll(".data-point-circle").remove()
|
||||
this.#d3Tooltip.style("opacity", 0)
|
||||
|
||||
this.#setTrendlineSplitAt(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#tooltipTemplate(datum) {
|
||||
return(`
|
||||
<div style="margin-bottom: 4px; color: ${tailwindColors.gray[500]};">
|
||||
${d3.timeFormat("%b %d, %Y")(datum.date)}
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 16px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<svg width="10" height="10">
|
||||
<circle
|
||||
cx="5"
|
||||
cy="5"
|
||||
r="4"
|
||||
stroke="${this.#tooltipTrendColor(datum)}"
|
||||
fill="transparent"
|
||||
stroke-width="1"></circle>
|
||||
</svg>
|
||||
|
||||
${this.#tooltipValue(datum)}${this.usePercentSignValue ? "%" : ""}
|
||||
</div>
|
||||
|
||||
${this.usePercentSignValue || datum.trend.value === 0 || datum.trend.value.amount === 0 ? `
|
||||
<span style="width: 80px;"></span>
|
||||
` : `
|
||||
<span style="color: ${this.#tooltipTrendColor(datum)};">
|
||||
${this.#tooltipChange(datum)} (${datum.trend.percent}%)
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
`)
|
||||
}
|
||||
|
||||
#tooltipTrendColor(datum) {
|
||||
return {
|
||||
up: tailwindColors.success,
|
||||
down: tailwindColors.error,
|
||||
flat: tailwindColors.gray[500],
|
||||
}[datum.trend.direction]
|
||||
}
|
||||
|
||||
#tooltipValue(datum) {
|
||||
if (datum.currency) {
|
||||
return this.#currencyValue(datum)
|
||||
} else {
|
||||
return datum.value
|
||||
}
|
||||
}
|
||||
|
||||
#tooltipChange(datum) {
|
||||
if (datum.currency) {
|
||||
return this.#currencyChange(datum)
|
||||
} else {
|
||||
return this.#decimalChange(datum)
|
||||
}
|
||||
}
|
||||
|
||||
#currencyValue(datum) {
|
||||
return Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: datum.currency,
|
||||
}).format(datum.value)
|
||||
}
|
||||
|
||||
#currencyChange(datum) {
|
||||
return Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: datum.currency,
|
||||
signDisplay: "always",
|
||||
}).format(datum.trend.value.amount)
|
||||
}
|
||||
|
||||
#decimalChange(datum) {
|
||||
return Intl.NumberFormat(undefined, {
|
||||
style: "decimal",
|
||||
signDisplay: "always",
|
||||
}).format(datum.trend.value)
|
||||
}
|
||||
|
||||
|
||||
#createMainSvg() {
|
||||
return this.#d3Container
|
||||
.append("svg")
|
||||
.attr("width", this.#d3InitialContainerWidth)
|
||||
.attr("height", this.#d3InitialContainerHeight)
|
||||
.attr("viewBox", [ 0, 0, this.#d3InitialContainerWidth, this.#d3InitialContainerHeight ])
|
||||
}
|
||||
|
||||
#createMainGroup() {
|
||||
return this.#d3Svg
|
||||
.append("g")
|
||||
.attr("transform", `translate(${this.#margin.left},${this.#margin.top})`)
|
||||
}
|
||||
|
||||
|
||||
get #d3Svg() {
|
||||
if (this.#d3SvgMemo) {
|
||||
return this.#d3SvgMemo
|
||||
} else {
|
||||
return this.#d3SvgMemo = this.#createMainSvg()
|
||||
}
|
||||
}
|
||||
|
||||
get #d3Group() {
|
||||
if (this.#d3GroupMemo) {
|
||||
return this.#d3GroupMemo
|
||||
} else {
|
||||
return this.#d3GroupMemo = this.#createMainGroup()
|
||||
}
|
||||
}
|
||||
|
||||
get #margin() {
|
||||
if (this.useLabelsValue) {
|
||||
return { top: 20, right: 0, bottom: 30, left: 0 }
|
||||
} else {
|
||||
return { top: 0, right: 0, bottom: 0, left: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
get #d3ContainerWidth() {
|
||||
return this.#d3InitialContainerWidth - this.#margin.left - this.#margin.right
|
||||
}
|
||||
|
||||
get #d3ContainerHeight() {
|
||||
return this.#d3InitialContainerHeight - this.#margin.top - this.#margin.bottom
|
||||
}
|
||||
|
||||
get #d3Container() {
|
||||
return d3.select(this.element)
|
||||
}
|
||||
|
||||
get #trendColor() {
|
||||
if (this.#trendDirection === "flat") {
|
||||
return tailwindColors.gray[500]
|
||||
} else if (this.#trendDirection === this.#favorableDirection) {
|
||||
return tailwindColors.green[500]
|
||||
} else {
|
||||
return tailwindColors.error
|
||||
}
|
||||
}
|
||||
|
||||
get #trendDirection() {
|
||||
return this.dataValue.trend.direction
|
||||
}
|
||||
|
||||
get #favorableDirection() {
|
||||
return this.dataValue.trend.favorable_direction
|
||||
}
|
||||
|
||||
get #d3Line() {
|
||||
return d3
|
||||
.line()
|
||||
.x(d => this.#d3XScale(d.date))
|
||||
.y(d => this.#d3YScale(d.value))
|
||||
}
|
||||
|
||||
get #d3XScale() {
|
||||
return d3
|
||||
.scaleTime()
|
||||
.rangeRound([ 0, this.#d3ContainerWidth ])
|
||||
.domain(d3.extent(this.#normalDataPoints, d => d.date))
|
||||
}
|
||||
|
||||
get #d3YScale() {
|
||||
const reductionPercent = this.useLabelsValue ? 0.15 : 0.05
|
||||
const dataMin = d3.min(this.#normalDataPoints, d => d.value)
|
||||
const dataMax = d3.max(this.#normalDataPoints, d => d.value)
|
||||
const padding = (dataMax - dataMin) * reductionPercent
|
||||
|
||||
return d3
|
||||
.scaleLinear()
|
||||
.rangeRound([ this.#d3ContainerHeight, 0 ])
|
||||
.domain([ dataMin - padding, dataMax + padding ])
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import tailwindColors from "@maybe/tailwindcolors";
|
||||
import * as d3 from "d3";
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { series: Object };
|
||||
|
||||
connect() {
|
||||
this.renderChart(this.seriesValue);
|
||||
document.addEventListener("turbo:load", this.renderChart);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener("turbo:load", this.renderChart);
|
||||
}
|
||||
|
||||
renderChart = () => {
|
||||
const data = this.prepareData(this.seriesValue);
|
||||
this.drawChart(data);
|
||||
};
|
||||
|
||||
prepareData(series) {
|
||||
return series.values.map((d) => ({
|
||||
date: new Date(d.date + "T00:00:00"),
|
||||
value: +d.value.amount,
|
||||
}));
|
||||
}
|
||||
|
||||
drawChart(data) {
|
||||
const chartContainer = d3.select(this.element);
|
||||
chartContainer.selectAll("*").remove();
|
||||
const initialDimensions = {
|
||||
width: chartContainer.node().clientWidth,
|
||||
height: chartContainer.node().clientHeight,
|
||||
};
|
||||
|
||||
const svg = chartContainer
|
||||
.append("svg")
|
||||
.attr("width", initialDimensions.width)
|
||||
.attr("height", initialDimensions.height)
|
||||
.attr("viewBox", [
|
||||
0,
|
||||
0,
|
||||
initialDimensions.width,
|
||||
initialDimensions.height,
|
||||
])
|
||||
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
|
||||
|
||||
const margin = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
const width = initialDimensions.width - margin.left - margin.right;
|
||||
const height = initialDimensions.height - margin.top - margin.bottom;
|
||||
|
||||
const isLiability = this.classificationValue === "liability";
|
||||
const trendDirection = data[data.length - 1].value - data[0].value;
|
||||
let lineColor;
|
||||
|
||||
if (trendDirection > 0) {
|
||||
lineColor = isLiability
|
||||
? tailwindColors.error
|
||||
: tailwindColors.green[500];
|
||||
} else if (trendDirection < 0) {
|
||||
lineColor = isLiability
|
||||
? tailwindColors.green[500]
|
||||
: tailwindColors.error;
|
||||
} else {
|
||||
lineColor = tailwindColors.gray[500];
|
||||
}
|
||||
|
||||
const xScale = d3
|
||||
.scaleTime()
|
||||
.rangeRound([0, width])
|
||||
.domain(d3.extent(data, (d) => d.date));
|
||||
|
||||
const PADDING = 0.05;
|
||||
const dataMin = d3.min(data, (d) => d.value);
|
||||
const dataMax = d3.max(data, (d) => d.value);
|
||||
const padding = (dataMax - dataMin) * PADDING;
|
||||
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.rangeRound([height, 0])
|
||||
.domain([dataMin - padding, dataMax + padding]);
|
||||
|
||||
const line = d3
|
||||
.line()
|
||||
.x((d) => xScale(d.date))
|
||||
.y((d) => yScale(d.value));
|
||||
|
||||
svg
|
||||
.append("path")
|
||||
.datum(data)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", lineColor)
|
||||
.attr("stroke-width", 2)
|
||||
.attr("d", line);
|
||||
}
|
||||
}
|
||||
5
app/javascript/services/currencies_service.js
Normal file
5
app/javascript/services/currencies_service.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export class CurrenciesService {
|
||||
get(id) {
|
||||
return fetch(`/currencies/${id}.json`).then((response) => response.json());
|
||||
}
|
||||
}
|
||||
@@ -38,17 +38,17 @@ export default {
|
||||
900: "rgba(255, 255, 255, 0.7)",
|
||||
},
|
||||
"alpha-black": {
|
||||
25: "rgba(20, 20, 20, 0.03)",
|
||||
50: "rgba(20, 20, 20, 0.05)",
|
||||
100: "rgba(20, 20, 20, 0.08)",
|
||||
200: "rgba(20, 20, 20, 0.1)",
|
||||
300: "rgba(20, 20, 20, 0.15)",
|
||||
400: "rgba(20, 20, 20, 0.2)",
|
||||
500: "rgba(20, 20, 20, 0.3)",
|
||||
600: "rgba(20, 20, 20, 0.4)",
|
||||
700: "rgba(20, 20, 20, 0.5)",
|
||||
800: "rgba(20, 20, 20, 0.6)",
|
||||
900: "rgba(20, 20, 20, 0.7)",
|
||||
25: "rgba(11, 11, 11, 0.03)",
|
||||
50: "rgba(11, 11, 11, 0.05)",
|
||||
100: "rgba(11, 11, 11, 0.08)",
|
||||
200: "rgba(11, 11, 11, 0.1)",
|
||||
300: "rgba(11, 11, 11, 0.15)",
|
||||
400: "rgba(11, 11, 11, 0.2)",
|
||||
500: "rgba(11, 11, 11, 0.3)",
|
||||
600: "rgba(11, 11, 11, 0.4)",
|
||||
700: "rgba(11, 11, 11, 0.5)",
|
||||
800: "rgba(11, 11, 11, 0.6)",
|
||||
900: "rgba(11, 11, 11, 0.7)",
|
||||
},
|
||||
red: {
|
||||
25: "#FFFBFB",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class AccountSyncJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(account)
|
||||
account.sync
|
||||
def perform(account, start_date: nil)
|
||||
account.sync(start_date: start_date)
|
||||
end
|
||||
end
|
||||
|
||||
7
app/jobs/import_job.rb
Normal file
7
app/jobs/import_job.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class ImportJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(import)
|
||||
import.publish
|
||||
end
|
||||
end
|
||||
7
app/jobs/user_purge_job.rb
Normal file
7
app/jobs/user_purge_job.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class UserPurgeJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(user)
|
||||
user.purge
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,16 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "from@example.com"
|
||||
layout "mailer"
|
||||
|
||||
after_action :set_self_host_settings, if: -> { Rails.configuration.app_mode.self_hosted? }
|
||||
|
||||
private
|
||||
|
||||
def set_self_host_settings
|
||||
mail.from = Setting.email_sender
|
||||
mail.delivery_method.settings.merge!({ address: Setting.smtp_host,
|
||||
port: Setting.smtp_port,
|
||||
user_name: Setting.smtp_username,
|
||||
password: Setting.smtp_password,
|
||||
tls: ENV.fetch("SMTP_TLS_ENABLED", "true") == "true" })
|
||||
end
|
||||
end
|
||||
|
||||
5
app/mailers/notification_mailer.rb
Normal file
5
app/mailers/notification_mailer.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class NotificationMailer < ApplicationMailer
|
||||
def test_email
|
||||
mail(to: params[:user].email, subject: t(".test_email_subject"), body: t(".test_email_body"))
|
||||
end
|
||||
end
|
||||
@@ -2,87 +2,117 @@ class Account < ApplicationRecord
|
||||
include Syncable
|
||||
include Monetizable
|
||||
|
||||
validates :family, presence: true
|
||||
validates :name, :balance, :currency, presence: true
|
||||
|
||||
broadcasts_refreshes
|
||||
belongs_to :family
|
||||
belongs_to :institution, optional: true
|
||||
|
||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
|
||||
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
|
||||
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
|
||||
has_many :holdings, dependent: :destroy
|
||||
has_many :balances, dependent: :destroy
|
||||
has_many :valuations, dependent: :destroy
|
||||
has_many :transactions, dependent: :destroy
|
||||
has_many :imports, dependent: :destroy
|
||||
has_many :syncs, dependent: :destroy
|
||||
|
||||
monetize :balance
|
||||
|
||||
enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true
|
||||
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
|
||||
|
||||
scope :active, -> { where(is_active: true) }
|
||||
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]
|
||||
class << self
|
||||
def by_group(period: Period.all, currency: Money.default_currency)
|
||||
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
|
||||
|
||||
Accountable.by_classification.each do |classification, types|
|
||||
types.each do |type|
|
||||
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
|
||||
self.where(accountable_type: type).each do |account|
|
||||
group.add_value_node(
|
||||
account,
|
||||
account.balance_money.exchange_to(currency, fallback_rate: 0),
|
||||
account.series(period: period, currency: currency)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped_accounts
|
||||
end
|
||||
|
||||
def 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.entries.build \
|
||||
date: Date.current,
|
||||
amount: attributes[:balance],
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
|
||||
# Conditionally build the optional start valuation
|
||||
if start_date.present? && start_balance.present?
|
||||
account.entries.build \
|
||||
date: start_date,
|
||||
amount: start_balance,
|
||||
currency: account.currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
|
||||
account.save!
|
||||
account
|
||||
end
|
||||
end
|
||||
|
||||
def balance_on(date)
|
||||
balances.where("date <= ?", date).order(date: :desc).first&.balance
|
||||
def alert
|
||||
latest_sync = syncs.latest
|
||||
[ latest_sync&.error, *latest_sync&.warnings ].compact.first
|
||||
end
|
||||
|
||||
# e.g. Wise, Revolut accounts that have transactions in multiple currencies
|
||||
def multi_currency?
|
||||
currencies = [ valuations.pluck(:currency), transactions.pluck(:currency) ].flatten.uniq
|
||||
currencies.count > 1
|
||||
def favorable_direction
|
||||
classification == "asset" ? "up" : "down"
|
||||
end
|
||||
|
||||
# e.g. Accounts denominated in currency other than family currency
|
||||
def foreign_currency?
|
||||
currency != family.currency
|
||||
end
|
||||
|
||||
def self.by_provider
|
||||
# TODO: When 3rd party providers are supported, dynamically load all providers and their accounts
|
||||
[ { name: "Manual accounts", accounts: all.order(balance: :desc).group_by(&:accountable_type) } ]
|
||||
end
|
||||
|
||||
def self.some_syncing?
|
||||
exists?(status: "syncing")
|
||||
end
|
||||
|
||||
|
||||
def series(period: Period.all, currency: self.currency)
|
||||
balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code)
|
||||
|
||||
if balance_series.empty? && period.date_range.end == Date.current
|
||||
converted_balance = balance_money.exchange_to(currency)
|
||||
if converted_balance
|
||||
TimeSeries.new([ { date: Date.current, value: converted_balance } ])
|
||||
else
|
||||
TimeSeries.new([])
|
||||
end
|
||||
TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
|
||||
def self.by_group(period: Period.all, currency: Money.default_currency)
|
||||
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
|
||||
def update_balance!(balance)
|
||||
valuation = entries.account_valuations.find_by(date: Date.current)
|
||||
|
||||
Accountable.by_classification.each do |classification, types|
|
||||
types.each do |type|
|
||||
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
|
||||
Accountable.from_type(type).includes(:account).each do |accountable|
|
||||
account = accountable.account
|
||||
next unless account
|
||||
|
||||
value_node = group.add_value_node(
|
||||
account,
|
||||
account.balance_money.exchange_to(currency) || Money.new(0, currency),
|
||||
account.series(period: period, currency: currency)
|
||||
)
|
||||
end
|
||||
end
|
||||
if valuation
|
||||
valuation.update! amount: balance
|
||||
else
|
||||
entries.create! \
|
||||
date: Date.current,
|
||||
amount: balance,
|
||||
currency: currency,
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
end
|
||||
|
||||
grouped_accounts
|
||||
def holding_qty(security, date: Date.current)
|
||||
entries.account_trades
|
||||
.joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id")
|
||||
.where(account_trades: { security_id: security.id })
|
||||
.where("account_entries.date <= ?", date)
|
||||
.sum("account_trades.qty")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,4 +5,5 @@ class Account::Balance < ApplicationRecord
|
||||
validates :account, :date, :balance, presence: true
|
||||
monetize :balance
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
scope :chronological, -> { order(:date) }
|
||||
end
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
class Account::Balance::Calculator
|
||||
attr_reader :daily_balances, :errors, :warnings
|
||||
|
||||
@daily_balances = []
|
||||
@errors = []
|
||||
@warnings = []
|
||||
|
||||
def initialize(account, options = {})
|
||||
@account = account
|
||||
@calc_start_date = [ options[:calc_start_date], @account.effective_start_date ].compact.max
|
||||
end
|
||||
|
||||
def calculate
|
||||
prior_balance = implied_start_balance
|
||||
|
||||
calculated_balances = ((@calc_start_date + 1.day)...Date.current).map do |date|
|
||||
valuation = normalized_valuations.find { |v| v["date"] == date }
|
||||
|
||||
if valuation
|
||||
current_balance = valuation["value"]
|
||||
else
|
||||
txn_flows = transaction_flows(date)
|
||||
current_balance = prior_balance - txn_flows
|
||||
end
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
{ date: date, balance: current_balance, currency: @account.currency, updated_at: Time.current }
|
||||
end
|
||||
|
||||
@daily_balances = [
|
||||
{ date: @calc_start_date, balance: implied_start_balance, currency: @account.currency, updated_at: Time.current },
|
||||
*calculated_balances,
|
||||
{ date: Date.current, balance: @account.balance, currency: @account.currency, updated_at: Time.current } # Last balance must always match "source of truth"
|
||||
]
|
||||
|
||||
if @account.foreign_currency?
|
||||
converted_balances = convert_balances_to_family_currency
|
||||
@daily_balances.concat(converted_balances)
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
private
|
||||
def convert_balances_to_family_currency
|
||||
rates = ExchangeRate.get_rate_series(
|
||||
@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
|
||||
{ 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)
|
||||
|
||||
if currency != @account.currency
|
||||
rate = ExchangeRate.find_by(base_currency: currency, converted_currency: @account.currency, date: date)
|
||||
raise "Rate for #{currency} to #{@account.currency} not found" unless rate
|
||||
|
||||
value *= rate.rate
|
||||
currency = @account.currency
|
||||
end
|
||||
|
||||
entry.attributes.merge(value_key.to_s => value, "currency" => currency)
|
||||
end
|
||||
end
|
||||
|
||||
def normalized_valuations
|
||||
@normalized_valuations ||= normalize_entries_to_account_currency(@account.valuations.where("date >= ?", @calc_start_date).order(:date).select(:date, :value, :currency), :value)
|
||||
end
|
||||
|
||||
def normalized_transactions
|
||||
@normalized_transactions ||= normalize_entries_to_account_currency(@account.transactions.where("date >= ?", @calc_start_date).order(:date).select(:date, :amount, :currency), :amount)
|
||||
end
|
||||
|
||||
def transaction_flows(date)
|
||||
flows = normalized_transactions.select { |t| t["date"] == date }.sum { |t| t["amount"] }
|
||||
flows *= -1 if @account.classification == "liability"
|
||||
flows
|
||||
end
|
||||
|
||||
def implied_start_balance
|
||||
oldest_valuation_date = normalized_valuations.first&.dig("date")
|
||||
oldest_transaction_date = normalized_transactions.first&.dig("date")
|
||||
oldest_entry_date = [ oldest_valuation_date, oldest_transaction_date ].compact.min
|
||||
|
||||
if oldest_entry_date == oldest_valuation_date
|
||||
oldest_valuation = normalized_valuations.find { |v| v["date"] == oldest_valuation_date }
|
||||
oldest_valuation["value"].to_d
|
||||
else
|
||||
net_transaction_flows = normalized_transactions.sum { |t| t["amount"].to_d }
|
||||
net_transaction_flows *= -1 if @account.classification == "liability"
|
||||
@account.balance.to_d + net_transaction_flows
|
||||
end
|
||||
end
|
||||
end
|
||||
127
app/models/account/balance/syncer.rb
Normal file
127
app/models/account/balance/syncer.rb
Normal file
@@ -0,0 +1,127 @@
|
||||
class Account::Balance::Syncer
|
||||
attr_reader :warnings
|
||||
|
||||
def initialize(account, start_date: nil)
|
||||
@account = account
|
||||
@warnings = []
|
||||
@sync_start_date = calculate_sync_start_date(start_date)
|
||||
end
|
||||
|
||||
def run
|
||||
daily_balances = calculate_daily_balances
|
||||
daily_balances += calculate_converted_balances(daily_balances) if account.currency != account.family.currency
|
||||
|
||||
Account::Balance.transaction do
|
||||
upsert_balances!(daily_balances)
|
||||
purge_stale_balances!
|
||||
|
||||
if daily_balances.any?
|
||||
account.reload
|
||||
account.update! balance: daily_balances.select { |db| db.currency == account.currency }.last&.balance
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :sync_start_date, :account
|
||||
|
||||
def upsert_balances!(balances)
|
||||
current_time = Time.now
|
||||
balances_to_upsert = balances.map do |balance|
|
||||
balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time)
|
||||
end
|
||||
|
||||
account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency])
|
||||
end
|
||||
|
||||
def purge_stale_balances!
|
||||
account.balances.delete_by("date < ?", account_start_date)
|
||||
end
|
||||
|
||||
def calculate_balance_for_date(date, entries:, prior_balance:)
|
||||
valuation = entries.find { |e| e.date == date && e.account_valuation? }
|
||||
|
||||
return valuation.amount if valuation
|
||||
return derived_sync_start_balance(entries) unless prior_balance
|
||||
|
||||
entries = entries.select { |e| e.date == date }
|
||||
|
||||
prior_balance - net_entry_flows(entries)
|
||||
end
|
||||
|
||||
def calculate_daily_balances
|
||||
entries = account.entries.where("date >= ?", sync_start_date).to_a
|
||||
prior_balance = find_prior_balance
|
||||
|
||||
(sync_start_date..Date.current).map do |date|
|
||||
current_balance = calculate_balance_for_date(date, entries:, prior_balance:)
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
build_balance(date, current_balance)
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_converted_balances(balances)
|
||||
from_currency = account.currency
|
||||
to_currency = account.family.currency
|
||||
|
||||
exchange_rates = ExchangeRate.find_rates from: from_currency,
|
||||
to: to_currency,
|
||||
start_date: sync_start_date
|
||||
|
||||
balances.map do |balance|
|
||||
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
|
||||
|
||||
raise Money::ConversionError.new("missing exchange rate from #{from_currency} to #{to_currency} on date #{balance.date}") unless exchange_rate
|
||||
|
||||
build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
@warnings << "missing exchange rates from #{from_currency} to #{to_currency}"
|
||||
[]
|
||||
end
|
||||
|
||||
def build_balance(date, balance, currency = nil)
|
||||
account.balances.build \
|
||||
date: date,
|
||||
balance: balance,
|
||||
currency: currency || account.currency
|
||||
end
|
||||
|
||||
def derived_sync_start_balance(entries)
|
||||
transactions_and_trades = entries.reject { |e| e.account_valuation? }.select { |e| e.date > sync_start_date }
|
||||
|
||||
account.balance + net_entry_flows(transactions_and_trades)
|
||||
end
|
||||
|
||||
def find_prior_balance
|
||||
account.balances.where("date < ?", sync_start_date).order(date: :desc).first&.balance
|
||||
end
|
||||
|
||||
def net_entry_flows(entries, target_currency = account.currency)
|
||||
converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
|
||||
|
||||
flows = converted_entry_amounts.sum(&:amount)
|
||||
|
||||
account.liability? ? flows * -1 : flows
|
||||
end
|
||||
|
||||
def account_start_date
|
||||
@account_start_date ||= begin
|
||||
oldest_entry_date = account.entries.chronological.first.try(:date)
|
||||
|
||||
return Date.current unless oldest_entry_date
|
||||
|
||||
oldest_entry_is_valuation = account.entries.account_valuations.where(date: oldest_entry_date).exists?
|
||||
|
||||
oldest_entry_date -= 1 unless oldest_entry_is_valuation
|
||||
oldest_entry_date
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_sync_start_date(provided_start_date)
|
||||
[ provided_start_date, account_start_date ].compact.max
|
||||
end
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::Credit < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::Crypto < ApplicationRecord
|
||||
include Accountable
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user