├── log └── .keep ├── script └── .keep ├── storage └── .keep ├── tmp ├── .keep ├── pids │ └── .keep └── storage │ └── .keep ├── vendor └── .keep ├── lib └── tasks │ └── .keep ├── app ├── assets │ ├── builds │ │ └── .keep │ └── fonts │ │ └── sn_pro │ │ ├── SNPro-VariableItalic.woff2 │ │ └── SNPro-VariableRegular.woff2 ├── controllers │ ├── concerns │ │ └── .keep │ ├── record_types_controller.rb │ ├── insights_controller.rb │ ├── application_controller.rb │ ├── ordering_controller.rb │ ├── accounts_controller.rb │ ├── categories_controller.rb │ └── templates_controller.rb ├── views │ ├── layouts │ │ ├── mailer.text.erb │ │ └── mailer.html.erb │ ├── sessions │ │ └── new.html.erb │ ├── base.rb │ ├── pwa │ │ ├── manifest.json.erb │ │ └── service-worker.js │ ├── accounts │ │ ├── new.rb │ │ ├── index.rb │ │ ├── records │ │ │ └── new.rb │ │ └── edit.rb │ ├── categories │ │ ├── new.rb │ │ ├── index.rb │ │ └── edit.rb │ ├── templates │ │ ├── new.rb │ │ ├── index.rb │ │ └── edit.rb │ └── transfers │ │ ├── new.rb │ │ └── edit.rb ├── lib │ ├── insights_data.rb │ ├── cashflow.rb │ ├── spending_breakdown.rb │ ├── constants.rb │ └── period.rb ├── models │ ├── user.rb │ ├── application_record.rb │ ├── concerns │ │ └── sortable.rb │ ├── category.rb │ ├── template.rb │ ├── account.rb │ ├── record.rb │ └── transfer.rb ├── javascript │ ├── application.js │ └── controllers │ │ ├── auto_submit_controller.js │ │ ├── application.js │ │ ├── index.js │ │ ├── sortable_controller.js │ │ ├── charts │ │ └── spending_breakdown_controller.js │ │ └── money_field_controller.js ├── mailers │ └── application_mailer.rb ├── components │ ├── bolt │ │ ├── page │ │ │ ├── page_subtitle.rb │ │ │ ├── page_body.rb │ │ │ ├── page_heading.rb │ │ │ ├── page.rb │ │ │ ├── page_actions.rb │ │ │ ├── page_header.rb │ │ │ └── page_title.rb │ │ ├── list │ │ │ ├── list.rb │ │ │ ├── list_legend.rb │ │ │ └── list_item.rb │ │ ├── result │ │ │ ├── result_description.rb │ │ │ ├── result_title.rb │ │ │ ├── result_actions.rb │ │ │ ├── result.rb │ │ │ └── result_image.rb │ │ ├── drawer │ │ │ ├── drawer_content.rb │ │ │ ├── drawer.rb │ │ │ ├── drawer_toggle.rb │ │ │ └── drawer_side.rb │ │ ├── panel │ │ │ └── panel.rb │ │ ├── form │ │ │ ├── submit.rb │ │ │ ├── email_field.rb │ │ │ ├── toggle.rb │ │ │ ├── date_field.rb │ │ │ ├── form.rb │ │ │ ├── password_field.rb │ │ │ ├── radio_button.rb │ │ │ ├── field_wrapper.rb │ │ │ ├── field_error.rb │ │ │ ├── text_field.rb │ │ │ ├── label.rb │ │ │ ├── collection_select.rb │ │ │ ├── form_builder.rb │ │ │ ├── money_field.rb │ │ │ ├── error_summary.rb │ │ │ └── color_picker.rb │ │ ├── toast │ │ │ └── toast.rb │ │ ├── menu │ │ │ ├── menu.rb │ │ │ └── menu_item.rb │ │ ├── container │ │ │ └── stack.rb │ │ ├── base.rb │ │ ├── dot │ │ │ └── dot.rb │ │ ├── alert │ │ │ └── alert.rb │ │ ├── link_button │ │ │ └── link_button.rb │ │ ├── currency_display │ │ │ └── currency_display.rb │ │ └── pagination │ │ │ └── pagination.rb │ ├── layouts │ │ ├── sidenav.rb │ │ ├── zen.rb │ │ ├── flash.rb │ │ ├── navbar.rb │ │ ├── main.rb │ │ ├── menu_items.rb │ │ └── root.rb │ ├── accounts │ │ ├── list.rb │ │ ├── empty_state.rb │ │ └── list_item.rb │ ├── templates │ │ ├── list.rb │ │ └── empty_state.rb │ ├── categories │ │ ├── list.rb │ │ ├── empty_state.rb │ │ ├── list_item.rb │ │ └── form.rb │ ├── base.rb │ ├── records │ │ ├── empty_state.rb │ │ └── list.rb │ └── insights │ │ ├── spending_breakdown_card.rb │ │ └── cashflow_card.rb ├── jobs │ └── application_job.rb └── operations │ ├── records │ ├── destroy.rb │ ├── create.rb │ ├── update.rb │ └── build_from_params.rb │ ├── accounts │ ├── destroy.rb │ ├── update.rb │ └── create.rb │ ├── application_operation.rb │ ├── categories │ ├── destroy.rb │ ├── update.rb │ └── create.rb │ ├── templates │ ├── destroy.rb │ ├── update.rb │ └── create.rb │ ├── transfers │ ├── destroy.rb │ ├── create.rb │ └── update.rb │ ├── insights │ ├── calculate_cashflow.rb │ ├── build_data.rb │ └── calculate_spending_breakdown.rb │ └── sortables │ └── reorder.rb ├── .tool-versions ├── public ├── robots.txt ├── icon.png └── icon.svg ├── test ├── support │ ├── minitest.rb │ ├── shoulda_matchers.rb │ ├── translation_helpers.rb │ ├── clearance.rb │ └── component_test_case.rb ├── factories │ ├── users_factories.rb │ ├── categories_factories.rb │ ├── accounts_factories.rb │ ├── templates_factories.rb │ ├── records_factories.rb │ └── transfers_factories.rb ├── system │ ├── sessions │ │ ├── destroy_test.rb │ │ └── create_test.rb │ ├── templates │ │ ├── destroy_test.rb │ │ ├── list_test.rb │ │ ├── reorder_test.rb │ │ └── update_test.rb │ ├── categories │ │ ├── destroy_test.rb │ │ ├── list_test.rb │ │ ├── reorder_test.rb │ │ ├── create_test.rb │ │ └── update_test.rb │ ├── records │ │ ├── destroy_test.rb │ │ └── update_test.rb │ ├── accounts │ │ ├── destroy_test.rb │ │ ├── list_test.rb │ │ ├── reorder_test.rb │ │ ├── create_test.rb │ │ └── update_test.rb │ ├── transfers │ │ └── destroy_test.rb │ └── insights │ │ └── list_test.rb ├── lib │ ├── cashflow_test.rb │ └── period_test.rb ├── operations │ ├── records │ │ ├── destroy_test.rb │ │ ├── create_test.rb │ │ └── update_test.rb │ ├── accounts │ │ ├── destroy_test.rb │ │ ├── create_test.rb │ │ └── update_test.rb │ ├── templates │ │ ├── destroy_test.rb │ │ ├── update_test.rb │ │ └── create_test.rb │ ├── transfers │ │ ├── destroy_test.rb │ │ ├── update_test.rb │ │ └── create_test.rb │ ├── categories │ │ ├── destroy_test.rb │ │ ├── create_test.rb │ │ └── update_test.rb │ ├── sortables │ │ └── reorder_test.rb │ └── insights │ │ ├── calculate_cashflow_test.rb │ │ └── calculate_spending_breakdown_test.rb ├── test_helper.rb ├── application_system_test_case.rb ├── controllers │ └── accounts │ │ └── records_controller_test.rb ├── components │ ├── insights │ │ ├── spending_breakdown_card_test.rb │ │ └── cashflow_card_test.rb │ ├── accounts │ │ └── list_item_test.rb │ ├── templates │ │ └── list_item_test.rb │ └── records │ │ └── list_item_test.rb └── models │ ├── category_test.rb │ └── template_test.rb ├── .github ├── screenshot.png └── dependabot.yml ├── .kamal ├── hooks │ ├── docker-setup.sample │ ├── post-proxy-reboot.sample │ ├── pre-proxy-reboot.sample │ ├── post-app-boot.sample │ ├── pre-app-boot.sample │ ├── post-deploy.sample │ ├── pre-connect.sample │ └── pre-build.sample └── secrets ├── bin ├── rake ├── thrust ├── jobs ├── rails ├── brakeman ├── dev ├── docker-entrypoint ├── kamal └── setup ├── config ├── initializers │ ├── generators.rb │ ├── i18n.rb │ ├── action_view.rb │ ├── money.rb │ ├── phlex.rb │ ├── assets.rb │ ├── bolt.rb │ ├── filter_parameter_logging.rb │ ├── inflections.rb │ ├── content_security_policy.rb │ └── clearance.rb ├── environment.rb ├── boot.rb ├── locales │ ├── en │ │ ├── clearance.yml │ │ └── models.yml │ └── pt-BR │ │ ├── clearance.yml │ │ └── models.yml ├── recurring.yml ├── cache.yml ├── queue.yml ├── credentials.yml.enc ├── cable.yml ├── routes.rb ├── application.rb ├── database.yml └── storage.yml ├── Procfile.dev ├── .devcontainer ├── Dockerfile ├── compose.yaml └── devcontainer.json ├── config.ru ├── .zed ├── tasks.json └── settings.json ├── Rakefile ├── db ├── migrate │ ├── 030_create_categories.rb │ ├── 020_create_accounts.rb │ ├── 100_create_indexes.rb │ ├── 010_create_users.rb │ ├── 060_create_records.rb │ ├── 050_create_transfers.rb │ └── 040_create_templates.rb ├── cable_schema.rb └── cache_schema.rb ├── .standard.yml ├── .gitattributes ├── package.json ├── .gitignore ├── .dockerignore └── LICENSE.txt /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /script/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /storage/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/pids/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/storage/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/builds/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.4.5 2 | nodejs 24.5.0 3 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /test/support/minitest.rb: -------------------------------------------------------------------------------- 1 | require "minitest/mock" 2 | -------------------------------------------------------------------------------- /app/views/sessions/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= render Views::Sessions::New %> 2 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gunbolt/pikoin/HEAD/public/icon.png -------------------------------------------------------------------------------- /app/lib/insights_data.rb: -------------------------------------------------------------------------------- 1 | InsightsData = Data.define(:cashflow, :spending_breakdown) 2 | -------------------------------------------------------------------------------- /app/views/base.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | class Base < Components::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | include Clearance::User 3 | end 4 | -------------------------------------------------------------------------------- /.github/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gunbolt/pikoin/HEAD/.github/screenshot.png -------------------------------------------------------------------------------- /.kamal/hooks/docker-setup.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Docker set up on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /.kamal/hooks/post-proxy-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooted kamal-proxy on $KAMAL_HOSTS" 4 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-proxy-reboot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /app/lib/cashflow.rb: -------------------------------------------------------------------------------- 1 | Cashflow = Data.define(:expense, :income) do 2 | def balance = income + expense 3 | end 4 | -------------------------------------------------------------------------------- /.kamal/hooks/post-app-boot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-app-boot.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." 4 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /config/initializers/generators.rb: -------------------------------------------------------------------------------- 1 | Rails.application.config.generators do |g| 2 | g.factory_bot suffix: "factories" 3 | end 4 | -------------------------------------------------------------------------------- /config/initializers/i18n.rb: -------------------------------------------------------------------------------- 1 | I18n.available_locales = %i[en pt-BR] 2 | I18n.default_locale = Rails.configuration.language 3 | -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: env RUBY_DEBUG_OPEN=true bin/rails server -b 0.0.0.0 2 | js: yarn build --watch 3 | css: yarn build:css --watch 4 | -------------------------------------------------------------------------------- /bin/thrust: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | load Gem.bin_path("thruster", "thrust") 6 | -------------------------------------------------------------------------------- /app/lib/spending_breakdown.rb: -------------------------------------------------------------------------------- 1 | SpendingBreakdown = Data.define(:items) 2 | SpendingBreakdown::Item = Data.define(:label, :color, :total) 3 | -------------------------------------------------------------------------------- /app/assets/fonts/sn_pro/SNPro-VariableItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gunbolt/pikoin/HEAD/app/assets/fonts/sn_pro/SNPro-VariableItalic.woff2 -------------------------------------------------------------------------------- /app/assets/fonts/sn_pro/SNPro-VariableRegular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gunbolt/pikoin/HEAD/app/assets/fonts/sn_pro/SNPro-VariableRegular.woff2 -------------------------------------------------------------------------------- /app/javascript/application.js: -------------------------------------------------------------------------------- 1 | // Entry point for the build script in your package.json 2 | import '@hotwired/turbo-rails' 3 | import './controllers' 4 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /bin/jobs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../config/environment" 4 | require "solid_queue/cli" 5 | 6 | SolidQueue::Cli.start(ARGV) 7 | -------------------------------------------------------------------------------- /config/initializers/action_view.rb: -------------------------------------------------------------------------------- 1 | Rails.application.config.action_view.field_error_proc = proc do |html_tag, _| 2 | html_tag.html_safe 3 | end 4 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /app/components/bolt/page/page_subtitle.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class PageSubtitle < Base 3 | def view_template(&) 4 | h3(&) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /app/lib/constants.rb: -------------------------------------------------------------------------------- 1 | module Constants 2 | COLOR_REGEX = /\A#[a-fA-F0-9]{6}\z/ 3 | DEV_USER_EMAIL = "example@example.com" 4 | DEV_USER_PASSWORD = "Example.123" 5 | end 6 | -------------------------------------------------------------------------------- /test/factories/users_factories.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :user do 3 | email { "example@example.com" } 4 | password { "Password.123" } 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /bin/brakeman: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | ARGV.unshift("--ensure-latest") 6 | 7 | load Gem.bin_path("brakeman", "brakeman") 8 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # Make sure RUBY_VERSION matches the Ruby version in .ruby-version 2 | ARG RUBY_VERSION=3.4.4 3 | FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION 4 | -------------------------------------------------------------------------------- /app/components/bolt/page/page_body.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class PageBody < Base 3 | def view_template(&) 4 | div class: "flex flex-col px-4 pb-4", & 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/models/concerns/sortable.rb: -------------------------------------------------------------------------------- 1 | module Sortable 2 | extend ActiveSupport::Concern 3 | 4 | class_methods do 5 | def next_position = maximum(:position).to_i + 1 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /test/support/shoulda_matchers.rb: -------------------------------------------------------------------------------- 1 | Shoulda::Matchers.configure do |config| 2 | config.integrate do |with| 3 | with.test_framework :minitest 4 | with.library :rails 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/components/bolt/page/page_heading.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class PageHeading < Base 3 | def view_template(&) 4 | div class: "grow flex flex-col gap-1", & 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.zed/tasks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "label": "test $ZED_RELATIVE_FILE:$ZED_ROW", 4 | "command": "rails test", 5 | "args": ["\"$ZED_RELATIVE_FILE:$ZED_ROW\""], 6 | "tags": ["ruby-test"] 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /app/components/bolt/page/page.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Page < Base 3 | def view_template(&) 4 | div class: "flex flex-col gap-2 lg:gap-4 w-full max-w-3xl m-auto", & 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/factories/categories_factories.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :category do 3 | sequence(:title) { |n| "Category #{n}" } 4 | color { "#012345" } 5 | sequence(:position) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/components/bolt/list/list.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class List < Base 3 | private 4 | 5 | def view_template(&) 6 | ul class: "flex flex-col gap-2", **@attributes, & 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/bolt/page/page_actions.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class PageActions < Base 3 | def view_template(&) 4 | div class: "w-full lg:w-fit flex flex-row justify-end gap-1", & 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/components/bolt/result/result_description.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class ResultDescription < Base 3 | private 4 | 5 | def view_template(&) 6 | p class: "text-center", & 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/bolt/result/result_title.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class ResultTitle < Base 3 | private 4 | 5 | def view_template(&) 6 | div class: "text-xl font-medium text-center", & 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | require "bootsnap/setup" # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /config/locales/en/clearance.yml: -------------------------------------------------------------------------------- 1 | en: 2 | flashes: 3 | failure_after_create: "Bad email or password." 4 | failure_when_not_signed_in: "Please sign in to continue." 5 | failure_when_missing_email: "Email can't be blank." 6 | -------------------------------------------------------------------------------- /app/components/bolt/list/list_legend.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class ListLegend < Base 3 | private 4 | 5 | def view_template(&) 6 | li class: "p-4 pb-2 text-xs opacity-60 tracking-wide", & 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/bolt/result/result_actions.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class ResultActions < Base 3 | private 4 | 5 | def view_template(&) 6 | div class: "flex pt-4 justify-center gap-4", & 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/controllers/record_types_controller.rb: -------------------------------------------------------------------------------- 1 | class RecordTypesController < ApplicationController 2 | def index 3 | templates = Template.order(:position) 4 | 5 | render Views::RecordTypes::Index.new(templates:) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /config/initializers/money.rb: -------------------------------------------------------------------------------- 1 | Money.locale_backend = :currency 2 | Money.rounding_mode = BigDecimal::ROUND_HALF_UP 3 | Money.default_currency = Rails.configuration.default_currency 4 | Money.default_formatting_rules = {sign_before_symbol: true} 5 | -------------------------------------------------------------------------------- /test/factories/accounts_factories.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :account do 3 | sequence(:title) { |n| "Account #{n}" } 4 | color { "#012345" } 5 | initial_amount_cents { 0 } 6 | sequence(:position) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/components/bolt/drawer/drawer_content.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class DrawerContent < Base 3 | private 4 | 5 | def view_template(&) 6 | div class: "drawer-content bg-base-200 min-h-screen", & 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/components/bolt/result/result.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Result < Base 3 | private 4 | 5 | def view_template(&) 6 | div class: "flex flex-col gap-2 bg-base-100 p-8 shadow rounded-box", & 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/factories/templates_factories.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :template do 3 | account 4 | category 5 | 6 | sequence(:title) { |n| "Template #{n}" } 7 | group { :expense } 8 | sequence(:position) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/javascript/controllers/auto_submit_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | 3 | // Connects to data-controller="auto-submit" 4 | export default class extends Controller { 5 | submit () { 6 | this.element.requestSubmit() 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /config/locales/pt-BR/clearance.yml: -------------------------------------------------------------------------------- 1 | pt-BR: 2 | flashes: 3 | failure_after_create: "E-mail ou senha errados" 4 | failure_when_not_signed_in: "É necessário entrar na sua conta para continuar" 5 | failure_when_missing_email: "E-mail não pode ficar em branco" 6 | -------------------------------------------------------------------------------- /app/components/bolt/panel/panel.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Panel < Base 3 | private 4 | 5 | def view_template(&) 6 | div class: [ 7 | "flex flex-col gap-2 bg-base-100 shadow rounded-box p-4", 8 | @extra_classes 9 | ], & 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/javascript/controllers/application.js: -------------------------------------------------------------------------------- 1 | import { Application } from '@hotwired/stimulus' 2 | 3 | const application = Application.start() 4 | 5 | // Configure Stimulus development experience 6 | application.debug = false 7 | window.Stimulus = application 8 | 9 | export { application } 10 | -------------------------------------------------------------------------------- /app/controllers/insights_controller.rb: -------------------------------------------------------------------------------- 1 | class InsightsController < ApplicationController 2 | def index 3 | period = Period.coerce(params.fetch(:period, "tm")) 4 | insights = Insights::BuildData.call(period: period.range).insights 5 | 6 | render Views::Insights::Index.new(insights:) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/components/bolt/drawer/drawer.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Drawer < Base 3 | private 4 | 5 | def view_template(&) 6 | div class: "drawer" do 7 | input type: "checkbox", id: "drawer-toggle", class: "drawer-toggle" 8 | 9 | yield 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if gem list --no-installed --exact --silent foreman; then 4 | echo "Installing foreman..." 5 | gem install foreman 6 | fi 7 | 8 | # Default to port 3000 if not specified 9 | export PORT="${PORT:-3000}" 10 | 11 | exec foreman start -f Procfile.dev --env /dev/null "$@" 12 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/components/bolt/result/result_image.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class ResultImage < Base 3 | private 4 | 5 | def view_template(&) 6 | div class: "flex items-center justify-center p-4" do 7 | img class: ["h-48 w-auto ", @extra_classes], **@attributes 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/030_create_categories.rb: -------------------------------------------------------------------------------- 1 | class CreateCategories < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :categories do |t| 4 | t.text :title, null: false 5 | t.text :color, null: false 6 | t.integer :position, null: false 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | include Clearance::Controller 3 | 4 | # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. 5 | allow_browser versions: :modern 6 | 7 | before_action :require_login 8 | end 9 | -------------------------------------------------------------------------------- /config/initializers/phlex.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | end 3 | 4 | module Components 5 | extend Phlex::Kit 6 | end 7 | 8 | Rails.autoloaders.main.push_dir( 9 | Rails.root.join("app/views"), namespace: Views 10 | ) 11 | 12 | Rails.autoloaders.main.push_dir( 13 | Rails.root.join("app/components"), namespace: Components 14 | ) 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # version: 2 2 | # updates: 3 | # - package-ecosystem: bundler 4 | # directory: "/" 5 | # schedule: 6 | # interval: daily 7 | # open-pull-requests-limit: 10 8 | # - package-ecosystem: github-actions 9 | # directory: "/" 10 | # schedule: 11 | # interval: daily 12 | # open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ruby_version: 3.4 2 | format: progress 3 | parallel: true 4 | 5 | plugins: 6 | - standard-rails 7 | 8 | ignore: 9 | - "node_modules/**/*" 10 | - "vendor/**/*" 11 | - "tmp/**/*" 12 | - "storage/**/*" 13 | - "public/**/*" 14 | - "coverage/**/*" 15 | - "app/{views,components}/**/*.rb": 16 | - "Rails/Output" 17 | -------------------------------------------------------------------------------- /config/recurring.yml: -------------------------------------------------------------------------------- 1 | # production: 2 | # periodic_cleanup: 3 | # class: CleanSoftDeletedRecordsJob 4 | # queue: background 5 | # args: [ 1000, { batch_size: 500 } ] 6 | # schedule: every hour 7 | # periodic_command: 8 | # command: "SoftDeletedRecord.due.delete_all" 9 | # priority: 2 10 | # schedule: at 5am every day 11 | -------------------------------------------------------------------------------- /test/system/sessions/destroy_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Sessions 4 | class DestroySystemTest < ApplicationSystemTestCase 5 | test "sign out" do 6 | visit root_path(as: create(:user)) 7 | 8 | click_link t("Sign out") 9 | 10 | assert_current_path sign_in_path 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/components/bolt/form/submit.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Submit < Base 3 | def initialize(form, value = nil, **) 4 | @form = form 5 | @value = value 6 | 7 | super(**) 8 | end 9 | 10 | private 11 | 12 | def view_template(&) 13 | @form.submit @value, **@attributes, class: "btn btn-primary" 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/components/bolt/form/email_field.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class EmailField < Base 3 | def initialize(form, attribute, **) 4 | @form = form 5 | @attribute = attribute 6 | 7 | super(**) 8 | end 9 | 10 | private 11 | 12 | def view_template 13 | @form.email_field @attribute, class: "input w-full", **@attributes 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/components/bolt/form/toggle.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Toggle < Base 3 | def initialize(form, attribute, **) 4 | @form = form 5 | @attribute = attribute 6 | 7 | super(**) 8 | end 9 | 10 | private 11 | 12 | def view_template 13 | @form.checkbox @attribute, class: "toggle toggle-primary", **@attributes 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /config/cache.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | store_options: 3 | # Cap age of oldest cache entry to fulfill retention policies 4 | # max_age: <%= 60.days.to_i %> 5 | max_size: <%= 256.megabytes %> 6 | namespace: <%= Rails.env %> 7 | 8 | development: 9 | <<: *default 10 | 11 | test: 12 | <<: *default 13 | 14 | production: 15 | database: cache 16 | <<: *default 17 | -------------------------------------------------------------------------------- /config/queue.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | dispatchers: 3 | - polling_interval: 1 4 | batch_size: 500 5 | workers: 6 | - queues: "*" 7 | threads: 3 8 | processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> 9 | polling_interval: 0.1 10 | 11 | development: 12 | <<: *default 13 | 14 | test: 15 | <<: *default 16 | 17 | production: 18 | <<: *default 19 | -------------------------------------------------------------------------------- /test/factories/records_factories.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :record do 3 | account 4 | category 5 | 6 | group { :expense } 7 | amount_cents { 1000 } 8 | occurred_on { 2.days.ago } 9 | 10 | factory :expense do 11 | group { :expense } 12 | end 13 | 14 | factory :income do 15 | group { :income } 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/support/translation_helpers.rb: -------------------------------------------------------------------------------- 1 | module TranslationHelpers 2 | def t(...) = I18n.t(...) 3 | 4 | def field(model, attribute) = model.human_attribute_name(attribute) 5 | 6 | def submit_text(model, action = :create) 7 | model_name = t("activerecord.models.#{model.model_name.i18n_key}") 8 | 9 | t("helpers.submit.#{action}", model: model_name) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/components/bolt/form/date_field.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class DateField < Base 3 | def initialize(form, attribute, **) 4 | @form = form 5 | @attribute = attribute 6 | 7 | super(**) 8 | end 9 | 10 | private 11 | 12 | def view_template(&) 13 | @form.date_field @attribute, class: "input w-full", **@attributes, & 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark any vendored files as having been vendored. 7 | vendor/* linguist-vendored 8 | config/credentials/*.yml.enc diff=rails_credentials 9 | config/credentials.yml.enc diff=rails_credentials 10 | -------------------------------------------------------------------------------- /app/components/bolt/drawer/drawer_toggle.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class DrawerToggle < Base 3 | private 4 | 5 | def view_template(&) 6 | label( 7 | for: "drawer-toggle", 8 | class: "btn btn-square btn-ghost lg:hidden", 9 | aria: {label: "open sidebar"} 10 | ) do 11 | Lucide.Menu class: "size-6" 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/components/bolt/form/form.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Form < Phlex::HTML 3 | include Phlex::Rails::Helpers::FormWith 4 | 5 | def initialize(**options) 6 | @options = options 7 | end 8 | 9 | private 10 | 11 | def view_template(&) 12 | form_with(**@options) do |form| 13 | Bolt.FormBuilder form:, & 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/components/bolt/form/password_field.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class PasswordField < Base 3 | def initialize(form, attribute, **) 4 | @form = form 5 | @attribute = attribute 6 | 7 | super(**) 8 | end 9 | 10 | private 11 | 12 | def view_template 13 | @form.password_field @attribute, class: "input w-full", **@attributes 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/components/bolt/toast/toast.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Toast < Base 3 | def initialize(color:, **) 4 | @color = color 5 | 6 | super(**) 7 | end 8 | 9 | private 10 | 11 | def view_template(&) 12 | div class: "toast animate-fade-in-up", data: {turbo_temporary: true} do 13 | Bolt.Alert(color: @color, &) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.kamal/hooks/post-deploy.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A sample post-deploy hook 4 | # 5 | # These environment variables are available: 6 | # KAMAL_RECORDED_AT 7 | # KAMAL_PERFORMER 8 | # KAMAL_VERSION 9 | # KAMAL_HOSTS 10 | # KAMAL_ROLE (if set) 11 | # KAMAL_DESTINATION (if set) 12 | # KAMAL_RUNTIME 13 | 14 | echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" 15 | -------------------------------------------------------------------------------- /test/support/clearance.rb: -------------------------------------------------------------------------------- 1 | require "clearance/test_unit" 2 | 3 | module Clearance::RequestHelpers 4 | def sign_in 5 | user = create(:user, password: "test123") 6 | 7 | post session_path, params: { 8 | session: {email: user.email, password: "test123"} 9 | } 10 | end 11 | end 12 | 13 | ActiveSupport.on_load :action_dispatch_integration_test do 14 | include Clearance::RequestHelpers 15 | end 16 | -------------------------------------------------------------------------------- /config/initializers/bolt.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | extend Phlex::Kit 3 | end 4 | 5 | # Allow using Bolt::ComponentName instead Components::Bolt::ComponentName 6 | Rails.autoloaders.main.push_dir( 7 | Rails.root.join("app/components/bolt"), namespace: Bolt 8 | ) 9 | 10 | # Allow using Bolt::ComponentName instead Bolt::ComponentName::ComponentName 11 | Rails.autoloaders.main.collapse(Rails.root.join("app/components/bolt/*")) 12 | -------------------------------------------------------------------------------- /app/components/bolt/drawer/drawer_side.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class DrawerSide < Base 3 | private 4 | 5 | def view_template(&) 6 | div class: "drawer-side z-100", data: {turbo_temporary: true} do 7 | label( 8 | for: "drawer-toggle", 9 | class: "drawer-overlay", 10 | aria_label: "close sidebar" 11 | ) 12 | 13 | yield 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/operations/records/destroy.rb: -------------------------------------------------------------------------------- 1 | module Records 2 | class Destroy < ApplicationOperation 3 | prop :id, Id 4 | 5 | Success = Result.define(record: Record) 6 | Failure = Result.define(record: Record) 7 | 8 | def call 9 | record = Record.find(@id) 10 | 11 | if record.destroy 12 | Success[record:] 13 | else 14 | Failure[record:] 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/components/bolt/form/radio_button.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class RadioButton < Base 3 | def initialize(form, attribute, value, **) 4 | @form = form 5 | @attribute = attribute 6 | @value = value 7 | 8 | super(**) 9 | end 10 | 11 | private 12 | 13 | def view_template 14 | @form.radio_button @attribute, @value, class: "radio radio-primary", **@attributes 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/operations/accounts/destroy.rb: -------------------------------------------------------------------------------- 1 | module Accounts 2 | class Destroy < ApplicationOperation 3 | prop :id, Id 4 | 5 | Success = Result.define(account: Account) 6 | Failure = Result.define(account: Account) 7 | 8 | def call 9 | account = Account.find(@id) 10 | 11 | if account.destroy 12 | Success[account:] 13 | else 14 | Failure[account:] 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/operations/records/create.rb: -------------------------------------------------------------------------------- 1 | module Records 2 | class Create < ApplicationOperation 3 | prop :attributes, Hash 4 | 5 | Success = Result.define(record: Record) 6 | Failure = Result.define(record: Record) 7 | 8 | def call 9 | record = Record.new(@attributes) 10 | 11 | if record.save 12 | Success[record:] 13 | else 14 | Failure[record:] 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/operations/application_operation.rb: -------------------------------------------------------------------------------- 1 | class ApplicationOperation 2 | extend Literal::Properties 3 | 4 | Id = _Union(String, Integer) 5 | 6 | def self.call(...) = new(...).call 7 | 8 | class Result < Literal::Data 9 | def self.[](...) = new(...) 10 | 11 | def self.define(**properties) 12 | Class.new(self) do 13 | properties.each { |name, type| prop(name, type) } 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/020_create_accounts.rb: -------------------------------------------------------------------------------- 1 | class CreateAccounts < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :accounts do |t| 4 | t.text :title, null: false 5 | t.text :color, null: false 6 | t.integer :initial_amount_cents, default: 0, null: false 7 | t.boolean :archived, null: false, default: false 8 | t.integer :position, null: false 9 | 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/components/layouts/sidenav.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Layouts 3 | class Sidenav < Base 4 | private 5 | 6 | def view_template 7 | Bolt.Menu size: :lg, class: "bg-base-200 min-h-full w-60" do 8 | span class: "text-2xl font-black text-primary text-center p-8" do 9 | "pikoin" 10 | end 11 | 12 | Layouts.MenuItems 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/models/category.rb: -------------------------------------------------------------------------------- 1 | class Category < ApplicationRecord 2 | include Sortable 3 | 4 | has_many :records, dependent: :destroy 5 | has_many :templates, dependent: :destroy 6 | 7 | validates :title, presence: true 8 | validates :color, presence: true 9 | validates :position, presence: true 10 | 11 | validates :title, length: {maximum: 40} 12 | 13 | validates :color, format: {with: Constants::COLOR_REGEX, allow_blank: true} 14 | end 15 | -------------------------------------------------------------------------------- /app/operations/categories/destroy.rb: -------------------------------------------------------------------------------- 1 | module Categories 2 | class Destroy < ApplicationOperation 3 | prop :id, Id 4 | 5 | Success = Result.define(category: Category) 6 | Failure = Result.define(category: Category) 7 | 8 | def call 9 | category = Category.find(@id) 10 | 11 | if category.destroy 12 | Success[category:] 13 | else 14 | Failure[category:] 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/operations/templates/destroy.rb: -------------------------------------------------------------------------------- 1 | module Templates 2 | class Destroy < ApplicationOperation 3 | prop :id, Id 4 | 5 | Success = Result.define(template: Template) 6 | Failure = Result.define(template: Template) 7 | 8 | def call 9 | template = Template.find(@id) 10 | 11 | if template.destroy 12 | Success[template:] 13 | else 14 | Failure[template:] 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/operations/transfers/destroy.rb: -------------------------------------------------------------------------------- 1 | module Transfers 2 | class Destroy < ApplicationOperation 3 | prop :id, Id 4 | 5 | Success = Result.define(transfer: Transfer) 6 | Failure = Result.define(transfer: Transfer) 7 | 8 | def call 9 | transfer = Transfer.find(@id) 10 | 11 | if transfer.destroy! 12 | Success[transfer:] 13 | else 14 | Failure[transfer:] 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/factories/transfers_factories.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :transfer do 3 | to_create do |transfer| 4 | transfer.save! 5 | Record.create!(transfer.attributes_for_expense_record) 6 | Record.create!(transfer.attributes_for_income_record) 7 | end 8 | 9 | from_account factory: :account 10 | to_account factory: :account 11 | 12 | amount_cents { 95_00 } 13 | occurred_on { 2.days.ago } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/components/layouts/zen.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Layouts 3 | class Zen < Base 4 | private 5 | 6 | def view_template(&) 7 | Layouts.Root do 8 | div class: "flex flex-col mx-auto justify-start lg:justify-center " \ 9 | "items-center w-full min-h-screen bg-base-200" do 10 | div class: "w-full max-w-prose", & 11 | end 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/components/bolt/page/page_header.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class PageHeader < Base 3 | def initialize(sticky: true) 4 | @sticky = sticky 5 | end 6 | 7 | private 8 | 9 | def view_template(&) 10 | div class: [ 11 | "flex flex-col justify-between gap-4 w-full px-4 bg-base-200 " \ 12 | "lg:flex-row lg:items-center lg:gap-1 py-4", 13 | ("top-0 sticky z-50" if @sticky) 14 | ], & 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /bin/docker-entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # Enable jemalloc for reduced memory usage and latency. 4 | if [ -z "${LD_PRELOAD+x}" ]; then 5 | LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit) 6 | export LD_PRELOAD 7 | fi 8 | 9 | # If running the rails server then create or migrate existing database 10 | if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then 11 | ./bin/rails db:prepare 12 | fi 13 | 14 | exec "${@}" 15 | -------------------------------------------------------------------------------- /db/migrate/100_create_indexes.rb: -------------------------------------------------------------------------------- 1 | class CreateIndexes < ActiveRecord::Migration[8.0] 2 | def change 3 | add_index :accounts, %i[archived position] 4 | add_index :categories, %i[position] 5 | add_index :templates, %i[position] 6 | 7 | add_index :records, %i[account_id occurred_on created_at], 8 | order: {occurred_on: :desc, created_at: :desc} 9 | 10 | add_index :records, %i[occurred_on], order: {occurred_on: :desc} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/operations/records/update.rb: -------------------------------------------------------------------------------- 1 | module Records 2 | class Update < ApplicationOperation 3 | prop :id, Id 4 | prop :attributes, Hash 5 | 6 | Success = Result.define(record: Record) 7 | Failure = Result.define(record: Record) 8 | 9 | def call 10 | record = Record.find(@id) 11 | 12 | if record.update(@attributes) 13 | Success[record:] 14 | else 15 | Failure[record:] 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/components/bolt/menu/menu.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Menu < Base 3 | SIZE_CLASSES = { 4 | lg: "menu-lg" 5 | }.freeze 6 | 7 | def initialize(size: nil, **) 8 | @size = size 9 | 10 | super(**) 11 | end 12 | 13 | private 14 | 15 | def view_template(&) 16 | ul class: classes, & 17 | end 18 | 19 | def classes 20 | ["menu flex flex-col gap-1", SIZE_CLASSES[@size], @extra_classes] 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/operations/accounts/update.rb: -------------------------------------------------------------------------------- 1 | module Accounts 2 | class Update < ApplicationOperation 3 | prop :id, Id 4 | prop :attributes, Hash 5 | 6 | Success = Result.define(account: Account) 7 | Failure = Result.define(account: Account) 8 | 9 | def call 10 | account = Account.find(@id) 11 | 12 | if account.update(@attributes) 13 | Success[account:] 14 | else 15 | Failure[account:] 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/operations/templates/update.rb: -------------------------------------------------------------------------------- 1 | module Templates 2 | class Update < ApplicationOperation 3 | prop :id, Id 4 | prop :attributes, Hash 5 | 6 | Success = Result.define(template: Template) 7 | Failure = Result.define(template: Template) 8 | 9 | def call 10 | template = Template.find(@id) 11 | 12 | if template.update(@attributes) 13 | Success[template:] 14 | else 15 | Failure[template:] 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/components/bolt/form/field_wrapper.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class FieldWrapper < Base 3 | ORIENTATIONS = { 4 | vertical: "flex-col", 5 | horizontal: "flex-row items-center" 6 | } 7 | 8 | def initialize(orientation: :vertical, **) 9 | @orientation = orientation 10 | 11 | super(**) 12 | end 13 | 14 | private 15 | 16 | def view_template(&) 17 | div class: ["flex gap-2 w-full", ORIENTATIONS[@orientation]], & 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/operations/categories/update.rb: -------------------------------------------------------------------------------- 1 | module Categories 2 | class Update < ApplicationOperation 3 | prop :id, Id 4 | prop :attributes, Hash 5 | 6 | Success = Result.define(category: Category) 7 | Failure = Result.define(category: Category) 8 | 9 | def call 10 | category = Category.find(@id) 11 | 12 | if category.update(@attributes) 13 | Success[category:] 14 | else 15 | Failure[category:] 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/components/bolt/page/page_title.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class PageTitle < Base 3 | def initialize(drawer_toggle: true, **) 4 | @drawer_toggle = drawer_toggle 5 | 6 | super(**) 7 | end 8 | 9 | private 10 | 11 | def view_template(&) 12 | div class: "flex flex-row items-center gap-2" do 13 | Bolt.DrawerToggle if @drawer_toggle 14 | h1 class: ["text-primary font-extrabold text-2xl", @extra_classes], & 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/lib/cashflow_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CashflowTest < ActiveSupport::TestCase 4 | test "#balance" do 5 | assert_equal Money.new(0), 6 | Cashflow[income: Money.new(0), expense: Money.new(0)].balance 7 | 8 | assert_equal Money.new(3_00), 9 | Cashflow[income: Money.new(4_00), expense: Money.new(-100)].balance 10 | 11 | assert_equal Money.new(-2_00), 12 | Cashflow[income: Money.new(2_00), expense: Money.new(-4_00)].balance 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/operations/insights/calculate_cashflow.rb: -------------------------------------------------------------------------------- 1 | module Insights 2 | class CalculateCashflow < ApplicationOperation 3 | prop :period, _Range?(_Union(Date, Time)), default: nil 4 | 5 | Result = Result.define(cashflow: Cashflow) 6 | 7 | def call 8 | Result[ 9 | cashflow: Cashflow[ 10 | income: Record.income.on(@period).without_transfers.total, 11 | expense: Record.expense.on(@period).without_transfers.total 12 | ] 13 | ] 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/views/pwa/manifest.json.erb: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pikoin", 3 | "icons": [ 4 | { 5 | "src": "/icon.png", 6 | "type": "image/png", 7 | "sizes": "512x512" 8 | }, 9 | { 10 | "src": "/icon.png", 11 | "type": "image/png", 12 | "sizes": "512x512", 13 | "purpose": "maskable" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "scope": "/", 19 | "description": "pikoin.", 20 | "theme_color": "red", 21 | "background_color": "red" 22 | } 23 | -------------------------------------------------------------------------------- /app/components/bolt/container/stack.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Stack < Base 3 | GAP_CLASSES = { 4 | xs: "gap-1", 5 | sm: "gap-2", 6 | md: "gap-4" 7 | }.freeze 8 | 9 | def initialize(gap: :md, **) 10 | @gap = gap 11 | 12 | super(**) 13 | end 14 | 15 | private 16 | 17 | def view_template(&) 18 | div( 19 | class: ["flex flex-col", GAP_CLASSES[@gap], @extra_classes], 20 | **@attributes, 21 | & 22 | ) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 4 | # Use this to limit dissemination of sensitive information. 5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc 8 | ] 9 | -------------------------------------------------------------------------------- /app/components/bolt/form/field_error.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class FieldError < Base 3 | def initialize(form, attribute, **) 4 | @form = form 5 | @attribute = attribute 6 | 7 | super(**) 8 | end 9 | 10 | private 11 | 12 | def render? = @form.object.errors.key?(@attribute) 13 | 14 | def view_template(&) 15 | span class: "text-sm text-error", data: {turbo_temporary: true} do 16 | @form.object.errors[@attribute].to_sentence.upcase_first 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/components/bolt/list/list_item.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class ListItem < Base 3 | PADDING = { 4 | md: "p-4" 5 | } 6 | def initialize(padding: :md, **) 7 | @padding = padding 8 | 9 | super(**) 10 | end 11 | 12 | private 13 | 14 | def view_template(&) 15 | li( 16 | class: [ 17 | "bg-base-100 rounded-box shadow hover:bg-base-300", 18 | PADDING[@padding] 19 | ], 20 | **@attributes, 21 | & 22 | ) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/components/bolt/base.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Base < Phlex::HTML 3 | include PhlexIcons 4 | 5 | include Phlex::Rails::Helpers::T 6 | include Phlex::Rails::Helpers::LinkTo 7 | 8 | def initialize(**attributes) 9 | @extra_classes = attributes.delete(:class) 10 | @attributes = attributes 11 | end 12 | 13 | private 14 | 15 | if Rails.env.development? 16 | def before_template 17 | comment { "Before #{self.class.name}" } 18 | super 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/components/layouts/flash.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Layouts 3 | class Flash < Base 4 | include Phlex::Rails::Helpers::Flash 5 | 6 | COLORS = { 7 | "notice" => :success, 8 | "alert" => :error 9 | }.freeze 10 | 11 | private 12 | 13 | def render? = flash.any? 14 | 15 | def view_template 16 | flash.each do |type, message| 17 | Bolt.Toast color: COLORS[type] do 18 | message 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/components/accounts/list.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Accounts 3 | class List < Base 4 | def initialize(accounts:) 5 | @accounts = accounts 6 | end 7 | 8 | private 9 | 10 | def view_template 11 | Bolt.List( 12 | data: { 13 | controller: "sortable", 14 | sortable_endpoint_value: ordering_path(Account.name.parameterize) 15 | } 16 | ) do 17 | @accounts.each { ListItem(account: it) } 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | KNF5KH+em21bM1mKYE4LP83yJjzrkrY2S5tHmGlvZ2R7mJu9/wj7xEW7Q+8Wi6gVSbEphOQFo5Zh6X/edlWAX9PtxBm4Jw473rUd3Nen/m6EdPtxzdUlRYoRnY3cKT/4TZPx6oEt9wDpW4+BXO4N36wu/43FDNiT1Qk5S0huyvkmttL6MNNK2wGXtZNabhMJQwUGFYgAObbsKonE4cKjTa3VnFB+s3Mej6WLG1Tjda90klsa2HFsbn4sCQedeOHc1C1Di9zQnoKVL1UijhZ1fclqXqQTznWpwbulvllbOJ/xRZ2pyH/mnJdzasMJ52LjokKKW/cs0q7AyvPGXmgk5PLIZVhitpKtDgoSBUdwXLh3HaNoMQAyCfj2PE7NFqe3Snf4clu0p3mRXTx2K/RuN3Fh4oE5t6sbVFjM5tNigeIea3IgmfrUlA9w3NDUkt+zKZne8nGJoWr6fdaQotmR54zXQdZgA5wEzhrAElk5hFmSL9wVsr3wHKdU--1w/QmlhKbdQONXZN--yaIwiFKzBeUzoFJtRs40fw== -------------------------------------------------------------------------------- /db/migrate/010_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :users do |t| 4 | t.text :email, null: false 5 | t.text :encrypted_password, limit: 128, null: false 6 | t.text :confirmation_token, limit: 128 7 | t.text :remember_token, limit: 128, null: false 8 | t.timestamps null: false 9 | end 10 | 11 | add_index :users, :email 12 | add_index :users, :confirmation_token, unique: true 13 | add_index :users, :remember_token, unique: true 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/operations/categories/create.rb: -------------------------------------------------------------------------------- 1 | module Categories 2 | class Create < ApplicationOperation 3 | prop :attributes, Hash 4 | 5 | Success = Result.define(category: Category) 6 | Failure = Result.define(category: Category) 7 | 8 | def call 9 | category = Category.new(default_attributes.merge(@attributes)) 10 | 11 | if category.save 12 | Success[category:] 13 | else 14 | Failure[category:] 15 | end 16 | end 17 | 18 | def default_attributes = {position: Category.next_position} 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/operations/templates/create.rb: -------------------------------------------------------------------------------- 1 | module Templates 2 | class Create < ApplicationOperation 3 | prop :attributes, Hash 4 | 5 | Success = Result.define(template: Template) 6 | Failure = Result.define(template: Template) 7 | 8 | def call 9 | template = Template.new(default_attributes.merge(@attributes)) 10 | 11 | if template.save 12 | Success[template:] 13 | else 14 | Failure[template:] 15 | end 16 | end 17 | 18 | def default_attributes = {position: Template.next_position} 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/components/bolt/dot/dot.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Dot < Base 3 | def initialize(color:, size:, **) 4 | @color = color 5 | @size = size 6 | 7 | super(**) 8 | end 9 | 10 | private 11 | 12 | def view_template(&) 13 | span( 14 | class: [ 15 | "flex items-center justify-center rounded-full aspect-square", 16 | @size, 17 | @extra_classes 18 | ], 19 | style: {background_color: @color}, 20 | **@attributes, 21 | & 22 | ) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/components/templates/list.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Templates 3 | class List < Base 4 | def initialize(templates:) 5 | @templates = templates 6 | end 7 | 8 | private 9 | 10 | def view_template 11 | Bolt.List( 12 | data: { 13 | controller: "sortable", 14 | sortable_endpoint_value: ordering_path(Template.name.parameterize) 15 | } 16 | ) do 17 | @templates.each { ListItem(template: it) } 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/components/bolt/form/text_field.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class TextField < Base 3 | def initialize(form, attribute, **) 4 | @form = form 5 | @attribute = attribute 6 | @object = @form.object.presence 7 | 8 | super(**) 9 | end 10 | 11 | private 12 | 13 | def view_template 14 | @form.text_field( 15 | @attribute, 16 | class: [ 17 | "input w-full", 18 | ("input-error" if @object&.errors&.key?(@attribute)) 19 | ], 20 | **@attributes 21 | ) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/components/categories/list.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Categories 3 | class List < Base 4 | def initialize(categories:) 5 | @categories = categories 6 | end 7 | 8 | private 9 | 10 | def view_template 11 | Bolt.List( 12 | data: { 13 | controller: "sortable", 14 | sortable_endpoint_value: ordering_path(Category.name.parameterize) 15 | } 16 | ) do 17 | @categories.each { ListItem(category: it) } 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/migrate/060_create_records.rb: -------------------------------------------------------------------------------- 1 | class CreateRecords < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :records do |t| 4 | t.references :account, null: false, foreign_key: true 5 | t.references :transfer, null: true, foreign_key: true 6 | t.references :category, null: true, foreign_key: true 7 | t.integer :group, null: false 8 | t.integer :amount_cents, default: 0, null: false 9 | t.date :occurred_on, null: false 10 | t.text :note, null: false, default: "" 11 | 12 | t.timestamps 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/operations/records/destroy_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Records 4 | class DestroyTest < ActiveSupport::TestCase 5 | test "success" do 6 | id = create(:record).id 7 | 8 | result = Records::Destroy.call(id:) 9 | 10 | assert_instance_of Records::Destroy::Success, result 11 | assert result.record.destroyed? 12 | end 13 | 14 | test "not found" do 15 | id = "not-found" 16 | 17 | assert_raises ActiveRecord::RecordNotFound do 18 | Records::Destroy.call(id:) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/components/bolt/menu/menu_item.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class MenuItem < Base 3 | def initialize(active: false, **) 4 | @active = active 5 | 6 | super(**) 7 | end 8 | 9 | private 10 | 11 | def view_template(&) 12 | li do 13 | a( 14 | class: [ 15 | "flex flex-row gap-2 font-semibold [&>svg]:stroke-3", 16 | ("bg-primary text-primary-content" if @active), 17 | @extra_classes 18 | ], 19 | **@attributes, 20 | & 21 | ) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/operations/accounts/create.rb: -------------------------------------------------------------------------------- 1 | module Accounts 2 | class Create < ApplicationOperation 3 | prop :attributes, Hash 4 | 5 | Success = Result.define(account: Account) 6 | Failure = Result.define(account: Account) 7 | 8 | def call 9 | account = Account.new(default_attributes.merge(@attributes)) 10 | 11 | if account.save 12 | Success[account:] if account.save 13 | else 14 | Failure[account:] 15 | end 16 | end 17 | 18 | private 19 | 20 | def default_attributes = {position: Account.next_position} 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/migrate/050_create_transfers.rb: -------------------------------------------------------------------------------- 1 | class CreateTransfers < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :transfers do |t| 4 | t.references :from_account, 5 | null: false, 6 | foreign_key: {to_table: :accounts} 7 | 8 | t.references :to_account, 9 | null: false, 10 | foreign_key: {to_table: :accounts} 11 | 12 | t.integer :amount_cents, default: 0, null: false 13 | t.date :occurred_on, null: false 14 | t.text :note, null: false, default: "" 15 | 16 | t.timestamps 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/operations/accounts/destroy_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Accounts 4 | class DestroyTest < ActiveSupport::TestCase 5 | test "success" do 6 | id = create(:account).id 7 | 8 | result = Accounts::Destroy.call(id:) 9 | 10 | assert_instance_of Accounts::Destroy::Success, result 11 | assert result.account.destroyed? 12 | end 13 | 14 | test "failure" do 15 | id = "not-found" 16 | 17 | assert_raises ActiveRecord::RecordNotFound do 18 | Accounts::Destroy.call(id:) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/components/bolt/form/label.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Label < Base 3 | def initialize(form, attribute, text = nil, **) 4 | @form = form 5 | @attribute = attribute 6 | @text = text 7 | @object = @form.object.presence 8 | 9 | super(**) 10 | end 11 | 12 | private 13 | 14 | def view_template(&) 15 | @form.label @attribute, @text, 16 | class: [ 17 | "font-bold", 18 | ("text-error" if @object&.errors&.key?(@attribute)) 19 | ], 20 | **@attributes, 21 | & 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /db/migrate/040_create_templates.rb: -------------------------------------------------------------------------------- 1 | class CreateTemplates < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :templates do |t| 4 | t.text :title, null: false 5 | t.integer :group, null: false 6 | t.references :account, null: false, foreign_key: true 7 | t.references :category, null: false, foreign_key: true 8 | t.integer :amount_cents, default: 0, null: false 9 | t.text :note 10 | t.integer :position, null: false 11 | 12 | t.timestamps 13 | end 14 | 15 | add_index :templates, :title, unique: true 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/operations/templates/destroy_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Templates 4 | class DestroyTest < ActiveSupport::TestCase 5 | test "success" do 6 | id = create(:template).id 7 | 8 | result = Templates::Destroy.call(id:) 9 | 10 | assert_instance_of Templates::Destroy::Success, result 11 | assert result.template.destroyed? 12 | end 13 | 14 | test "not found" do 15 | id = "not-found" 16 | 17 | assert_raises ActiveRecord::RecordNotFound do 18 | Templates::Destroy.call(id:) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/operations/transfers/destroy_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Transfers 4 | class DestroyTest < ActiveSupport::TestCase 5 | test "success" do 6 | id = create(:transfer).id 7 | 8 | result = Transfers::Destroy.call(id:) 9 | 10 | assert_instance_of Transfers::Destroy::Success, result 11 | assert result.transfer.destroyed? 12 | end 13 | 14 | test "not found" do 15 | id = "not-found" 16 | 17 | assert_raises ActiveRecord::RecordNotFound do 18 | Transfers::Destroy.call(id:) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/operations/categories/destroy_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Categories 4 | class DestroyTest < ActiveSupport::TestCase 5 | test "success" do 6 | id = create(:category).id 7 | 8 | result = Categories::Destroy.call(id:) 9 | 10 | assert_instance_of Categories::Destroy::Success, result 11 | assert result.category.destroyed? 12 | end 13 | 14 | test "failure" do 15 | id = "not-found" 16 | 17 | assert_raises ActiveRecord::RecordNotFound do 18 | Categories::Destroy.call(id:) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/cable_schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema[7.1].define(version: 1) do 2 | create_table "solid_cable_messages", force: :cascade do |t| 3 | t.binary "channel", limit: 1024, null: false 4 | t.binary "payload", limit: 536870912, null: false 5 | t.datetime "created_at", null: false 6 | t.integer "channel_hash", limit: 8, null: false 7 | t.index ["channel"], name: "index_solid_cable_messages_on_channel" 8 | t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" 9 | t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/components/base.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | class Base < Phlex::HTML 3 | include Components 4 | include PhlexIcons 5 | 6 | # Include any helpers you want to be available across all components 7 | include Phlex::Rails::Helpers::ImagePath 8 | include Phlex::Rails::Helpers::L 9 | include Phlex::Rails::Helpers::Request 10 | include Phlex::Rails::Helpers::Routes 11 | include Phlex::Rails::Helpers::T 12 | 13 | if Rails.env.development? 14 | def before_template 15 | comment { "Before #{self.class.name}" } 16 | super 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/components/layouts/navbar.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Layouts 3 | class Navbar < Base 4 | private 5 | 6 | def view_template 7 | Bolt.Navbar( 8 | class: [ 9 | "flex flex-col items-center justify-center w-full fixed z-20 top-0", 10 | "start-0 bg-base-200 lg:hidden" 11 | ] 12 | ) do 13 | div class: "flex flex-row gap-2 items-center w-full max-w-screen-xl" do 14 | Bolt.DrawerToggle 15 | Bolt.NavbarTitle(href: root_path) { "pikoin" } 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | # Async adapter only works within the same process, so for manually triggering cable updates from a console, 2 | # and seeing results in the browser, you must do so from the web console (running inside the dev process), 3 | # not a terminal started via bin/rails console! Add "console" to any action or any ERB template view 4 | # to make the web console appear. 5 | development: 6 | adapter: async 7 | 8 | test: 9 | adapter: test 10 | 11 | production: 12 | adapter: solid_cable 13 | connects_to: 14 | database: 15 | writing: cable 16 | polling_interval: 0.1.seconds 17 | message_retention: 1.day 18 | -------------------------------------------------------------------------------- /app/models/template.rb: -------------------------------------------------------------------------------- 1 | class Template < ApplicationRecord 2 | include Sortable 3 | 4 | enum :group, {expense: -1, income: 1}, validate: true 5 | 6 | belongs_to :account 7 | belongs_to :category 8 | 9 | validates :title, presence: true 10 | validates :position, presence: true 11 | 12 | validates :title, uniqueness: {case_sensitive: false} 13 | 14 | validates :amount_cents, numericality: {greater_than_or_equal_to: 0} 15 | 16 | validates :title, length: {maximum: 40} 17 | validates :note, length: {maximum: 32} 18 | 19 | def amount 20 | Money.new(amount_cents * self.class.groups[group]) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/operations/insights/build_data.rb: -------------------------------------------------------------------------------- 1 | module Insights 2 | class BuildData < ApplicationOperation 3 | prop :period, _Range?(_Union(Date, Time)), default: nil 4 | 5 | Result = Result.define(insights: InsightsData) 6 | 7 | def call 8 | Result[insights: InsightsData[cashflow:, spending_breakdown:]] 9 | end 10 | 11 | private 12 | 13 | def cashflow 14 | Insights::CalculateCashflow.call(period: @period).cashflow 15 | end 16 | 17 | def spending_breakdown 18 | Insights::CalculateSpendingBreakdown 19 | .call(period: @period) 20 | .spending_breakdown 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/operations/transfers/create.rb: -------------------------------------------------------------------------------- 1 | module Transfers 2 | class Create < ApplicationOperation 3 | prop :attributes, Hash 4 | 5 | Success = Result.define(transfer: Transfer) 6 | Failure = Result.define(transfer: Transfer) 7 | 8 | def call 9 | transfer = Transfer.new(@attributes) 10 | 11 | ActiveRecord::Base.transaction do 12 | transfer.save! 13 | Record.create!(transfer.attributes_for_expense_record) 14 | Record.create!(transfer.attributes_for_income_record) 15 | 16 | Success[transfer:] 17 | rescue 18 | Failure[transfer:] 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] ||= "test" 2 | require_relative "../config/environment" 3 | require "rails/test_help" 4 | 5 | Rails.root.glob("test/support/**/*.rb").each { |path| require path } 6 | 7 | module ActiveSupport 8 | class TestCase 9 | include TranslationHelpers 10 | include FactoryBot::Syntax::Methods 11 | 12 | # Run tests in parallel with specified workers 13 | parallelize(workers: :number_of_processors) 14 | 15 | # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. 16 | fixtures :all 17 | 18 | # Add more helper methods to be used by all tests here... 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/components/bolt/alert/alert.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Alert < Base 3 | COLORS = { 4 | success: "alert-success", 5 | error: "alert-error" 6 | }.freeze 7 | 8 | ICONS = { 9 | success: Lucide::CircleCheck, 10 | error: Lucide::CircleX 11 | }.freeze 12 | 13 | def initialize(color:, **) 14 | @color = color 15 | 16 | super(**) 17 | end 18 | 19 | private 20 | 21 | def view_template(&) 22 | div class: ["alert", COLORS[@color], @extra_classes], **@attributes do 23 | render ICONS[@color].new(class: "size-8") 24 | span(&) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/operations/sortables/reorder.rb: -------------------------------------------------------------------------------- 1 | module Sortables 2 | class Reorder < ApplicationOperation 3 | prop :model_class, _Descendant(Sortable) 4 | prop :ordering, Hash 5 | 6 | Success = Result.define 7 | Failure = Result.define 8 | 9 | def initialize(model_class:, ordering:) 10 | @model_class = model_class 11 | @ordering = ordering 12 | end 13 | 14 | def call 15 | @model_class.transaction do 16 | @ordering.each do |item| 17 | @model_class.update!(item[:id], position: item[:position]) 18 | end 19 | 20 | Success[] 21 | end 22 | rescue ActiveRecord::RecordInvalid 23 | Failure[] 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/controllers/ordering_controller.rb: -------------------------------------------------------------------------------- 1 | class OrderingController < ApplicationController 2 | ALLOWED_RESOURCES = %w[account category template] 3 | 4 | def update 5 | ordering = ordering_params 6 | 7 | case Sortables::Reorder.call(model_class:, ordering:) 8 | in Sortables::Reorder::Success 9 | head :ok 10 | in Sortables::Reorder::Failure 11 | head :unprocessable_content 12 | end 13 | end 14 | 15 | private 16 | 17 | def ordering_params 18 | params.expect(items: [%i[id position]]) 19 | end 20 | 21 | def model_class 22 | return unless ALLOWED_RESOURCES.include?(params[:resource]) 23 | 24 | params[:resource].camelcase.constantize 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/operations/transfers/update.rb: -------------------------------------------------------------------------------- 1 | module Transfers 2 | class Update < ApplicationOperation 3 | prop :id, Id 4 | prop :attributes, Hash 5 | 6 | Success = Result.define(transfer: Transfer) 7 | Failure = Result.define(transfer: Transfer) 8 | 9 | def call 10 | transfer = Transfer.find(@id) 11 | 12 | ActiveRecord::Base.transaction do 13 | transfer.update!(@attributes) 14 | transfer.expense_record.update!(transfer.attributes_for_expense_record) 15 | transfer.income_record.update!(transfer.attributes_for_income_record) 16 | 17 | Success[transfer:] 18 | rescue 19 | Failure[transfer:] 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /db/cache_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema[7.2].define(version: 1) do 4 | create_table "solid_cache_entries", force: :cascade do |t| 5 | t.binary "key", limit: 1024, null: false 6 | t.binary "value", limit: 536870912, null: false 7 | t.datetime "created_at", null: false 8 | t.integer "key_hash", limit: 8, null: false 9 | t.integer "byte_size", limit: 4, null: false 10 | t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" 11 | t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" 12 | t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/components/bolt/link_button/link_button.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class LinkButton < Base 3 | COLORS = { 4 | primary: "btn-primary", 5 | error: "btn-error" 6 | } 7 | 8 | def initialize(color: nil, ghost: false, dash: false, **) 9 | @color = color 10 | @ghost = ghost 11 | @dash = dash 12 | 13 | super(**) 14 | end 15 | 16 | private 17 | 18 | def view_template(&) 19 | a class: classes, **@attributes, & 20 | end 21 | 22 | def classes 23 | [ 24 | "btn", 25 | COLORS[@color], 26 | ("btn-ghost" if @ghost), 27 | ("btn-dash" if @dash), 28 | @extra_classes 29 | ] 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/application_system_test_case.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ApplicationSystemTestCase < ActionDispatch::SystemTestCase 4 | parallelize(workers: 1) 5 | 6 | # See https://github.com/teamcapybara/capybara/pull/2773 7 | Selenium::WebDriver.logger.ignore(:clear_local_storage, :clear_session_storage) 8 | 9 | if ENV["CAPYBARA_SERVER_PORT"] 10 | served_by host: "rails-app", port: ENV["CAPYBARA_SERVER_PORT"] 11 | 12 | driven_by :selenium, 13 | using: :headless_firefox, 14 | screen_size: [1400, 1400], 15 | options: {browser: :remote, url: "http://#{ENV["SELENIUM_HOST"]}:4444"} 16 | else 17 | driven_by :selenium, using: :headless_firefox, screen_size: [1400, 1400] 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.zed/settings.json: -------------------------------------------------------------------------------- 1 | // Folder-specific settings 2 | // 3 | // For a full list of overridable settings, and general information on folder-specific settings, 4 | // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 5 | { 6 | "wrap_guides": [80], 7 | "languages": { 8 | "Ruby": { 9 | "language_servers": ["ruby-lsp", "!solargraph", "!rubocop"], 10 | "formatter": "language_server", 11 | "format_on_save": "on" 12 | } 13 | }, 14 | "lsp": { 15 | "ruby-lsp": { 16 | "initialization_options": { 17 | "enabledFeatures": { 18 | "diagnostics": false, 19 | "formatting": true 20 | }, 21 | "formatter": "standard" 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /test/support/component_test_case.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "capybara/rails" 3 | require "capybara/minitest" 4 | 5 | # All component test classes should inherit ComponentTestCase 6 | class ComponentTestCase < ActiveSupport::TestCase 7 | include Capybara::DSL 8 | include Capybara::Minitest::Assertions 9 | include Rails.application.routes.url_helpers 10 | 11 | attr_reader :page 12 | 13 | def view_context 14 | controller.view_context 15 | end 16 | 17 | def controller 18 | @controller ||= ActionView::TestCase::TestController.new 19 | end 20 | 21 | def render(...) 22 | @page = ::Capybara::Node::Simple.new(render_to_string(...)) 23 | end 24 | 25 | def render_to_string(...) 26 | view_context.render(...) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /.devcontainer/compose.yaml: -------------------------------------------------------------------------------- 1 | name: "pikoin" 2 | 3 | services: 4 | rails-app: 5 | build: 6 | context: .. 7 | dockerfile: .devcontainer/Dockerfile 8 | 9 | volumes: 10 | - ../..:/workspaces:cached 11 | 12 | # Overrides default command so things don't shut down after the process ends. 13 | command: sleep infinity 14 | 15 | # Uncomment the next line to use a non-root user for all processes. 16 | # user: vscode 17 | 18 | # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. 19 | # (Adding the "ports" property to this file will not forward from a Codespace.) 20 | depends_on: 21 | - selenium 22 | 23 | selenium: 24 | image: selenium/standalone-chromium 25 | restart: unless-stopped 26 | -------------------------------------------------------------------------------- /app/components/templates/empty_state.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Templates 3 | class EmptyState < Base 4 | private 5 | 6 | def view_template 7 | Bolt.Result do 8 | Bolt.ResultImage src: image_path("templates-empty-state.svg") 9 | 10 | Bolt.ResultTitle do 11 | t("You haven’t saved any templates") 12 | end 13 | 14 | Bolt.ResultDescription do 15 | t("Use templates to simplify the process of adding similar records") 16 | end 17 | 18 | Bolt.ResultActions do 19 | Bolt.LinkButton href: new_template_path, color: :primary do 20 | t("New template") 21 | end 22 | end 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/components/categories/empty_state.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Categories 3 | class EmptyState < Base 4 | private 5 | 6 | def view_template 7 | Bolt.Result do 8 | Bolt.ResultImage src: image_path("categories-empty-state.svg") 9 | 10 | Bolt.ResultTitle do 11 | t("No categories yet!") 12 | end 13 | 14 | Bolt.ResultDescription do 15 | t("Keep your records organized by creating custom categories that fit your needs") 16 | end 17 | 18 | Bolt.ResultActions do 19 | Bolt.LinkButton href: new_category_path, color: :primary do 20 | t("New category") 21 | end 22 | end 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/components/accounts/empty_state.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Accounts 3 | class EmptyState < Base 4 | private 5 | 6 | def view_template 7 | Bolt.Result do 8 | Bolt.ResultImage src: image_path("accounts-empty-state.svg") 9 | 10 | Bolt.ResultTitle do 11 | t("You don't have any accounts yet!") 12 | end 13 | 14 | Bolt.ResultDescription do 15 | t("After creating an account, you will be able to start recording transactions.") 16 | end 17 | 18 | Bolt.ResultActions do 19 | Bolt.LinkButton href: new_account_path, color: :primary do 20 | t("New account") 21 | end 22 | end 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/controllers/accounts/records_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Accounts::RecordsControllerTest < ActionDispatch::IntegrationTest 4 | test "edit" do 5 | sign_in 6 | 7 | record = create(:transfer).expense_record 8 | 9 | get edit_account_record_path(record.account, record) 10 | 11 | assert_response :not_found 12 | end 13 | 14 | test "update" do 15 | sign_in 16 | 17 | record = create(:transfer).expense_record 18 | 19 | put account_record_path(record.account, record) 20 | 21 | assert_response :not_found 22 | end 23 | 24 | test "destroy" do 25 | sign_in 26 | 27 | record = create(:transfer).expense_record 28 | 29 | delete account_record_path(record.account, record) 30 | 31 | assert_response :not_found 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /bin/kamal: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # 4 | # This file was generated by Bundler. 5 | # 6 | # The application 'kamal' is installed as part of a gem, and 7 | # this file is here to facilitate running it. 8 | # 9 | 10 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 11 | 12 | bundle_binstub = File.expand_path("bundle", __dir__) 13 | 14 | if File.file?(bundle_binstub) 15 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 16 | load(bundle_binstub) 17 | else 18 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 19 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 20 | end 21 | end 22 | 23 | require "rubygems" 24 | require "bundler/setup" 25 | 26 | load Gem.bin_path("kamal", "kamal") 27 | -------------------------------------------------------------------------------- /app/components/bolt/form/collection_select.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class CollectionSelect < Base 3 | def initialize( 4 | form, 5 | attribute, 6 | collection, 7 | value_method, 8 | text_method, 9 | options = {}, 10 | ** 11 | ) 12 | @form = form 13 | @attribute = attribute 14 | @collection = collection 15 | @value_method = value_method 16 | @text_method = text_method 17 | @options = options 18 | 19 | super(**) 20 | end 21 | 22 | private 23 | 24 | def view_template 25 | @form.collection_select( 26 | @attribute, 27 | @collection, 28 | @value_method, 29 | @text_method, 30 | @options, 31 | class: "select w-full", 32 | **@attributes 33 | ) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/system/templates/destroy_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Templates 4 | class DestroySystemTest < ApplicationSystemTestCase 5 | test "success" do 6 | template = create(:template) 7 | 8 | visit_edit_template_page(template:) 9 | 10 | accept_confirm t("Are you sure you want to remove this template?") do 11 | click_link I18n.t("Remove") 12 | end 13 | 14 | assert_current_path templates_path 15 | assert_css ".alert-success", text: t("Template removed") 16 | refute_text template.title 17 | end 18 | 19 | private 20 | 21 | def visit_edit_template_page(template:) 22 | visit root_path(as: create(:user)) 23 | 24 | click_link t("Templates") 25 | 26 | click_link template.title, href: edit_template_path(template) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/javascript/controllers/index.js: -------------------------------------------------------------------------------- 1 | // This file is auto-generated by ./bin/rails stimulus:manifest:update 2 | // Run that command whenever you add a new controller or create them with 3 | // ./bin/rails generate stimulus controllerName 4 | 5 | import { application } from "./application" 6 | 7 | import AutoSubmitController from "./auto_submit_controller" 8 | application.register("auto-submit", AutoSubmitController) 9 | 10 | import Charts__SpendingBreakdownController from "./charts/spending_breakdown_controller" 11 | application.register("charts--spending-breakdown", Charts__SpendingBreakdownController) 12 | 13 | import MoneyFieldController from "./money_field_controller" 14 | application.register("money-field", MoneyFieldController) 15 | 16 | import SortableController from "./sortable_controller" 17 | application.register("sortable", SortableController) 18 | -------------------------------------------------------------------------------- /app/operations/insights/calculate_spending_breakdown.rb: -------------------------------------------------------------------------------- 1 | module Insights 2 | class CalculateSpendingBreakdown < ApplicationOperation 3 | prop :period, _Range?(_Union(Date, Time)), default: nil 4 | 5 | Result = Result.define(spending_breakdown: SpendingBreakdown) 6 | 7 | def call 8 | items = Category 9 | .joins(:records) 10 | .merge(Record.expense.without_transfers.on(@period)) 11 | .select(:title, :color, "sum(records.amount_cents) AS total") 12 | .group(:id) 13 | .order("sum(amount_cents) DESC") 14 | .map do 15 | SpendingBreakdown::Item[ 16 | label: it.title, 17 | color: it.color, 18 | total: Money.new(it.total) 19 | ] 20 | end 21 | 22 | Result[spending_breakdown: SpendingBreakdown[items:]] 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/views/accounts/new.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | module Accounts 3 | class New < Views::Base 4 | def initialize(account:) 5 | @account = account 6 | end 7 | 8 | private 9 | 10 | def view_template 11 | Layouts.Zen do 12 | Bolt.Page do 13 | Bolt.PageHeader sticky: false do 14 | Bolt.PageHeading do 15 | Bolt.PageTitle(drawer_toggle: false) do 16 | t("New account") 17 | end 18 | 19 | Bolt.PageSubtitle do 20 | t("Set up a new account") 21 | end 22 | end 23 | end 24 | 25 | Bolt.PageBody do 26 | Components::Accounts.Form(account: @account) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/components/records/empty_state.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Records 3 | class EmptyState < Base 4 | private 5 | 6 | def view_template 7 | Bolt.Result do 8 | Bolt.ResultImage src: image_path("records-empty-state.svg") 9 | 10 | Bolt.ResultTitle do 11 | t("This account doesn't have records yet") 12 | end 13 | 14 | Bolt.ResultDescription do 15 | t("Keep track of your account activity by adding transactions") 16 | end 17 | 18 | Bolt.ResultActions do 19 | Bolt.LinkButton( 20 | href: record_types_path(account_id: request.params[:account_id]), 21 | color: :primary 22 | ) do 23 | t("New record") 24 | end 25 | end 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/views/categories/new.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | module Categories 3 | class New < Views::Base 4 | def initialize(category:) 5 | @category = category 6 | end 7 | 8 | private 9 | 10 | def view_template 11 | Layouts.Zen do 12 | Bolt.Page do 13 | Bolt.PageHeader sticky: false do 14 | Bolt.PageHeading do 15 | Bolt.PageTitle(drawer_toggle: false) do 16 | t("New category") 17 | end 18 | 19 | Bolt.PageSubtitle do 20 | t("Set up a new category") 21 | end 22 | end 23 | end 24 | 25 | Bolt.PageBody do 26 | Components::Categories.Form(category: @category) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/components/bolt/currency_display/currency_display.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class CurrencyDisplay < Base 3 | SIZES = { 4 | sm: "text-sm", 5 | lg: "text-lg" 6 | }.freeze 7 | 8 | def initialize(amount:, size: :lg, color: nil, **) 9 | @amount = amount 10 | @color = color 11 | @size = size 12 | 13 | super(**) 14 | end 15 | 16 | private 17 | 18 | def view_template(&) 19 | span( 20 | class: ["text-lg font-semibold", color, SIZES[@size], @extra_classes], 21 | **@attributes 22 | ) do 23 | @amount.format(sign_positive: true) 24 | end 25 | end 26 | 27 | def color 28 | return @color if @color.present? 29 | 30 | if @amount.positive? 31 | "text-success" 32 | elsif @amount.negative? 33 | "text-error" 34 | else 35 | "text-current" 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/models/account.rb: -------------------------------------------------------------------------------- 1 | class Account < ApplicationRecord 2 | include Sortable 3 | 4 | has_many :records, dependent: :destroy 5 | has_many :templates, dependent: :destroy 6 | 7 | # This is necessary to allow destroying associated transfers 8 | has_many :transfers, ->(account) { 9 | unscope(:where) 10 | .where(from_account_id: account.id) 11 | .or(where(to_account_id: account.id)) 12 | }, dependent: :destroy 13 | 14 | scope :active, -> { where(archived: false) } 15 | 16 | validates :title, presence: true 17 | validates :color, presence: true 18 | validates :position, presence: true 19 | 20 | validates :title, length: {maximum: 40} 21 | 22 | validates :color, format: {with: Constants::COLOR_REGEX, allow_blank: true} 23 | 24 | def balance(on: Time.zone.today) 25 | records.on(..on).total + initial_amount 26 | end 27 | 28 | private 29 | 30 | def initial_amount = Money.new(initial_amount_cents) 31 | end 32 | -------------------------------------------------------------------------------- /test/operations/categories/create_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Categories 4 | class CreateTest < ActiveSupport::TestCase 5 | test "success" do 6 | attributes = {title: "My category", color: "#000123"} 7 | 8 | result = Category.stub :next_position, 543 do 9 | Categories::Create.call(attributes:) 10 | end 11 | 12 | assert_instance_of Categories::Create::Success, result 13 | assert result.category.persisted? 14 | assert_equal "My category", result.category.title 15 | assert_equal "#000123", result.category.color 16 | assert_equal 543, result.category.position 17 | end 18 | 19 | test "failure" do 20 | attributes = {title: nil} 21 | 22 | result = Categories::Create.call(attributes:) 23 | 24 | assert_instance_of Categories::Create::Failure, result 25 | refute result.category.persisted? 26 | assert result.category.errors.any? 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "private": true, 4 | "devDependencies": { 5 | "esbuild": "^0.25.9" 6 | }, 7 | "scripts": { 8 | "lint": "npx standard app/javascript", 9 | "build": "esbuild app/javascript/*.* --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets", 10 | "build:css": "npx @tailwindcss/cli -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css --minify" 11 | }, 12 | "dependencies": { 13 | "@hotwired/stimulus": "^3.2.2", 14 | "@hotwired/turbo-rails": "^8.0.16", 15 | "@rails/request.js": "^0.0.12", 16 | "@tailwindcss/cli": "^4.1.12", 17 | "chart.js": "^4.5.0", 18 | "daisyui": "^5.0.50", 19 | "postcss": "^8.5.6", 20 | "simple-mask-money": "^4.1.4", 21 | "sortablejs": "^1.15.6", 22 | "tailwindcss": "^4.1.12" 23 | }, 24 | "standard": { 25 | "ignore": [ 26 | "app/javascript/controllers/index.js" 27 | ] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/system/categories/destroy_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Categories 4 | class DestroySystemTest < ApplicationSystemTestCase 5 | test "success" do 6 | category = create(:category) 7 | 8 | visit_edit_category_page(category:) 9 | 10 | assert_field field(Category, :title), with: category.title 11 | 12 | accept_confirm t("Are you sure you want to remove this category? This will also permanently remove all associated records") do 13 | click_link t("Remove") 14 | end 15 | 16 | assert_current_path categories_path 17 | assert_css ".alert-success", text: t("Category removed") 18 | refute_text category.title 19 | end 20 | 21 | private 22 | 23 | def visit_edit_category_page(category:) 24 | visit root_path(as: create(:user)) 25 | 26 | click_link t("Categories") 27 | 28 | click_link category.title, href: edit_category_path(category) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/system/categories/list_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Categories 4 | class ListSystemTest < ApplicationSystemTestCase 5 | test "with categories" do 6 | food = create(:category, title: "Food") 7 | wage = create(:category, title: "Wage") 8 | 9 | visit_categories_page 10 | 11 | assert_css "h1", text: t("Categories") 12 | assert_css "li", text: food.title 13 | assert_css "li", text: wage.title 14 | assert_link t("New category"), href: new_category_path 15 | end 16 | 17 | test "empty state" do 18 | visit_categories_page 19 | 20 | assert_css "h1", text: t("Categories") 21 | assert_css "div", text: t("No categories yet!") 22 | assert_link t("New category"), href: new_category_path 23 | end 24 | 25 | private 26 | 27 | def visit_categories_page 28 | visit root_path(as: create(:user)) 29 | 30 | click_link t("Categories") 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # Temporary files generated by your text editor or operating system 4 | # belong in git's global ignore instead: 5 | # `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all environment files. 11 | /.env* 12 | 13 | # Ignore all logfiles and tempfiles. 14 | /log/* 15 | /tmp/* 16 | !/log/.keep 17 | !/tmp/.keep 18 | 19 | # Ignore pidfiles, but keep the directory. 20 | /tmp/pids/* 21 | !/tmp/pids/ 22 | !/tmp/pids/.keep 23 | 24 | # Ignore storage (uploaded files in development and any SQLite databases). 25 | /storage/* 26 | !/storage/.keep 27 | /tmp/storage/* 28 | !/tmp/storage/ 29 | !/tmp/storage/.keep 30 | 31 | /public/assets 32 | 33 | # Ignore master key for decrypting credentials and more. 34 | /config/master.key 35 | 36 | /app/assets/builds/* 37 | !/app/assets/builds/.keep 38 | 39 | /node_modules 40 | 41 | .DS_Store 42 | -------------------------------------------------------------------------------- /app/models/record.rb: -------------------------------------------------------------------------------- 1 | class Record < ApplicationRecord 2 | enum :group, {expense: -1, income: 1}, validate: true 3 | 4 | belongs_to :account 5 | belongs_to :category, optional: true 6 | belongs_to :transfer, optional: true 7 | 8 | scope :on, ->(period) { period ? where(occurred_on: period) : all } 9 | scope :without_transfers, -> { where(transfer_id: nil) } 10 | 11 | validates :category, presence: true, unless: :transfer_id? 12 | validates :category, absence: true, if: :transfer_id? 13 | validates :occurred_on, presence: true 14 | validates :note, presence: true, allow_blank: true 15 | 16 | validates :amount_cents, numericality: {greater_than: 0} 17 | 18 | validates :note, length: {maximum: 32} 19 | 20 | validates :occurred_on, 21 | comparison: {less_than_or_equal_to: -> { Time.zone.today }} 22 | 23 | def self.total = Money.new(sum('amount_cents * "group"')) 24 | 25 | def amount 26 | Money.new(amount_cents * self.class.groups[group]) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/views/templates/new.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | module Templates 3 | class New < Views::Base 4 | def initialize(template:, accounts:, categories:) 5 | @template = template 6 | @accounts = accounts 7 | @categories = categories 8 | end 9 | 10 | private 11 | 12 | def view_template 13 | Layouts.Zen do 14 | Bolt.Page do 15 | Bolt.PageHeader sticky: false do 16 | Bolt.PageHeading do 17 | Bolt.PageTitle(drawer_toggle: false) { t("New template") } 18 | Bolt.PageSubtitle { t("Create a new template") } 19 | end 20 | end 21 | 22 | Bolt.PageBody do 23 | Components::Templates.Form( 24 | template: @template, 25 | accounts: @accounts, 26 | categories: @categories 27 | ) 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/views/pwa/service-worker.js: -------------------------------------------------------------------------------- 1 | // Add a service worker for processing Web Push notifications: 2 | // 3 | // self.addEventListener("push", async (event) => { 4 | // const { title, options } = await event.data.json() 5 | // event.waitUntil(self.registration.showNotification(title, options)) 6 | // }) 7 | // 8 | // self.addEventListener("notificationclick", function(event) { 9 | // event.notification.close() 10 | // event.waitUntil( 11 | // clients.matchAll({ type: "window" }).then((clientList) => { 12 | // for (let i = 0; i < clientList.length; i++) { 13 | // let client = clientList[i] 14 | // let clientPath = (new URL(client.url)).pathname 15 | // 16 | // if (clientPath == event.notification.data.path && "focus" in client) { 17 | // return client.focus() 18 | // } 19 | // } 20 | // 21 | // if (clients.openWindow) { 22 | // return clients.openWindow(event.notification.data.path) 23 | // } 24 | // }) 25 | // ) 26 | // }) 27 | -------------------------------------------------------------------------------- /test/components/insights/spending_breakdown_card_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Components::Insights 4 | class SpendingBreakdownCardTest < ComponentTestCase 5 | test "with data" do 6 | spending_breakdown = SpendingBreakdown[ 7 | items: [ 8 | SpendingBreakdown::Item[ 9 | label: "Food", 10 | color: "#ff000", 11 | total: Money.new(100_00) 12 | ] 13 | ] 14 | ] 15 | 16 | render Components::Insights::SpendingBreakdownCard.new(spending_breakdown:) 17 | 18 | assert_text t("Spending breakdown") 19 | refute_text t("No expenses for the chosen period") 20 | end 21 | 22 | test "without data" do 23 | spending_breakdown = SpendingBreakdown[items: []] 24 | 25 | render Components::Insights::SpendingBreakdownCard.new(spending_breakdown:) 26 | 27 | assert_text t("Spending breakdown") 28 | assert_text t("No expenses for the chosen period") 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/system/records/destroy_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Records 4 | class DestroySystemTest < ApplicationSystemTestCase 5 | test "success" do 6 | record = create(:record) 7 | 8 | visit_edit_record_page(record) 9 | 10 | accept_confirm t("Are you sure you want to remove this record?") do 11 | click_link t("Remove") 12 | end 13 | 14 | assert_current_path account_records_path(record.account) 15 | assert_css ".alert-success", text: t("Record removed") 16 | refute_text record.amount.format 17 | end 18 | 19 | private 20 | 21 | def visit_edit_record_page(record) 22 | account = record.account 23 | 24 | visit root_path(as: create(:user)) 25 | 26 | click_link t("Accounts") 27 | 28 | click_link account.title, href: account_records_path(account) 29 | 30 | click_link record.category.title, 31 | href: edit_account_record_path(account, record) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/javascript/controllers/sortable_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | import Sortable from 'sortablejs' 3 | import { patch } from '@rails/request.js' 4 | 5 | // Connects to data-controller="sortable" 6 | export default class extends Controller { 7 | static values = { endpoint: String } 8 | 9 | connect () { 10 | this.sortable = Sortable.create(this.element, { 11 | handle: '.sortable-handle', 12 | direction: 'vertical', 13 | ghostClass: 'bg-base-300', 14 | onEnd: this.onEnd.bind(this) 15 | }) 16 | } 17 | 18 | async onEnd (event) { 19 | const items = this.sortable.toArray() 20 | const data = items.map((item, index) => { 21 | return { id: item, position: index } 22 | }) 23 | const response = await patch(this.endpointValue, { body: { items: data } }) 24 | 25 | if (!response.ok) { 26 | event.to.removeChild(event.item) 27 | event.to.insertBefore(event.item, event.to.children[event.oldIndex]) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/components/accounts/list_item_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Components::Accounts 4 | class ListItemTest < ComponentTestCase 5 | test "title" do 6 | account = Account.new(id: 1, title: "My account") 7 | 8 | render Components::Accounts::ListItem.new(account:) 9 | 10 | assert_content "My account" 11 | end 12 | 13 | test "amount" do 14 | account = Account.new(id: 1) 15 | positive_balance = Money.new(10_00) 16 | negative_balance = Money.new(-10_00) 17 | account.stub :balance, positive_balance do 18 | render Components::Accounts::ListItem.new(account:) 19 | 20 | assert_css "span.text-success", 21 | text: positive_balance.format(sign_positive: true) 22 | end 23 | 24 | account.stub :balance, negative_balance do 25 | render Components::Accounts::ListItem.new(account:) 26 | 27 | assert_css "span.text-error", 28 | text: negative_balance.format 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/operations/records/create_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Records 4 | class CreateTest < ActiveSupport::TestCase 5 | test "success" do 6 | attributes = { 7 | group: "expense", 8 | account: create(:account), 9 | category: create(:category), 10 | amount_cents: 200, 11 | occurred_on: Date.parse("2025-02-03") 12 | } 13 | 14 | result = Records::Create.call(attributes:) 15 | 16 | assert_instance_of Records::Create::Success, result 17 | assert result.record.persisted? 18 | 19 | attributes.each do |attribute, value| 20 | assert_equal value, result.record.public_send(attribute) 21 | end 22 | end 23 | 24 | test "failure" do 25 | attributes = {amount_cents: nil} 26 | 27 | result = Records::Create.call(attributes:) 28 | 29 | assert_instance_of Records::Create::Failure, result 30 | refute result.record.persisted? 31 | assert result.record.errors.any? 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/operations/accounts/create_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Accounts 4 | class CreateTest < ActiveSupport::TestCase 5 | test "success" do 6 | attributes = { 7 | title: "My account", 8 | color: "#000123", 9 | initial_amount_cents: 20_00 10 | } 11 | 12 | result = Account.stub :next_position, 123 do 13 | Accounts::Create.call(attributes:) 14 | end 15 | 16 | assert_instance_of Accounts::Create::Success, result 17 | assert result.account.persisted? 18 | assert_equal "My account", result.account.title 19 | assert_equal "#000123", result.account.color 20 | assert_equal 123, result.account.position 21 | end 22 | 23 | test "failure" do 24 | attributes = {title: nil} 25 | 26 | result = Accounts::Create.call(attributes:) 27 | 28 | assert_instance_of Accounts::Create::Failure, result 29 | refute result.account.persisted? 30 | assert result.account.errors.any? 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/system/accounts/destroy_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Accounts 4 | class DestroySystemTest < ApplicationSystemTestCase 5 | test "success" do 6 | account = create(:account) 7 | 8 | visit_edit_account_page(account:) 9 | 10 | assert_field field(Account, :title), with: account.title 11 | 12 | accept_confirm t("Are you sure you want to remove this account? This will also permanently remove all associated records") do 13 | click_link t("Remove") 14 | end 15 | 16 | assert_current_path accounts_path 17 | assert_css ".alert-success", text: t("Account removed") 18 | refute_text account.title 19 | end 20 | 21 | private 22 | 23 | def visit_edit_account_page(account:) 24 | visit root_path(as: create(:user)) 25 | 26 | click_link t("Accounts") 27 | 28 | click_link account.title, href: account_records_path(account) 29 | 30 | click_link t("Edit"), href: edit_account_path(account) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/operations/records/update_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Records 4 | class UpdateTest < ActiveSupport::TestCase 5 | test "success" do 6 | id = create(:record).id 7 | attributes = {amount_cents: 888} 8 | 9 | result = Records::Update.call(id:, attributes:) 10 | 11 | assert_instance_of Records::Update::Success, result 12 | assert result.record.valid? 13 | assert_equal 888, result.record.amount_cents 14 | end 15 | 16 | test "failure" do 17 | id = create(:record).id 18 | attributes = {amount_cents: nil} 19 | 20 | result = Records::Update.call(id:, attributes:) 21 | 22 | assert_instance_of Records::Update::Failure, result 23 | refute result.record.valid? 24 | assert result.record.errors.any? 25 | end 26 | 27 | test "not found" do 28 | id = "not-found" 29 | attributes = {amount_cents: 888} 30 | 31 | assert_raises ActiveRecord::RecordNotFound do 32 | Records::Update.call(id:, attributes:) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/operations/templates/update_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Templates 4 | class UpdateTest < ActiveSupport::TestCase 5 | test "success" do 6 | id = create(:template).id 7 | attributes = {amount_cents: 888} 8 | 9 | result = Templates::Update.call(id:, attributes:) 10 | 11 | assert_instance_of Templates::Update::Success, result 12 | assert result.template.valid? 13 | assert_equal 888, result.template.amount_cents 14 | end 15 | 16 | test "failure" do 17 | id = create(:template).id 18 | attributes = {title: nil} 19 | 20 | result = Templates::Update.call(id:, attributes:) 21 | 22 | assert_instance_of Templates::Update::Failure, result 23 | refute result.template.valid? 24 | assert result.template.errors.any? 25 | end 26 | 27 | test "not found" do 28 | id = "not-found" 29 | attributes = {amount_cents: 888} 30 | 31 | assert_raises ActiveRecord::RecordNotFound do 32 | Templates::Update.call(id:, attributes:) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /.kamal/secrets: -------------------------------------------------------------------------------- 1 | # Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, 2 | # and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either 3 | # password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. 4 | 5 | # Example of extracting secrets from 1password (or another compatible pw manager) 6 | # SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) 7 | # KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) 8 | # RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) 9 | 10 | # Use a GITHUB_TOKEN if private repositories are needed for the image 11 | # GITHUB_TOKEN=$(gh config get -h github.com oauth_token) 12 | 13 | # Grab the registry password from ENV 14 | KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD 15 | 16 | # Improve security by using a password manager. Never check config/master.key into git! 17 | RAILS_MASTER_KEY=$(cat config/master.key) 18 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. 2 | 3 | # Ignore git directory. 4 | /.git/ 5 | /.gitignore 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all environment files. 11 | /.env* 12 | 13 | # Ignore all default key files. 14 | /config/master.key 15 | /config/credentials/*.key 16 | 17 | # Ignore all logfiles and tempfiles. 18 | /log/* 19 | /tmp/* 20 | !/log/.keep 21 | !/tmp/.keep 22 | 23 | # Ignore pidfiles, but keep the directory. 24 | /tmp/pids/* 25 | !/tmp/pids/.keep 26 | 27 | # Ignore storage (uploaded files in development and any SQLite databases). 28 | /storage/* 29 | !/storage/.keep 30 | /tmp/storage/* 31 | !/tmp/storage/.keep 32 | 33 | # Ignore assets. 34 | /node_modules/ 35 | /app/assets/builds/* 36 | !/app/assets/builds/.keep 37 | /public/assets 38 | 39 | # Ignore CI service files. 40 | /.github 41 | 42 | # Ignore Kamal files. 43 | /config/deploy*.yml 44 | /.kamal 45 | 46 | # Ignore development files 47 | /.devcontainer 48 | 49 | # Ignore Docker-related files 50 | /.dockerignore 51 | /Dockerfile* 52 | -------------------------------------------------------------------------------- /app/components/categories/list_item.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Categories 3 | class ListItem < Base 4 | def initialize(category:) 5 | @category = category 6 | end 7 | 8 | private 9 | 10 | def view_template 11 | Bolt.ListItem padding: nil, data: {id: @category.id} do 12 | div class: "flex flex-row gap-2 items-center px-4" do 13 | div class: "cursor-move sortable-handle" do 14 | Lucide.GripVertical class: "size-6 stroke-base-content/60" 15 | end 16 | 17 | a( 18 | href: edit_category_path(@category), 19 | class: "flex flex-row gap-2 items-center w-full py-4" 20 | ) do 21 | Bolt.Dot( 22 | size: "size-6", 23 | color: @category.color, 24 | title: @category.title 25 | ) 26 | 27 | span class: "text-lg font-semibold" do 28 | @category.title 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/components/bolt/form/form_builder.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class FormBuilder < Phlex::HTML 3 | def initialize(form:) 4 | @form = form 5 | end 6 | 7 | def label(...) = Bolt.Label(@form, ...) 8 | 9 | def text_field(...) = Bolt.TextField(@form, ...) 10 | 11 | def money_field(...) = Bolt.MoneyField(@form, ...) 12 | 13 | def email_field(...) = Bolt.EmailField(@form, ...) 14 | 15 | def password_field(...) = Bolt.PasswordField(@form, ...) 16 | 17 | def date_field(...) = Bolt.DateField(@form, ...) 18 | 19 | def collection_select(...) = Bolt.CollectionSelect(@form, ...) 20 | 21 | def color_picker(...) = Bolt.ColorPicker(@form, ...) 22 | 23 | def toggle(...) = Bolt.Toggle(@form, ...) 24 | 25 | def radio_button(...) = Bolt.RadioButton(@form, ...) 26 | 27 | def field_wrapper(...) = Bolt.FieldWrapper(...) 28 | 29 | def error_summary = Bolt.ErrorSummary(@form) 30 | 31 | def field_error(...) = Bolt.FieldError(@form, ...) 32 | 33 | def submit(...) = Bolt.Submit(@form, ...) 34 | 35 | private 36 | 37 | def view_template(&) = yield 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/operations/sortables/reorder_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Sortables 4 | class ReorderTest < ActiveSupport::TestCase 5 | test "success" do 6 | account_a = create(:account, position: 2) 7 | account_b = create(:account, position: 5) 8 | 9 | result = Reorder.call(model_class: Account, ordering: [ 10 | {id: account_a.id, position: 90}, 11 | {id: account_b.id, position: 2953} 12 | ]) 13 | 14 | assert_kind_of Reorder::Success, result 15 | assert_equal 90, account_a.reload.position 16 | assert_equal 2953, account_b.reload.position 17 | end 18 | 19 | test "failure" do 20 | account_a = create(:account, position: 2) 21 | account_b = create(:account, position: 5) 22 | 23 | result = Reorder.call(model_class: Account, ordering: [ 24 | {id: account_a.id, position: 90}, 25 | {id: account_b.id, position: nil} 26 | ]) 27 | 28 | assert_kind_of Reorder::Failure, result 29 | assert_equal 2, account_a.reload.position 30 | assert_equal 5, account_b.reload.position 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | root "insights#index" 3 | 4 | resources :accounts, except: %i[show] do 5 | scope module: :accounts do 6 | resources :records, except: %i[show] 7 | end 8 | end 9 | resources :categories, except: %i[show] 10 | resources :insights, only: %i[index] 11 | resources :ordering, only: %i[update], param: :resource 12 | resources :record_types, only: %i[index] 13 | resources :templates, except: %i[show] 14 | resources :transfers, except: %i[index show] 15 | 16 | # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. 17 | # Can be used by load balancers and uptime monitors to verify that the app is live. 18 | get "up", to: "rails/health#show", as: :rails_health_check 19 | 20 | # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) 21 | # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest 22 | # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker 23 | 24 | # Defines the root path route ("/") 25 | # root "posts#index" 26 | end 27 | -------------------------------------------------------------------------------- /app/views/transfers/new.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | module Transfers 3 | class New < Views::Base 4 | def initialize(transfer:, accounts:) 5 | @transfer = transfer 6 | @accounts = accounts 7 | end 8 | 9 | private 10 | 11 | def view_template 12 | Layouts.Zen do 13 | Bolt.Page do 14 | Bolt.PageHeader sticky: false do 15 | Bolt.PageHeading do 16 | Bolt.PageTitle(drawer_toggle: false) do 17 | t("New transfer") 18 | end 19 | 20 | Bolt.PageSubtitle do 21 | t("Move money between accounts") 22 | end 23 | end 24 | end 25 | 26 | Bolt.PageBody do 27 | Components::Transfers.Form( 28 | transfer: @transfer, 29 | accounts: @accounts, 30 | cancel_href: record_types_path( 31 | account_id: request.params[:account_id] 32 | ) 33 | ) 34 | end 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/components/bolt/form/money_field.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class MoneyField < Base 3 | def initialize(form, attribute, allow_negative: false, **) 4 | @form = form 5 | @attribute = attribute 6 | @allow_negative = allow_negative 7 | 8 | super(**) 9 | end 10 | 11 | private 12 | 13 | def view_template 14 | Bolt.TextField @form, @attribute, 15 | class: "input w-full", 16 | data: { 17 | controller: "money-field", 18 | **money_field_settings 19 | }, 20 | **@attributes 21 | end 22 | 23 | def money_field_settings 24 | default_currency = Money.default_currency 25 | { 26 | money_field_allow_negative_value: @allow_negative.to_s, 27 | money_field_symbol_value: default_currency.symbol, 28 | money_field_symbol_first_value: default_currency.symbol_first?, 29 | money_field_decimal_mark_value: default_currency.decimal_mark, 30 | money_field_thousands_separator_value: default_currency.thousands_separator, 31 | money_field_exponent_value: default_currency.exponent 32 | } 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/system/transfers/destroy_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Transfers 4 | class DestroySystemTest < ApplicationSystemTestCase 5 | test "success" do 6 | transfer = create(:transfer) 7 | 8 | visit_edit_transfer_page(transfer) 9 | 10 | accept_confirm t("Are you sure you want to remove this transfer?") do 11 | click_link I18n.t("Remove") 12 | end 13 | 14 | assert_current_path account_records_path(transfer.to_account) 15 | assert_css ".alert-success", text: t("Transfer removed") 16 | refute_link t("Transfer"), 17 | href: edit_transfer_path(transfer, account_id: transfer.to_account.id) 18 | end 19 | 20 | private 21 | 22 | def visit_edit_transfer_page(transfer) 23 | account = transfer.to_account 24 | visit root_path(as: create(:user)) 25 | 26 | click_link t("Accounts") 27 | 28 | click_link account.title, href: account_records_path(account) 29 | 30 | click_link transfer.income_record.amount.format(sign_positive: true), 31 | href: edit_transfer_path(transfer, account_id: account.id) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/operations/templates/create_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Templates 4 | class CreateTest < ActiveSupport::TestCase 5 | test "success" do 6 | attributes = { 7 | title: "My template", 8 | group: "expense", 9 | account: create(:account), 10 | category: create(:category), 11 | amount_cents: 200, 12 | note: "Testing" 13 | } 14 | 15 | result = Template.stub :next_position, 812 do 16 | Templates::Create.call(attributes:) 17 | end 18 | 19 | assert_instance_of Templates::Create::Success, result 20 | assert result.template.persisted? 21 | 22 | attributes.each do |attribute, value| 23 | assert_equal value, result.template.public_send(attribute) 24 | end 25 | assert_equal 812, result.template.position 26 | end 27 | 28 | test "failure" do 29 | attributes = {title: nil} 30 | 31 | result = Templates::Create.call(attributes:) 32 | 33 | assert_instance_of Templates::Create::Failure, result 34 | refute result.template.persisted? 35 | assert result.template.errors.any? 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/operations/accounts/update_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Accounts 4 | class UpdateTest < ActiveSupport::TestCase 5 | test "success" do 6 | id = create(:account).id 7 | attributes = {title: "Updated title", color: "#987543"} 8 | 9 | result = Accounts::Update.call(id:, attributes:) 10 | 11 | assert_instance_of Accounts::Update::Success, result 12 | assert result.account.valid? 13 | assert_equal "Updated title", result.account.title 14 | assert_equal "#987543", result.account.color 15 | end 16 | 17 | test "failure" do 18 | id = create(:account).id 19 | attributes = {title: nil} 20 | 21 | result = Accounts::Update.call(id:, attributes:) 22 | 23 | assert_instance_of Accounts::Update::Failure, result 24 | refute result.account.valid? 25 | assert result.account.errors.any? 26 | end 27 | 28 | test "not found" do 29 | id = "not-found" 30 | attributes = {title: "Updated title", color: "#987543"} 31 | 32 | assert_raises ActiveRecord::RecordNotFound do 33 | Accounts::Update.call(id:, attributes:) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/system/accounts/list_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Accounts 4 | class ListSystemTest < ApplicationSystemTestCase 5 | test "with accounts" do 6 | account_a = create(:account, initial_amount_cents: 50_00) 7 | account_b = create(:account, initial_amount_cents: 1_00) 8 | 9 | visit_accounts_page 10 | 11 | assert_css "h1", text: t("Accounts") 12 | assert_css "li", text: account_a.title 13 | assert_css "li", text: account_a.balance.format(sign_posistive: true) 14 | assert_css "li", text: account_b.title 15 | assert_css "li", text: account_b.balance.format(sign_posistive: true) 16 | assert_link t("New account"), href: new_account_path 17 | end 18 | 19 | test "empty state" do 20 | visit_accounts_page 21 | 22 | assert_css "h1", text: t("Accounts") 23 | assert_css "div", text: t("You don't have any accounts yet!") 24 | assert_link t("New account"), href: new_account_path 25 | end 26 | 27 | private 28 | 29 | def visit_accounts_page 30 | visit root_path(as: create(:user)) 31 | 32 | click_link t("Accounts") 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/components/bolt/form/error_summary.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class ErrorSummary < Base 3 | WRAPPER_CLASSES = "flex flex-col gap-2 rounded-box border border-dashed " \ 4 | "border-error bg-base-100 p-4 text-error shadow-sm" 5 | 6 | def initialize(form, **) 7 | @form = form 8 | 9 | super(**) 10 | end 11 | 12 | private 13 | 14 | def render? = record.errors.any? 15 | 16 | def view_template(&) 17 | div class: WRAPPER_CLASSES, data: {turbo_temporary: true} do 18 | div class: "flex flex-row items-center gap-2" do 19 | Lucide.CircleX 20 | span(class: "font-bold") do 21 | t("Something’s not quite right") 22 | end 23 | end 24 | 25 | ul class: "list-inside list-disc pl-8 text-sm" do 26 | record.errors.to_hash.each do |attribute, messages| 27 | li { build_error_message(attribute, messages) } 28 | end 29 | end 30 | end 31 | end 32 | 33 | def record = @form.object 34 | 35 | def build_error_message(attribute, messages) 36 | "#{record.class.human_attribute_name(attribute)} #{messages.to_sentence}" 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025-present Gunbolt, stephann & pikoin contributors. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/components/bolt/pagination/pagination.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class Pagination < Base 3 | def initialize(pagy:, **) 4 | @pagy = pagy 5 | 6 | super(**) 7 | end 8 | 9 | private 10 | 11 | def view_template 12 | div class: "flex flex-row gap-2 justify-between" do 13 | if @pagy.prev.present? 14 | link_to({page: @pagy.prev}, class: "btn btn-ghost") do 15 | Lucide.ArrowLeft(class: "size-4") 16 | plain t("Previous") 17 | end 18 | else 19 | button class: "btn btn-ghost cursor-not-allowed", disabled: true do 20 | Lucide.ArrowLeft(class: "size-4") 21 | plain t("Previous") 22 | end 23 | end 24 | 25 | if @pagy.next.present? 26 | link_to({page: @pagy.next}, class: "btn btn-ghost") do 27 | plain t("Next") 28 | Lucide.ArrowRight(class: "size-4") 29 | end 30 | else 31 | button class: "btn btn-ghost cursor-not-allowed", disabled: true do 32 | plain t("Next") 33 | Lucide.ArrowRight(class: "size-4") 34 | end 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/operations/categories/update_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Categories 4 | class UpdateTest < ActiveSupport::TestCase 5 | test "success" do 6 | id = create(:category).id 7 | attributes = {title: "Updated title", color: "#987543"} 8 | 9 | result = Categories::Update.call(id:, attributes:) 10 | 11 | assert_instance_of Categories::Update::Success, result 12 | assert result.category.valid? 13 | assert_equal "Updated title", result.category.title 14 | assert_equal "#987543", result.category.color 15 | end 16 | 17 | test "failure" do 18 | id = create(:category).id 19 | attributes = {title: nil} 20 | 21 | result = Categories::Update.call(id:, attributes:) 22 | 23 | assert_instance_of Categories::Update::Failure, result 24 | refute result.category.valid? 25 | assert result.category.errors.any? 26 | end 27 | 28 | test "not found" do 29 | id = "not-found" 30 | attributes = {title: "Updated title", color: "#987543"} 31 | 32 | assert_raises ActiveRecord::RecordNotFound do 33 | Categories::Update.call(id:, attributes:) 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles. 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /test/system/templates/list_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Templates 4 | class ListSystemTest < ApplicationSystemTestCase 5 | test "with templates" do 6 | template_a = create(:template) 7 | template_b = create(:template) 8 | 9 | visit_templates_page 10 | 11 | assert_css "h1", text: t("Templates") 12 | assert_css "li", text: template_a.title 13 | assert_css "li", text: template_a.category.title 14 | assert_css "li", text: template_a.account.title 15 | assert_css "li", text: template_b.title 16 | assert_css "li", text: template_b.category.title 17 | assert_css "li", text: template_b.account.title 18 | assert_link t("New template"), href: new_template_path 19 | end 20 | 21 | test "empty state" do 22 | visit_templates_page 23 | 24 | assert_css "h1", text: t("Templates") 25 | assert_css "div", text: t("You haven’t saved any templates") 26 | assert_link t("New template"), href: new_template_path 27 | end 28 | 29 | private 30 | 31 | def visit_templates_page 32 | visit root_path(as: create(:user)) 33 | 34 | click_link t("Templates") 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/components/templates/list_item_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Components::Templates 4 | class ListItemTest < ComponentTestCase 5 | test "content" do 6 | template = create(:template, note: "Rebound") 7 | 8 | render Components::Templates::ListItem.new(template:) 9 | 10 | assert_content template.title 11 | assert_content template.category.title 12 | assert_content template.account.title 13 | assert_content template.note 14 | end 15 | 16 | test "amount" do 17 | template = create(:template, :expense, amount_cents: 2000) 18 | render Components::Templates::ListItem.new(template:) 19 | 20 | assert_css "span.text-error", text: template.amount.format 21 | 22 | template = create(:template, :income, amount_cents: 40000) 23 | 24 | render Components::Templates::ListItem.new(template:) 25 | 26 | assert_css "span.text-success", 27 | text: template.amount.format(sign_positive: true) 28 | 29 | template = create(:template, :expense, amount_cents: 0) 30 | 31 | render Components::Templates::ListItem.new(template:) 32 | 33 | assert_css "span.text-current", text: template.amount.format 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/operations/transfers/update_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Transfers 4 | class UpdateTest < ActiveSupport::TestCase 5 | test "success" do 6 | id = create(:transfer).id 7 | attributes = {amount_cents: 888} 8 | 9 | result = Transfers::Update.call(id:, attributes:) 10 | 11 | assert_instance_of Transfers::Update::Success, result 12 | assert result.transfer.valid? 13 | assert_equal 888, result.transfer.amount_cents 14 | assert_equal 888, result.transfer.income_record.amount_cents 15 | assert_equal 888, result.transfer.expense_record.amount_cents 16 | end 17 | 18 | test "failure" do 19 | id = create(:transfer).id 20 | attributes = {amount_cents: nil} 21 | 22 | result = Transfers::Update.call(id:, attributes:) 23 | 24 | assert_instance_of Transfers::Update::Failure, result 25 | refute result.transfer.valid? 26 | assert result.transfer.errors.any? 27 | end 28 | 29 | test "not found" do 30 | id = "not-found" 31 | attributes = {amount_cents: 888} 32 | 33 | assert_raises ActiveRecord::RecordNotFound do 34 | Transfers::Update.call(id:, attributes:) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /app/components/categories/form.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Categories 3 | class Form < Base 4 | def initialize(category:) 5 | @category = category 6 | end 7 | 8 | private 9 | 10 | def view_template 11 | Bolt.Form model: @category do |form| 12 | Bolt.Stack do 13 | form.error_summary 14 | 15 | Bolt.Panel do 16 | Bolt.Stack do 17 | form.field_wrapper do 18 | form.label :title 19 | form.text_field :title 20 | form.field_error :title 21 | end 22 | 23 | form.field_wrapper do 24 | form.label :color 25 | form.color_picker :color 26 | form.field_error :color 27 | end 28 | end 29 | end 30 | 31 | Bolt.Stack gap: :sm do 32 | form.submit 33 | 34 | Bolt.LinkButton href: categories_path, ghost: true do 35 | Lucide.ArrowLeft(class: "size-5") 36 | plain t("Cancel") 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/views/templates/index.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | module Templates 3 | class Index < Views::Base 4 | def initialize(templates:) 5 | @templates = templates 6 | end 7 | 8 | private 9 | 10 | def view_template 11 | Layouts.Main do 12 | Bolt.Page do 13 | Bolt.PageHeader do 14 | Bolt.PageHeading do 15 | Bolt.PageTitle { t("Templates") } 16 | Bolt.PageSubtitle { t("Templates help pre-fill common records info") } 17 | end 18 | 19 | if @templates.any? 20 | Bolt.PageActions do 21 | Bolt.LinkButton href: new_template_path, color: :primary do 22 | Lucide.Plus class: "size-4" 23 | plain t("New template") 24 | end 25 | end 26 | end 27 | end 28 | 29 | Bolt.PageBody do 30 | if @templates.any? 31 | Components::Templates.List(templates: @templates) 32 | else 33 | Components::Templates.EmptyState 34 | end 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | APP_ROOT = File.expand_path("..", __dir__) 5 | 6 | def system!(*args) 7 | system(*args, exception: true) 8 | end 9 | 10 | FileUtils.chdir APP_ROOT do 11 | # This script is a way to set up or update your development environment automatically. 12 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 13 | # Add necessary setup steps to this file. 14 | 15 | puts "== Installing dependencies ==" 16 | system("bundle check") || system!("bundle install") 17 | 18 | # Install JavaScript dependencies 19 | system("yarn install --check-files") 20 | 21 | # puts "\n== Copying sample files ==" 22 | # unless File.exist?("config/database.yml") 23 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 24 | # end 25 | 26 | puts "\n== Preparing database ==" 27 | system! "bin/rails db:prepare" 28 | 29 | puts "\n== Removing old logs and tempfiles ==" 30 | system! "bin/rails log:clear tmp:clear" 31 | 32 | unless ARGV.include?("--skip-server") 33 | puts "\n== Starting development server ==" 34 | STDOUT.flush # flush the output before exec(2) so that it displays 35 | exec "bin/dev" 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Pikoin 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 8.0 13 | 14 | # Please, add to the `ignore` list any other `lib` subdirectories that do 15 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 16 | # Common ones are `templates`, `generators`, or `middleware`, for example. 17 | config.autoload_lib(ignore: %w[assets tasks]) 18 | 19 | # Configuration for the application, engines, and railties goes here. 20 | # 21 | # These settings can be overridden in specific environments using the files 22 | # in config/environments, which are processed later. 23 | # 24 | # config.time_zone = "Central Time (US & Canada)" 25 | # config.eager_load_paths << Rails.root.join("extras") 26 | 27 | config.language = ENV.fetch("PIKOIN_LANG", "pt-BR") 28 | config.default_currency = ENV.fetch("PIKOIN_CURRENCY", "BRL") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/javascript/controllers/charts/spending_breakdown_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | import { Chart, DoughnutController, ArcElement, Tooltip } from 'chart.js' 3 | 4 | Chart.register(DoughnutController, ArcElement, Tooltip) 5 | 6 | // Connects to data-controller="charts--spending-breakdown" 7 | export default class extends Controller { 8 | static values = { 9 | data: Array 10 | } 11 | 12 | connect () { 13 | this.chart = new Chart(this.element.getContext('2d'), { 14 | type: 'doughnut', 15 | data: { 16 | labels: this.dataValue.map((i) => i.label), 17 | datasets: [ 18 | { 19 | data: this.dataValue.map((i) => i.value), 20 | backgroundColor: this.dataValue.map((i) => i.color), 21 | formatted: this.dataValue.map((i) => i.formatted) 22 | } 23 | ] 24 | }, 25 | options: { 26 | plugins: { 27 | tooltip: { 28 | callbacks: { 29 | label: (context) => context.dataset.formatted[context.dataIndex] 30 | } 31 | } 32 | } 33 | } 34 | }) 35 | } 36 | 37 | disconnect () { 38 | this.chart.destroy() 39 | this.chart = undefined 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-connect.sample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # A sample pre-connect check 4 | # 5 | # Warms DNS before connecting to hosts in parallel 6 | # 7 | # These environment variables are available: 8 | # KAMAL_RECORDED_AT 9 | # KAMAL_PERFORMER 10 | # KAMAL_VERSION 11 | # KAMAL_HOSTS 12 | # KAMAL_ROLE (if set) 13 | # KAMAL_DESTINATION (if set) 14 | # KAMAL_RUNTIME 15 | 16 | hosts = ENV["KAMAL_HOSTS"].split(",") 17 | results = nil 18 | max = 3 19 | 20 | elapsed = Benchmark.realtime do 21 | results = hosts.map do |host| 22 | Thread.new do 23 | tries = 1 24 | 25 | begin 26 | Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) 27 | rescue SocketError 28 | if tries < max 29 | puts "Retrying DNS warmup: #{host}" 30 | tries += 1 31 | sleep rand 32 | retry 33 | else 34 | puts "DNS warmup failed: #{host}" 35 | host 36 | end 37 | end 38 | 39 | tries 40 | end 41 | end.map(&:value) 42 | end 43 | 44 | retries = results.sum - hosts.size 45 | nopes = results.count { |r| r == max } 46 | 47 | puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] 48 | -------------------------------------------------------------------------------- /app/operations/records/build_from_params.rb: -------------------------------------------------------------------------------- 1 | module Records 2 | class BuildFromParams < ApplicationOperation 3 | prop :account, Account 4 | prop :params, Hash 5 | 6 | Result = Result.define(record: Record) 7 | 8 | def call 9 | record = case @params 10 | in group: String => group 11 | build_from_group(group) 12 | in template: String => template_id 13 | build_from_template(template_id) 14 | else 15 | Record.new 16 | end 17 | 18 | record.account ||= @account 19 | record.occurred_on = Time.zone.today 20 | 21 | Result[record:] 22 | end 23 | 24 | private 25 | 26 | def build_from_group(group) 27 | if group == "expense" 28 | Record.new(group: :expense) 29 | elsif group == "income" 30 | Record.new(group: :income) 31 | end 32 | end 33 | 34 | def build_from_template(template_id) 35 | template = Template.find(template_id) 36 | 37 | Record.new( 38 | group: template.group, 39 | account_id: template.account_id, 40 | category_id: template.category_id, 41 | amount_cents: template.amount_cents, 42 | note: template.note.presence || template.title 43 | ) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/system/accounts/reorder_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Accounts 4 | class ReorderSystemTest < ApplicationSystemTestCase 5 | test "reorder" do 6 | account_a = create(:account, position: 1) 7 | account_b = create(:account, position: 2) 8 | account_c = create(:account, position: 3) 9 | 10 | visit_accounts_page 11 | 12 | row_a = page.find("[data-id='#{account_a.id}']") 13 | row_b = page.find("[data-id='#{account_b.id}']") 14 | row_c = page.find("[data-id='#{account_c.id}']") 15 | 16 | row_c.find(".sortable-handle").drag_to(row_a.find(".sortable-handle")) 17 | row_a.find(".sortable-handle").drag_to(row_b.find(".sortable-handle")) 18 | 19 | row_1, row_2, row_3 = page.all("[data-id]") 20 | 21 | assert row_1.has_text?(account_c.title) 22 | assert row_2.has_text?(account_a.title) 23 | assert row_3.has_text?(account_b.title) 24 | assert_equal 0, account_c.reload.position 25 | assert_equal 1, account_a.reload.position 26 | assert_equal 2, account_b.reload.position 27 | end 28 | 29 | private 30 | 31 | def visit_accounts_page 32 | visit root_path(as: create(:user)) 33 | 34 | click_link t("Accounts") 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: storage/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: storage/test.sqlite3 22 | 23 | # Store production database in the storage/ directory, which by default 24 | # is mounted as a persistent Docker volume in config/deploy.yml. 25 | production: 26 | primary: 27 | <<: *default 28 | database: storage/production.sqlite3 29 | cache: 30 | <<: *default 31 | database: storage/production_cache.sqlite3 32 | migrations_paths: db/cache_migrate 33 | queue: 34 | <<: *default 35 | database: storage/production_queue.sqlite3 36 | migrations_paths: db/queue_migrate 37 | cable: 38 | <<: *default 39 | database: storage/production_cable.sqlite3 40 | migrations_paths: db/cable_migrate 41 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name-<%= Rails.env %> 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /test/system/templates/reorder_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Templates 4 | class ReorderSystemTest < ApplicationSystemTestCase 5 | test "reorder" do 6 | template_a = create(:template, position: 1) 7 | template_b = create(:template, position: 2) 8 | template_c = create(:template, position: 3) 9 | 10 | visit_templates_page 11 | 12 | row_a = page.find("[data-id='#{template_a.id}']") 13 | row_b = page.find("[data-id='#{template_b.id}']") 14 | row_c = page.find("[data-id='#{template_c.id}']") 15 | 16 | row_c.find(".sortable-handle").drag_to(row_a.find(".sortable-handle")) 17 | row_a.find(".sortable-handle").drag_to(row_b.find(".sortable-handle")) 18 | 19 | row_1, row_2, row_3 = page.all("[data-id]") 20 | 21 | assert row_1.has_text?(template_c.title) 22 | assert row_2.has_text?(template_a.title) 23 | assert row_3.has_text?(template_b.title) 24 | assert_equal 0, template_c.reload.position 25 | assert_equal 1, template_a.reload.position 26 | assert_equal 2, template_b.reload.position 27 | end 28 | 29 | private 30 | 31 | def visit_templates_page 32 | visit root_path(as: create(:user)) 33 | 34 | click_link t("Templates") 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/system/categories/reorder_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Categories 4 | class ReorderSystemTest < ApplicationSystemTestCase 5 | test "reorder" do 6 | category_a = create(:category, position: 1) 7 | category_b = create(:category, position: 2) 8 | category_c = create(:category, position: 3) 9 | 10 | visit_categories_page 11 | 12 | row_a = page.find("[data-id='#{category_a.id}']") 13 | row_b = page.find("[data-id='#{category_b.id}']") 14 | row_c = page.find("[data-id='#{category_c.id}']") 15 | 16 | row_c.find(".sortable-handle").drag_to(row_a.find(".sortable-handle")) 17 | row_a.find(".sortable-handle").drag_to(row_b.find(".sortable-handle")) 18 | 19 | row_1, row_2, row_3 = page.all("[data-id]") 20 | 21 | assert row_1.has_text?(category_c.title) 22 | assert row_2.has_text?(category_a.title) 23 | assert row_3.has_text?(category_b.title) 24 | assert_equal 0, category_c.reload.position 25 | assert_equal 1, category_a.reload.position 26 | assert_equal 2, category_b.reload.position 27 | end 28 | 29 | private 30 | 31 | def visit_categories_page 32 | visit root_path(as: create(:user)) 33 | 34 | click_link t("Categories") 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /app/components/records/list.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Records 3 | class List < Base 4 | def initialize(account:, records:, pagy:) 5 | @account = account 6 | @records = records 7 | @pagy = pagy 8 | end 9 | 10 | private 11 | 12 | def view_template 13 | div class: "flex flex-col gap-6" do 14 | grouped_records_by_date.each do |occurred_on, records| 15 | div class: "flex flex-col gap-2" do 16 | div class: "flex flex-row justify-between" do 17 | span class: "font-bold text-sm text-current/50" do 18 | l(occurred_on, format: :full_date) 19 | end 20 | 21 | span do 22 | Bolt.CurrencyDisplay( 23 | amount: @account.balance(on: occurred_on), 24 | size: :sm 25 | ) 26 | end 27 | end 28 | 29 | Bolt.List do 30 | records.each { ListItem(record: it) } 31 | end 32 | end 33 | end 34 | 35 | Bolt.Pagination(pagy: @pagy) 36 | end 37 | end 38 | 39 | def grouped_records_by_date 40 | @records.group_by(&:occurred_on) 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/views/accounts/index.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | module Accounts 3 | class Index < Views::Base 4 | def initialize(accounts:) 5 | @accounts = accounts 6 | end 7 | 8 | private 9 | 10 | def view_template 11 | Layouts.Main do 12 | Bolt.Page do 13 | Bolt.PageHeader do 14 | Bolt.PageHeading do 15 | Bolt.PageTitle do 16 | t("Accounts") 17 | end 18 | 19 | Bolt.PageSubtitle do 20 | t("View and manage your accounts") 21 | end 22 | end 23 | 24 | if @accounts.any? 25 | Bolt.PageActions do 26 | Bolt.LinkButton href: new_account_path, color: :primary do 27 | Lucide.Plus class: "size-4" 28 | plain t("New account") 29 | end 30 | end 31 | end 32 | end 33 | 34 | Bolt.PageBody do 35 | if @accounts.any? 36 | Components::Accounts.List(accounts: @accounts) 37 | else 38 | Components::Accounts.EmptyState 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /app/components/bolt/form/color_picker.rb: -------------------------------------------------------------------------------- 1 | module Bolt 2 | class ColorPicker < Base 3 | COLORS = %w[ 4 | #122230 #244a63 #6882a8 #b1cbe2 5 | #d9eaf8 #e1e3d2 #afe4bd #48c39a 6 | #279098 #333a7f #995fbf #cc88e1 7 | #f9b9d8 #ed6697 #bb3c63 #692851 8 | #542730 #9f4444 #d9865d #f6d995 9 | #efba3f #c6c85f #84b25f #408450 10 | ].freeze 11 | 12 | CHECK_CLASSES = "hidden peer-checked:flex w-full h-full mask " \ 13 | "items-center justify-center border-[2px] ring-[2px] ring-offset-1 " \ 14 | "ring-base-content rounded-full" 15 | 16 | def initialize(form, attribute, **) 17 | @form = form 18 | @attribute = attribute 19 | 20 | super(**) 21 | end 22 | 23 | private 24 | 25 | def view_template 26 | div class: "grid grid-cols-6 lg:grid-cols-12 gap-4 lg:gap-2" do 27 | COLORS.each do |color| 28 | @form.label( 29 | @attribute, 30 | value: color, 31 | class: "aspect-square rounded-full cursor-pointer relative", 32 | style: "background-color: #{color}" 33 | ) do 34 | @form.radio_button @attribute, color, class: "peer hidden" 35 | 36 | span class: CHECK_CLASSES 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /app/views/accounts/records/new.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | module Accounts 3 | module Records 4 | class New < Views::Base 5 | def initialize(record:, accounts:, categories:) 6 | @record = record 7 | @accounts = accounts 8 | @categories = categories 9 | end 10 | 11 | private 12 | 13 | def view_template 14 | Layouts.Zen do 15 | Bolt.Page do 16 | Bolt.PageHeader sticky: false do 17 | Bolt.PageHeading do 18 | Bolt.PageTitle(drawer_toggle: false) do 19 | t("New record") 20 | end 21 | 22 | Bolt.PageSubtitle do 23 | t("Create a new record") 24 | end 25 | end 26 | end 27 | 28 | Bolt.PageBody do 29 | Components::Records.Form( 30 | record: @record, 31 | accounts: @accounts, 32 | categories: @categories, 33 | cancel_href: record_types_path( 34 | account_id: request.params[:account_id] 35 | ) 36 | ) 37 | end 38 | end 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/models/transfer.rb: -------------------------------------------------------------------------------- 1 | class Transfer < ApplicationRecord 2 | belongs_to :from_account, class_name: "Account" 3 | belongs_to :to_account, class_name: "Account" 4 | 5 | has_one :expense_record, 6 | -> { expense }, 7 | class_name: "Record", 8 | dependent: :destroy 9 | has_one :income_record, 10 | -> { income }, 11 | class_name: "Record", 12 | dependent: :destroy 13 | 14 | validates :occurred_on, presence: true 15 | validates :note, presence: true, allow_blank: true 16 | 17 | validates :amount_cents, numericality: {greater_than: 0} 18 | 19 | validates :note, length: {maximum: 32} 20 | 21 | validates :to_account_id, comparison: { 22 | other_than: :from_account_id, 23 | message: :must_be_different_from_origin_account 24 | } 25 | 26 | def attributes_for_expense_record 27 | attributes_for_records.merge(group: "expense", account_id: from_account_id) 28 | end 29 | 30 | def attributes_for_income_record 31 | attributes_for_records.merge(group: "income", account_id: to_account_id) 32 | end 33 | 34 | private 35 | 36 | def attributes_for_records 37 | { 38 | transfer_id: id, 39 | amount_cents: amount_cents, 40 | occurred_on: occurred_on, 41 | note: note, 42 | created_at: created_at, 43 | updated_at: updated_at 44 | } 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /.kamal/hooks/pre-build.sample: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A sample pre-build hook 4 | # 5 | # Checks: 6 | # 1. We have a clean checkout 7 | # 2. A remote is configured 8 | # 3. The branch has been pushed to the remote 9 | # 4. The version we are deploying matches the remote 10 | # 11 | # These environment variables are available: 12 | # KAMAL_RECORDED_AT 13 | # KAMAL_PERFORMER 14 | # KAMAL_VERSION 15 | # KAMAL_HOSTS 16 | # KAMAL_ROLE (if set) 17 | # KAMAL_DESTINATION (if set) 18 | 19 | if [ -n "$(git status --porcelain)" ]; then 20 | echo "Git checkout is not clean, aborting..." >&2 21 | git status --porcelain >&2 22 | exit 1 23 | fi 24 | 25 | first_remote=$(git remote) 26 | 27 | if [ -z "$first_remote" ]; then 28 | echo "No git remote set, aborting..." >&2 29 | exit 1 30 | fi 31 | 32 | current_branch=$(git branch --show-current) 33 | 34 | if [ -z "$current_branch" ]; then 35 | echo "Not on a git branch, aborting..." >&2 36 | exit 1 37 | fi 38 | 39 | remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) 40 | 41 | if [ -z "$remote_head" ]; then 42 | echo "Branch not pushed to remote, aborting..." >&2 43 | exit 1 44 | fi 45 | 46 | if [ "$KAMAL_VERSION" != "$remote_head" ]; then 47 | echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 48 | exit 1 49 | fi 50 | 51 | exit 0 52 | -------------------------------------------------------------------------------- /app/views/categories/index.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | module Categories 3 | class Index < Views::Base 4 | def initialize(categories:) 5 | @categories = categories 6 | end 7 | 8 | private 9 | 10 | def view_template 11 | Layouts.Main do 12 | Bolt.Page do 13 | Bolt.PageHeader do 14 | Bolt.PageHeading do 15 | Bolt.PageTitle do 16 | t("Categories") 17 | end 18 | 19 | Bolt.PageSubtitle do 20 | t("Keep your records organized with categories") 21 | end 22 | end 23 | 24 | if @categories.any? 25 | Bolt.PageActions do 26 | Bolt.LinkButton href: new_category_path, color: :primary do 27 | Lucide.Plus class: "size-4" 28 | plain t("New category") 29 | end 30 | end 31 | end 32 | end 33 | 34 | Bolt.PageBody do 35 | if @categories.any? 36 | Components::Categories.List(categories: @categories) 37 | else 38 | Components::Categories.EmptyState 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/system/categories/create_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Categories 4 | class CreateSystemTest < ApplicationSystemTestCase 5 | test "valid form" do 6 | visit_new_category_page 7 | 8 | assert_css "h1", text: t("New category") 9 | 10 | fill_in field(Category, :title), with: "My special category" 11 | choose("category[color]", option: "#bb3c63", allow_label_click: true) 12 | 13 | click_button submit_text(Category) 14 | 15 | assert_current_path categories_path 16 | assert_css ".alert-success", text: t("Category created") 17 | assert_css "li", text: "My special category" 18 | end 19 | 20 | test "invalid form" do 21 | visit_new_category_page 22 | 23 | fill_in field(Category, :title), with: "" 24 | choose("category[color]", option: "#bb3c63", allow_label_click: true) 25 | 26 | click_button submit_text(Category) 27 | 28 | assert_current_path new_category_path 29 | assert_field field(Category, :title), with: "" 30 | assert_css "span", text: t("errors.messages.blank").upcase_first 31 | end 32 | 33 | private 34 | 35 | def visit_new_category_page 36 | visit root_path(as: create(:user)) 37 | 38 | click_link t("Categories") 39 | 40 | click_link t("New category") 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /app/components/layouts/main.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Layouts 3 | class Main < Base 4 | private 5 | 6 | def view_template(&) 7 | Layouts.Root do 8 | Bolt.Drawer do 9 | Bolt.DrawerContent do 10 | div class: "flex flex-col" do 11 | main_container do 12 | side_menu 13 | main class: "w-full mx-auto", & 14 | end 15 | end 16 | end 17 | 18 | Bolt.DrawerSide do 19 | Layouts.Sidenav 20 | end 21 | end 22 | end 23 | end 24 | 25 | def side_menu 26 | div class: "w-full max-w-2xs hidden lg:flex h-full sticky top-0" do 27 | Bolt.Stack class: "w-full" do 28 | a( 29 | href: root_path, 30 | class: "text-2xl font-black text-primary text-center pt-8" 31 | ) do 32 | "pikoin" 33 | end 34 | 35 | Bolt.Menu size: :lg, class: "w-full" do 36 | Layouts.MenuItems 37 | end 38 | end 39 | end 40 | end 41 | 42 | def main_container(&) 43 | div( 44 | class: "flex flex-row justify-center w-full max-w-screen-xl m-auto", 45 | & 46 | ) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/system/templates/update_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Templates 4 | class UpdateSystemTest < ApplicationSystemTestCase 5 | test "valid form" do 6 | template = create(:template) 7 | 8 | visit_edit_template_page(template:) 9 | 10 | assert_css "h1", text: t("Edit template") 11 | 12 | fill_in field(Template, :title), with: "New template title" 13 | 14 | click_button submit_text(Template, :update) 15 | 16 | assert_current_path templates_path 17 | assert_css ".alert-success", text: t("Template updated") 18 | assert_css "li", text: "New template title" 19 | end 20 | 21 | test "invalid form" do 22 | template = create(:template) 23 | 24 | visit_edit_template_page(template:) 25 | 26 | fill_in field(Template, :title), with: "" 27 | 28 | click_button submit_text(Template, :update) 29 | 30 | assert_current_path edit_template_path(template) 31 | assert_field field(Template, :title), with: "" 32 | assert_css "span", text: t("errors.messages.blank").upcase_first 33 | end 34 | 35 | private 36 | 37 | def visit_edit_template_page(template:) 38 | visit root_path(as: create(:user)) 39 | 40 | click_link t("Templates") 41 | 42 | click_link template.title, href: edit_template_path(template) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/components/insights/cashflow_card_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Components::Insights 4 | class CashflowCardTest < ComponentTestCase 5 | test "balance" do 6 | cashflow = Cashflow[ 7 | income: Money.new(10_00), 8 | expense: Money.new(-15_00) 9 | ] 10 | 11 | render Components::Insights::CashflowCard.new(cashflow:) 12 | 13 | assert_text t("Cashflow") 14 | assert_css ".text-error", text: Money.new(-5_00).format 15 | end 16 | 17 | test "income" do 18 | cashflow = Cashflow[ 19 | income: Money.new(10_00), 20 | expense: Money.new(-15_00) 21 | ] 22 | 23 | render Components::Insights::CashflowCard.new(cashflow:) 24 | 25 | assert_text t("Income") 26 | assert_css ".text-success", 27 | text: Money.new(10_00).format(sign_positive: true) 28 | assert_css ".progress-success[max='1500'][value='1000']" 29 | end 30 | 31 | test "expense" do 32 | cashflow = Cashflow[ 33 | income: Money.new(19_00), 34 | expense: Money.new(-15_00) 35 | ] 36 | 37 | render Components::Insights::CashflowCard.new(cashflow:) 38 | 39 | assert_text t("Expense") 40 | assert_css ".text-error", 41 | text: Money.new(-15_00).format 42 | assert_css ".progress-error[max='1900'][value='1500']" 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/models/category_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CategoryTest < ActiveSupport::TestCase 4 | test "associations" do 5 | category = Category.new 6 | 7 | assert have_many(:records).dependent(:destroy).matches?(category) 8 | assert have_many(:templates).dependent(:destroy).matches?(category) 9 | end 10 | 11 | test "validations" do 12 | category = Category.new 13 | 14 | assert validate_presence_of(:title).matches?(category) 15 | assert validate_presence_of(:color).matches?(category) 16 | assert validate_presence_of(:position).matches?(category) 17 | 18 | assert validate_length_of(:title).is_at_most(40).matches?(category) 19 | 20 | assert allow_value("#FF00aa").for(:color).matches?(category) 21 | refute allow_value("FF00aa").for(:color).matches?(category) 22 | refute allow_value("#fff").for(:color).matches?(category) 23 | refute allow_value("#ff00aaff").for(:color).matches?(category) 24 | refute allow_value("#ff00zz").for(:color).matches?(category) 25 | refute allow_value("random").for(:color).matches?(category) 26 | end 27 | 28 | test ".next_position" do 29 | assert_equal 1, Category.next_position 30 | 31 | create(:category, position: 1) 32 | assert_equal 2, Category.next_position 33 | 34 | create(:category, position: 829) 35 | assert_equal 830, Category.next_position 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/system/categories/update_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Categories 4 | class UpdateSystemTest < ApplicationSystemTestCase 5 | test "valid form" do 6 | category = create(:category) 7 | 8 | visit_edit_category_page(category:) 9 | 10 | assert_css "h1", text: t("Edit category") 11 | 12 | assert_field field(Category, :title), with: category.title 13 | 14 | fill_in field(Category, :title), with: "Updated title" 15 | 16 | click_button submit_text(Category, :update) 17 | 18 | assert_current_path categories_path 19 | assert_css ".alert-success", text: t("Category updated") 20 | assert_css "li", text: "Updated title" 21 | end 22 | 23 | test "invalid form" do 24 | category = create(:category) 25 | 26 | visit_edit_category_page(category:) 27 | 28 | fill_in field(Category, :title), with: "" 29 | 30 | click_button submit_text(Category, :update) 31 | 32 | assert_current_path edit_category_path(category) 33 | assert_field field(Category, :title), with: "" 34 | assert_css "span", text: t("errors.messages.blank").upcase_first 35 | end 36 | 37 | private 38 | 39 | def visit_edit_category_page(category:) 40 | visit root_path(as: create(:user)) 41 | 42 | click_link t("Categories") 43 | 44 | click_link category.title, href: edit_category_path(category) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /app/components/insights/spending_breakdown_card.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Insights 3 | class SpendingBreakdownCard < Base 4 | def initialize(spending_breakdown:) 5 | @spending_breakdown = spending_breakdown 6 | end 7 | 8 | private 9 | 10 | def view_template 11 | Bolt.Panel do 12 | span class: "text-lg font-bold" do 13 | t("Spending breakdown") 14 | end 15 | 16 | if @spending_breakdown.items.any? 17 | chart 18 | else 19 | empty_state 20 | end 21 | end 22 | end 23 | 24 | def chart 25 | canvas( 26 | data: { 27 | controller: "charts--spending-breakdown", 28 | charts__spending_breakdown_data_value: data.to_json 29 | } 30 | ) 31 | end 32 | 33 | def empty_state 34 | div class: "flex items-center justify-center aspect-square w-full h-full" do 35 | span(class: "text-current/50") do 36 | t("No expenses for the chosen period") 37 | end 38 | end 39 | end 40 | 41 | def data 42 | @spending_breakdown.items.map do |item| 43 | { 44 | label: item.label, 45 | color: item.color, 46 | value: item.total.cents, 47 | formatted: item.total.format 48 | } 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/components/records/list_item_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Components::Records 4 | class ListItemTest < ComponentTestCase 5 | test "content" do 6 | record = create(:record) 7 | 8 | render Components::Records::ListItem.new(record:) 9 | 10 | assert_content record.note 11 | assert_content record.account.title 12 | end 13 | 14 | test "main text" do 15 | record = create(:record) 16 | 17 | render Components::Records::ListItem.new(record:) 18 | 19 | assert_content record.category.title 20 | refute_content t("Transfer") 21 | 22 | record = create(:transfer).expense_record 23 | 24 | render Components::Records::ListItem.new(record:) 25 | 26 | assert_content t("Transfer") 27 | end 28 | 29 | test "amount" do 30 | record = create(:expense) 31 | render Components::Records::ListItem.new(record:) 32 | 33 | assert_css "span.text-error", text: record.amount.format 34 | 35 | record = create(:income) 36 | 37 | render Components::Records::ListItem.new(record:) 38 | 39 | assert_css "span.text-success", 40 | text: record.amount.format(sign_positive: true) 41 | 42 | record = create(:transfer).income_record 43 | 44 | render Components::Records::ListItem.new(record:) 45 | 46 | assert_css "span.text-current", 47 | text: record.amount.format(sign_positive: true) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /app/components/layouts/menu_items.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Layouts 3 | class MenuItems < Base 4 | private 5 | 6 | register_value_helper :controller 7 | 8 | def view_template 9 | Bolt.MenuItem href: insights_path, active: insights_active? do 10 | Lucide.Gauge(class: "size-5") 11 | 12 | span { t("Insights") } 13 | end 14 | 15 | Bolt.MenuItem href: accounts_path, active: accounts_active? do 16 | Lucide.WalletMinimal(class: "size-5") 17 | 18 | span { t("Accounts") } 19 | end 20 | 21 | Bolt.MenuItem href: categories_path, active: categories_active? do 22 | Lucide.Tags(class: "size-5") 23 | span { t("Categories") } 24 | end 25 | 26 | Bolt.MenuItem href: templates_path, active: templates_active? do 27 | Lucide.BookDashed(class: "size-5") 28 | span { t("Templates") } 29 | end 30 | 31 | Bolt.MenuItem href: sign_out_path, data: {turbo_method: :delete} do 32 | Lucide.LogOut(class: "size-5") 33 | span { t("Sign out") } 34 | end 35 | end 36 | 37 | def insights_active? = InsightsController === controller 38 | 39 | def accounts_active? = request.path.start_with?("/accounts") 40 | 41 | def categories_active? = CategoriesController === controller 42 | 43 | def templates_active? = TemplatesController === controller 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /app/views/accounts/edit.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | module Accounts 3 | class Edit < Views::Base 4 | def initialize(account:) 5 | @account = account 6 | end 7 | 8 | private 9 | 10 | def view_template 11 | Layouts.Zen do 12 | Bolt.Page do 13 | Bolt.PageHeader sticky: false do 14 | Bolt.PageHeading do 15 | Bolt.PageTitle(drawer_toggle: false) do 16 | t("Edit account") 17 | end 18 | 19 | Bolt.PageSubtitle do 20 | t("Edit your account information") 21 | end 22 | end 23 | 24 | Bolt.PageActions do 25 | Bolt.LinkButton( 26 | href: account_path(@account), 27 | dash: true, 28 | color: :error, 29 | data: { 30 | turbo_method: :delete, 31 | turbo_confirm: t("Are you sure you want to remove this account? This will also permanently remove all associated records.") 32 | } 33 | ) do 34 | Lucide.Trash class: "size-4" 35 | plain t("Remove") 36 | end 37 | end 38 | end 39 | 40 | Bolt.PageBody do 41 | Components::Accounts.Form(account: @account) 42 | end 43 | end 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/operations/transfers/create_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Transfers 4 | class CreateTest < ActiveSupport::TestCase 5 | test "success" do 6 | attributes = { 7 | from_account: create(:account), 8 | to_account: create(:account), 9 | amount_cents: 200, 10 | occurred_on: Date.parse("2025-02-03") 11 | } 12 | 13 | result = Transfers::Create.call(attributes:) 14 | 15 | transfer = result.transfer 16 | 17 | assert_instance_of Transfers::Create::Success, result 18 | assert transfer.persisted? 19 | 20 | attributes.each do |attribute, value| 21 | assert_equal value, transfer.public_send(attribute) 22 | end 23 | 24 | transfer.attributes_for_expense_record.each do |attribute, value| 25 | assert_equal value, transfer.expense_record.public_send(attribute) 26 | end 27 | 28 | transfer.attributes_for_income_record.each do |attribute, value| 29 | assert_equal value, transfer.income_record.public_send(attribute) 30 | end 31 | end 32 | 33 | test "failure" do 34 | attributes = {amount_cents: nil} 35 | 36 | result = Transfers::Create.call(attributes:) 37 | 38 | assert_instance_of Transfers::Create::Failure, result 39 | refute result.transfer.persisted? 40 | assert result.transfer.errors.any? 41 | assert_nil result.transfer.expense_record 42 | assert_nil result.transfer.income_record 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /app/components/accounts/list_item.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Accounts 3 | class ListItem < Base 4 | def initialize(account:) 5 | @account = account 6 | end 7 | 8 | private 9 | 10 | def view_template 11 | Bolt.ListItem padding: nil, data: {id: @account.id} do 12 | div class: "flex flex-row gap-2 items-center px-4" do 13 | div class: "cursor-move sortable-handle" do 14 | Lucide.GripVertical class: "size-6 stroke-base-content/60" 15 | end 16 | 17 | a( 18 | href: account_records_path(@account), 19 | class: "flex flex-row gap-2 justify-between items-center w-full py-4" 20 | ) do 21 | div class: "flex flex-row gap-2 items-center" do 22 | Bolt.Dot( 23 | size: "size-6", 24 | color: @account.color, 25 | title: @account.title 26 | ) 27 | 28 | span( 29 | class: [ 30 | "text-lg font-semibold", 31 | ("text-current/40" if @account.archived?) 32 | ] 33 | ) do 34 | @account.title 35 | end 36 | end 37 | 38 | span do 39 | Bolt.CurrencyDisplay(amount: @account.balance) 40 | end 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /app/views/categories/edit.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | module Categories 3 | class Edit < Views::Base 4 | def initialize(category:) 5 | @category = category 6 | end 7 | 8 | private 9 | 10 | def view_template 11 | Layouts.Zen do 12 | Bolt.Page do 13 | Bolt.PageHeader sticky: false do 14 | Bolt.PageHeading do 15 | Bolt.PageTitle(drawer_toggle: false) do 16 | t("Edit category") 17 | end 18 | 19 | Bolt.PageSubtitle do 20 | t("Edit your category information") 21 | end 22 | end 23 | 24 | Bolt.PageActions do 25 | Bolt.LinkButton( 26 | href: category_path(@category), 27 | dash: true, 28 | color: :error, 29 | data: { 30 | turbo_method: :delete, 31 | turbo_confirm: t("Are you sure you want to remove this category? This will also permanently remove all associated records") 32 | } 33 | ) do 34 | Lucide.Trash class: "size-4" 35 | plain t("Remove") 36 | end 37 | end 38 | end 39 | 40 | Bolt.PageBody do 41 | Components::Categories.Form(category: @category) 42 | end 43 | end 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://containers.dev/implementors/json_reference/. 2 | // For config options, see the README at: https://github.com/devcontainers/templates/tree/main/src/ruby 3 | { 4 | "name": "pikoin", 5 | "dockerComposeFile": "compose.yaml", 6 | "service": "rails-app", 7 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 8 | 9 | // Features to add to the dev container. More info: https://containers.dev/features. 10 | "features": { 11 | "ghcr.io/devcontainers/features/github-cli:1": {}, 12 | "ghcr.io/rails/devcontainer/features/activestorage": {}, 13 | "ghcr.io/devcontainers/features/node:1": {}, 14 | "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, 15 | "ghcr.io/rails/devcontainer/features/sqlite3": {} 16 | }, 17 | 18 | "containerEnv": { 19 | "CAPYBARA_SERVER_PORT": "45678", 20 | "SELENIUM_HOST": "selenium", 21 | "KAMAL_REGISTRY_PASSWORD": "$KAMAL_REGISTRY_PASSWORD" 22 | }, 23 | 24 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 25 | "forwardPorts": [3000], 26 | 27 | // Configure tool-specific properties. 28 | // "customizations": {}, 29 | 30 | // Uncomment to connect as root instead. More info: https://containers.dev/implementors/json_reference/#remoteUser. 31 | // "remoteUser": "root", 32 | 33 | // Use 'postCreateCommand' to run commands after the container is created. 34 | "postCreateCommand": "bin/setup --skip-server" 35 | } 36 | -------------------------------------------------------------------------------- /test/system/insights/list_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Insights 4 | class ListSystemTest < ApplicationSystemTestCase 5 | test "display cashflow insight" do 6 | travel_to Date.new(2025, 5, 15) 7 | 8 | create(:income, amount_cents: 10_00, occurred_on: Time.zone.today) 9 | create(:expense, amount_cents: 1_00, occurred_on: Time.zone.today) 10 | create(:expense, amount_cents: 2_00, occurred_on: 10.days.ago) 11 | create(:expense, amount_cents: 4_00, occurred_on: 1.year.ago) 12 | 13 | visit root_path(as: create(:user)) 14 | 15 | assert_text t("Cashflow") 16 | assert_text Money.new(7_00).format(sign_positive: true) 17 | assert_text Money.new(10_00).format(sign_positive: true) 18 | assert_text Money.new(-3_00).format 19 | 20 | select t("periods.Last 7 days"), from: "period" 21 | 22 | assert_text Money.new(9_00).format(sign_positive: true) 23 | assert_text Money.new(10_00).format(sign_positive: true) 24 | assert_text Money.new(-1_00).format 25 | 26 | select t("periods.All"), from: "period" 27 | 28 | assert_text Money.new(3_00).format(sign_positive: true) 29 | assert_text Money.new(10_00).format(sign_positive: true) 30 | assert_text Money.new(-7_00).format 31 | 32 | travel_back 33 | end 34 | 35 | test "display spending breakdown insight" do 36 | visit root_path(as: create(:user)) 37 | 38 | assert_text t("Spending breakdown") 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/lib/period_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class PeriodTest < ActiveSupport::TestCase 4 | test "#range" do 5 | travel_to Date.new(2025, 4, 15) do 6 | period = Period.coerce("tm") 7 | assert_equal Date.new(2025, 4, 1)..Date.new(2025, 4, 30), period.range 8 | 9 | period = Period.coerce("lm") 10 | assert_equal Date.new(2025, 3, 1)..Date.new(2025, 3, 31), period.range 11 | 12 | period = Period.coerce("ty") 13 | assert_equal Date.new(2025, 1, 1)..Date.new(2025, 12, 31), period.range 14 | 15 | period = Period.coerce("ly") 16 | assert_equal Date.new(2024, 1, 1)..Date.new(2024, 12, 31), period.range 17 | 18 | period = Period.coerce("7d") 19 | assert_equal Date.new(2025, 4, 8)..Date.new(2025, 4, 15), period.range 20 | 21 | period = Period.coerce("30d") 22 | assert_equal Date.new(2025, 3, 16)..Date.new(2025, 4, 15), period.range 23 | 24 | period = Period.coerce("3m") 25 | assert_equal Date.new(2025, 1, 15)..Date.new(2025, 4, 15), period.range 26 | 27 | period = Period.coerce("6m") 28 | assert_equal Date.new(2024, 10, 15)..Date.new(2025, 4, 15), period.range 29 | 30 | period = Period.coerce("12m") 31 | assert_equal Date.new(2024, 4, 15)..Date.new(2025, 4, 15), period.range 32 | 33 | period = Period.coerce("24m") 34 | assert_equal Date.new(2023, 4, 15)..Date.new(2025, 4, 15), period.range 35 | 36 | period = Period.coerce("all") 37 | assert_nil period.range 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/system/accounts/create_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Accounts 4 | class CreateSystemTest < ApplicationSystemTestCase 5 | test "valid form" do 6 | visit_new_account_page 7 | 8 | assert_css "h1", text: t("New account") 9 | assert_link t("Cancel"), href: accounts_path 10 | 11 | fill_in field(Account, :title), with: "My special account" 12 | choose("account[color]", option: "#bb3c63", allow_label_click: true) 13 | fill_in field(Account, :initial_amount_cents), with: "20_00" 14 | click_button submit_text(Account) 15 | 16 | assert_current_path accounts_path 17 | assert_css ".alert-success", text: t("Account created") 18 | assert_css "li", text: "My special account" 19 | assert_css "li", text: Money.new(20_00).format(sign_positive: true) 20 | end 21 | 22 | test "invalid form" do 23 | visit_new_account_page 24 | 25 | fill_in field(Account, :title), with: "" 26 | choose("account[color]", option: "#bb3c63", allow_label_click: true) 27 | 28 | click_button submit_text(Account) 29 | 30 | assert_current_path new_account_path 31 | assert_field field(Account, :title), with: "" 32 | assert_css "span", text: t("errors.messages.blank").upcase_first 33 | end 34 | 35 | private 36 | 37 | def visit_new_account_page 38 | visit root_path(as: create(:user)) 39 | 40 | click_link t("Accounts") 41 | 42 | click_link t("New account") 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /config/initializers/clearance.rb: -------------------------------------------------------------------------------- 1 | if Rails.env.test? 2 | Rails.application.config.middleware.use Clearance::BackDoor 3 | end 4 | 5 | Rails.application.config.to_prepare do 6 | Clearance::SessionsController.rate_limit( 7 | to: 4, 8 | within: 1.minute, 9 | only: :create 10 | ) 11 | end 12 | 13 | Clearance.configure do |config| 14 | config.allow_sign_up = false 15 | config.allow_password_reset = false 16 | config.mailer_sender = "reply@example.com" 17 | config.rotate_csrf_on_sign_in = true 18 | config.signed_cookie = true 19 | 20 | # DEFAULT CONFIG 21 | # config.allow_sign_up = true 22 | # config.allow_password_reset = true 23 | # config.cookie_domain = ".example.com" 24 | # config.cookie_expiration = lambda { |cookies| 1.year.from_now.utc } 25 | # config.cookie_name = "remember_token" 26 | # config.cookie_path = "/" 27 | # config.routes = true 28 | # config.httponly = true 29 | # config.mailer_sender = "reply@example.com" 30 | # config.password_strategy = Clearance::PasswordStrategies::BCrypt 31 | # config.redirect_url = "/" 32 | # config.url_after_destroy = nil 33 | # config.url_after_denied_access_when_signed_out = nil 34 | # config.rotate_csrf_on_sign_in = true 35 | # config.same_site = nil 36 | # config.secure_cookie = Rails.configuration.force_ssl 37 | # config.signed_cookie = false 38 | # config.sign_in_guards = [] 39 | # config.user_model = "User" 40 | # config.parent_controller = "ApplicationController" 41 | # config.sign_in_on_password_reset = false 42 | end 43 | -------------------------------------------------------------------------------- /app/components/layouts/root.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Layouts 3 | class Root < Base 4 | include Phlex::Rails::Layout 5 | 6 | private 7 | 8 | def view_template(&) 9 | doctype 10 | 11 | html do 12 | head do 13 | title { "pikoin" } 14 | 15 | meta name: "viewport", content: "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" 16 | meta name: "apple-mobile-web-app-capable", content: "yes" 17 | meta name: "mobile-web-app-capable", content: "yes" 18 | meta name: "robots", content: "noindex,nofollow" 19 | 20 | csrf_meta_tags 21 | csp_meta_tag 22 | 23 | # Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) 24 | # link rel: "manifest", href: pwa_manifest_path(format: :json) 25 | 26 | link rel: "icon", href: "/icon.png", type: "image/png" 27 | link rel: "icon", href: "/icon.svg", type: "image/svg+xml" 28 | link rel: "apple-touch-icon", href: "/icon.png" 29 | 30 | # stylesheet_link_tag :app, data_turbo_track: :reload 31 | javascript_include_tag :application, data_turbo_track: :reload, type: :module 32 | stylesheet_link_tag :application, data_turbo_track: :reload 33 | end 34 | 35 | body do 36 | yield 37 | 38 | Components::Layouts.Flash 39 | end 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/controllers/accounts_controller.rb: -------------------------------------------------------------------------------- 1 | class AccountsController < ApplicationController 2 | def index 3 | accounts = Account.order(:position) 4 | 5 | render Views::Accounts::Index.new(accounts:) 6 | end 7 | 8 | def new 9 | account = Account.new 10 | 11 | render Views::Accounts::New.new(account:) 12 | end 13 | 14 | def edit 15 | account = Account.find(params[:id]) 16 | 17 | render Views::Accounts::Edit.new(account:) 18 | end 19 | 20 | def create 21 | case Accounts::Create.call(attributes: account_params) 22 | in Accounts::Create::Success 23 | redirect_to accounts_path, notice: t("Account created") 24 | in Accounts::Create::Failure(account) 25 | render Views::Accounts::New.new(account:), status: :unprocessable_content 26 | end 27 | end 28 | 29 | def update 30 | case Accounts::Update.call(id: params[:id], attributes: account_params) 31 | in Accounts::Update::Success(account) 32 | redirect_to account_records_path(account), notice: t("Account updated") 33 | in Accounts::Update::Failure(account) 34 | render Views::Accounts::Edit.new(account:), status: :unprocessable_content 35 | end 36 | end 37 | 38 | def destroy 39 | case Accounts::Destroy.call(id: params[:id]) 40 | in Accounts::Destroy::Success 41 | redirect_to accounts_path, notice: t("Account removed") 42 | end 43 | end 44 | 45 | private 46 | 47 | def account_params 48 | params.expect(account: %i[title color initial_amount_cents archived]).to_h 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /app/controllers/categories_controller.rb: -------------------------------------------------------------------------------- 1 | class CategoriesController < ApplicationController 2 | def index 3 | categories = Category.order(:position) 4 | 5 | render Views::Categories::Index.new(categories:) 6 | end 7 | 8 | def new 9 | category = Category.new 10 | 11 | render Views::Categories::New.new(category:) 12 | end 13 | 14 | def edit 15 | category = Category.find(params[:id]) 16 | 17 | render Views::Categories::Edit.new(category:) 18 | end 19 | 20 | def create 21 | case Categories::Create.call(attributes: category_params) 22 | in Categories::Create::Success 23 | redirect_to categories_path, notice: t("Category created") 24 | in Categories::Create::Failure(category) 25 | render Views::Categories::New.new(category:), status: :unprocessable_content 26 | end 27 | end 28 | 29 | def update 30 | case Categories::Update.call(id: params[:id], attributes: category_params) 31 | in Categories::Update::Success 32 | redirect_to categories_path, notice: t("Category updated") 33 | in Categories::Update::Failure(category) 34 | render Views::Categories::Edit.new(category:), status: :unprocessable_content 35 | end 36 | end 37 | 38 | def destroy 39 | case Categories::Destroy.call(id: params[:id]) 40 | in Categories::Destroy::Success 41 | redirect_to categories_path, notice: t("Category removed") 42 | end 43 | end 44 | 45 | private 46 | 47 | def category_params 48 | params.expect(category: %i[title color]).to_h 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/system/sessions/create_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Sessions 4 | class CreateSystemTest < ApplicationSystemTestCase 5 | test "valid credentials" do 6 | create(:user, email: "example@example.com", password: "test123") 7 | 8 | visit root_path 9 | 10 | assert_css "h1", text: "pikoin" 11 | 12 | fill_in t("Email address"), with: "example@example.com" 13 | fill_in t("Password"), with: "test123" 14 | 15 | click_button t("Sign in") 16 | 17 | assert_current_path root_path 18 | assert_link t("Sign out") 19 | end 20 | 21 | test "invalid credentials" do 22 | visit root_path 23 | 24 | fill_in t("Email address"), with: "example@example.com" 25 | fill_in t("Password"), with: "test123" 26 | 27 | click_button t("Sign in") 28 | 29 | assert_current_path sign_in_path 30 | assert_content t("flashes.failure_after_create") 31 | end 32 | 33 | test "sign in rate limit" do 34 | create(:user, email: "example@example.com", password: "test123") 35 | 36 | visit root_path 37 | 38 | fill_in t("Email address"), with: "example@example.com" 39 | fill_in t("Password"), with: "WRONG" 40 | 41 | 4.times do 42 | click_button t("Sign in") 43 | 44 | assert_content t("flashes.failure_after_create") 45 | end 46 | 47 | fill_in t("Email address"), with: "example@example.com" 48 | fill_in t("Password"), with: "test123" 49 | 50 | assert_current_path sign_in_path 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/system/accounts/update_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Accounts 4 | class UpdateSystemTest < ApplicationSystemTestCase 5 | test "valid form" do 6 | account = create(:account) 7 | 8 | visit_edit_account_page(account:) 9 | 10 | assert_css "h1", text: t("Edit account") 11 | assert_link t("Cancel"), href: account_records_path(account) 12 | 13 | assert_field field(Account, :title), with: account.title 14 | 15 | fill_in field(Account, :title), with: "Updated title" 16 | 17 | click_button submit_text(Account, :update) 18 | 19 | assert_current_path account_records_path(account) 20 | assert_css ".alert-success", text: t("Account updated") 21 | assert_css "h1", text: "Updated title" 22 | end 23 | 24 | test "invalid form" do 25 | account = create(:account) 26 | 27 | visit_edit_account_page(account:) 28 | 29 | fill_in field(Account, :title), with: "" 30 | 31 | click_button submit_text(Account, :update) 32 | 33 | assert_current_path edit_account_path(account) 34 | assert_field field(Account, :title), with: "" 35 | assert_css "span", text: t("errors.messages.blank").upcase_first 36 | end 37 | 38 | private 39 | 40 | def visit_edit_account_page(account:) 41 | visit root_path(as: create(:user)) 42 | 43 | click_link t("Accounts") 44 | 45 | click_link account.title, href: account_records_path(account) 46 | 47 | click_link t("Edit"), href: edit_account_path(account) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /config/locales/en/models.yml: -------------------------------------------------------------------------------- 1 | en: 2 | activerecord: 3 | models: 4 | account: &account "Account" 5 | category: &category "Category" 6 | record: &record "Record" 7 | template: &template "Template" 8 | transfer: &transfer "Transfer" 9 | 10 | attributes: 11 | account: 12 | title: "Title" 13 | color: "Color" 14 | initial_amount_cents: "Initial amount" 15 | archived: "Archived" 16 | category: 17 | title: "Title" 18 | color: "Color" 19 | 20 | record: 21 | account: *account 22 | account_id: *account 23 | category: *category 24 | category_id: *category 25 | transfer: *transfer 26 | transfer_id: *transfer 27 | group: "Group" 28 | amount_cents: "Amount" 29 | occurred_on: "Occurred on" 30 | note: "Note" 31 | 32 | template: 33 | title: "Title" 34 | account: *account 35 | account_id: *account 36 | category: *category 37 | category_id: *category 38 | group: "Group" 39 | amount_cents: "Amount" 40 | note: "Note" 41 | 42 | transfer: 43 | from_account: &from_account "From account" 44 | from_account_id: *from_account 45 | to_account: &to_account "To account" 46 | to_account_id: *to_account 47 | amount_cents: "Amount" 48 | occurred_on: "Occurred on" 49 | note: "Note" 50 | 51 | errors: 52 | models: 53 | transfer: 54 | must_be_different_from_origin_account: "must be different from the origin account" 55 | -------------------------------------------------------------------------------- /config/locales/pt-BR/models.yml: -------------------------------------------------------------------------------- 1 | pt-BR: 2 | activerecord: 3 | models: 4 | account: &account "Conta" 5 | category: &category "Categoria" 6 | record: &record "Transação" 7 | template: &template "Modelo" 8 | transfer: &transfer "Transferência" 9 | 10 | attributes: 11 | account: 12 | title: "Nome" 13 | color: "Cor" 14 | initial_amount_cents: "Saldo inicial" 15 | archived: "Arquivada" 16 | category: 17 | title: "Nome" 18 | color: "Cor" 19 | 20 | record: 21 | account: *account 22 | account_id: *account 23 | category: *category 24 | category_id: *category 25 | transfer: *transfer 26 | transfer_id: *transfer 27 | group: "Tipo" 28 | amount_cents: "Valor" 29 | occurred_on: "Ocorrido em" 30 | note: "Anotação" 31 | 32 | template: 33 | title: "Nome" 34 | account: *account 35 | account_id: *account 36 | category: *category 37 | category_id: *category 38 | group: "Tipo" 39 | amount_cents: "Valor" 40 | note: "Anotação" 41 | 42 | transfer: 43 | from_account: &from_account "Conta origem" 44 | from_account_id: *from_account 45 | to_account: &to_account "Conta destino" 46 | to_account_id: *to_account 47 | amount_cents: "Valor" 48 | occurred_on: "Ocorrido em" 49 | note: "Anotação" 50 | 51 | errors: 52 | models: 53 | transfer: 54 | must_be_different_from_origin_account: "deve ser diferente da conta de origem" 55 | -------------------------------------------------------------------------------- /app/views/templates/edit.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | module Templates 3 | class Edit < Views::Base 4 | def initialize(template:, accounts:, categories:) 5 | @template = template 6 | @accounts = accounts 7 | @categories = categories 8 | end 9 | 10 | private 11 | 12 | def view_template 13 | Layouts.Zen do 14 | Bolt.Page do 15 | Bolt.PageHeader sticky: false do 16 | Bolt.PageHeading do 17 | Bolt.PageTitle(drawer_toggle: false) do 18 | t("Edit template") 19 | end 20 | 21 | Bolt.PageSubtitle do 22 | t("Edit template data") 23 | end 24 | end 25 | 26 | Bolt.PageActions do 27 | Bolt.LinkButton( 28 | href: template_path(@template), 29 | dash: true, 30 | color: :error, 31 | data: { 32 | turbo_method: :delete, 33 | turbo_confirm: t("Are you sure you want to remove this template?") 34 | } 35 | ) do 36 | Lucide.Trash class: "size-4" 37 | plain t("Remove") 38 | end 39 | end 40 | end 41 | 42 | Bolt.PageBody do 43 | Components::Templates.Form( 44 | template: @template, 45 | accounts: @accounts, 46 | categories: @categories 47 | ) 48 | end 49 | end 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /app/components/insights/cashflow_card.rb: -------------------------------------------------------------------------------- 1 | module Components 2 | module Insights 3 | class CashflowCard < Base 4 | PROGRESS_COLOR = { 5 | income: "progress-success", 6 | expense: "progress-error" 7 | }.freeze 8 | 9 | def initialize(cashflow:) 10 | @cashflow = cashflow 11 | @income = cashflow.income 12 | @expense = cashflow.expense 13 | end 14 | 15 | private 16 | 17 | def view_template 18 | Bolt.Panel do 19 | div class: "flex flex-col gap-4" do 20 | div class: "flex flex-col" do 21 | span class: "text-lg font-bold" do 22 | t("Cashflow") 23 | end 24 | 25 | span class: "text-lg font-bold" do 26 | Bolt.CurrencyDisplay(amount: @cashflow.balance) 27 | end 28 | end 29 | 30 | progress_bar(title: t("Income"), amount: @income, group: :income) 31 | progress_bar(title: t("Expense"), amount: @expense, group: :expense) 32 | end 33 | end 34 | end 35 | 36 | def progress_bar(title:, amount:, group:) 37 | div class: "flex flex-col gap-1" do 38 | div class: "flex flex-row justify-between" do 39 | span class: "text-sm" do 40 | title 41 | end 42 | 43 | Bolt.CurrencyDisplay(amount:, size: :sm) 44 | end 45 | 46 | progress class: ["progress h-4", PROGRESS_COLOR[group]], 47 | value: amount.cents.abs, 48 | max: [@income.cents, @expense.cents.abs].max 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /app/javascript/controllers/money_field_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | import SimpleMaskMoney from 'simple-mask-money' 3 | 4 | // Connects to data-controller="money-field" 5 | export default class extends Controller { 6 | #remove = () => {} 7 | 8 | static values = { 9 | allowNegative: Boolean, 10 | symbol: String, 11 | symbolFirst: Boolean, 12 | decimalMark: String, 13 | thousandsSeparator: String, 14 | exponent: Number 15 | } 16 | 17 | connect () { 18 | this.#setupInput() 19 | this.#setupUnmaskOnSubmit() 20 | } 21 | 22 | disconnect () { 23 | this.#remove() 24 | } 25 | 26 | #setupInput () { 27 | this.#remove = SimpleMaskMoney.setMask(this.element, { 28 | allowNegative: this.allowNegativeValue, 29 | decimalSeparator: this.decimalMarkValue, 30 | thousandsSeparator: this.thousandsSeparatorValue, 31 | fractionDigits: this.exponentValue, 32 | fixed: true, 33 | cursor: 'end', 34 | ...this.#symbolOptions() 35 | }) 36 | } 37 | 38 | #setupUnmaskOnSubmit () { 39 | this.element.form.addEventListener('submit', () => { 40 | const maskedValue = this.element.value 41 | this.element.value = maskedValue.replace(/[^0-9-]/g, '') // keep numeric characters 42 | setTimeout(() => { 43 | this.element.value = maskedValue 44 | }, 1) // set masked value again after submit 45 | }) 46 | } 47 | 48 | #symbolOptions () { 49 | if (this.symbolFirstValue) { 50 | return { prefix: `${this.symbolValue} ` } 51 | } else { 52 | return { suffix: ` ${this.symbolValue}` } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/views/transfers/edit.rb: -------------------------------------------------------------------------------- 1 | module Views 2 | module Transfers 3 | class Edit < Views::Base 4 | def initialize(transfer:, accounts:) 5 | @transfer = transfer 6 | @accounts = accounts 7 | end 8 | 9 | private 10 | 11 | def view_template 12 | Layouts.Zen do 13 | Bolt.Page do 14 | Bolt.PageHeader sticky: false do 15 | Bolt.PageHeading do 16 | Bolt.PageTitle(drawer_toggle: false) do 17 | t("Edit transfer") 18 | end 19 | 20 | Bolt.PageSubtitle do 21 | t("Edit transfer data") 22 | end 23 | end 24 | 25 | Bolt.PageActions do 26 | Bolt.LinkButton( 27 | href: transfer_path(@transfer, account_id: request.params[:account_id]), 28 | dash: true, 29 | color: :error, 30 | data: { 31 | turbo_method: :delete, 32 | turbo_confirm: t("Are you sure you want to remove this transfer?") 33 | } 34 | ) do 35 | Lucide.Trash class: "size-4" 36 | plain t("Remove") 37 | end 38 | end 39 | end 40 | 41 | Bolt.PageBody do 42 | Components::Transfers.Form( 43 | transfer: @transfer, 44 | accounts: @accounts, 45 | cancel_href: account_records_path(request.params[:account_id]) 46 | ) 47 | end 48 | end 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/models/template_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TemplateTest < ActiveSupport::TestCase 4 | test "associations" do 5 | template = Template.new 6 | 7 | assert belong_to(:account).matches?(template) 8 | assert belong_to(:category).matches?(template) 9 | end 10 | 11 | test "validations" do 12 | template = create(:template) 13 | 14 | assert validate_presence_of(:title).matches?(template) 15 | assert validate_presence_of(:position).matches?(template) 16 | 17 | assert validate_uniqueness_of(:title) 18 | .case_insensitive 19 | .matches?(template) 20 | 21 | assert validate_numericality_of(:amount_cents) 22 | .is_greater_than_or_equal_to(0) 23 | .matches?(template) 24 | 25 | assert validate_length_of(:title).is_at_most(40).matches?(template) 26 | 27 | assert validate_length_of(:note) 28 | .is_at_most(32) 29 | .allow_nil 30 | .matches?(template) 31 | end 32 | 33 | test ".next_position" do 34 | assert_equal 1, Template.next_position 35 | 36 | create(:template, position: 1) 37 | assert_equal 2, Template.next_position 38 | 39 | create(:template, position: 829) 40 | assert_equal 830, Template.next_position 41 | end 42 | 43 | test "#amount" do 44 | expense = Template.new( 45 | group: :expense, 46 | amount_cents: 1234 47 | ) 48 | income = Template.new( 49 | group: :income, 50 | amount_cents: 1234 51 | ) 52 | no_amount = Template.new(group: :expense, amount_cents: 0) 53 | 54 | assert_equal Money.new(-12_34), expense.amount 55 | assert_equal Money.new(12_34), income.amount 56 | assert no_amount.amount.zero? 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /app/lib/period.rb: -------------------------------------------------------------------------------- 1 | class Period < Literal::Enum(String) 2 | prop :range_proc, _Callable 3 | prop :label, String 4 | 5 | def label_i18n = I18n.t(@label, scope: :periods) 6 | 7 | def range = range_proc.call 8 | 9 | ThisMonth = new( 10 | "tm", 11 | range_proc: -> { Time.zone.today.all_month }, 12 | label: "This month" 13 | ) 14 | 15 | LastMonth = new( 16 | "lm", 17 | range_proc: -> { 1.month.ago.to_date.all_month }, 18 | label: "Last month" 19 | ) 20 | 21 | ThisYear = new( 22 | "ty", 23 | range_proc: -> { Time.zone.today.all_year }, 24 | label: "This year" 25 | ) 26 | 27 | LastYear = new( 28 | "ly", 29 | range_proc: -> { 1.year.ago.to_date.all_year }, 30 | label: "Last year" 31 | ) 32 | 33 | Last7Days = new( 34 | "7d", 35 | range_proc: -> { 7.days.ago.to_date..Time.zone.today }, 36 | label: "Last 7 days" 37 | ) 38 | 39 | Last30days = new( 40 | "30d", 41 | range_proc: -> { 30.days.ago..Time.zone.today }, 42 | label: "Last 30 days" 43 | ) 44 | 45 | Last3months = new( 46 | "3m", 47 | range_proc: -> { 3.months.ago.to_date..Time.zone.today }, 48 | label: "Last 3 months" 49 | ) 50 | 51 | Last6months = new( 52 | "6m", 53 | range_proc: -> { 6.months.ago.to_date..Time.zone.today }, 54 | label: "Last 6 months" 55 | ) 56 | 57 | Last12months = new( 58 | "12m", 59 | range_proc: -> { 12.months.ago.to_date..Time.zone.today }, 60 | label: "Last 12 months" 61 | ) 62 | 63 | Last24months = new( 64 | "24m", 65 | range_proc: -> { 24.months.ago.to_date..Time.zone.today }, 66 | label: "Last 24 months" 67 | ) 68 | 69 | All = new( 70 | "all", 71 | range_proc: -> {}, 72 | label: "All" 73 | ) 74 | end 75 | -------------------------------------------------------------------------------- /test/operations/insights/calculate_cashflow_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Insights 4 | class CalculateClashflowTest < ActiveSupport::TestCase 5 | test "income calc" do 6 | create(:income, amount_cents: 12_00) 7 | create(:income, amount_cents: 4_00) 8 | create(:expense, amount_cents: 10_00) 9 | create(:transfer, amount_cents: 84_00) 10 | 11 | cashflow = Insights::CalculateCashflow.call.cashflow 12 | 13 | assert_equal Money.new(16_00), cashflow.income 14 | end 15 | 16 | test "expense calc" do 17 | create(:expense, amount_cents: 23_00) 18 | create(:expense, amount_cents: 5_00) 19 | create(:income, amount_cents: 3_00) 20 | create(:transfer, amount_cents: 120_00) 21 | 22 | cashflow = Insights::CalculateCashflow.call.cashflow 23 | 24 | assert_equal Money.new(-28_00), cashflow.expense 25 | end 26 | 27 | test "period filter" do 28 | create(:income, amount_cents: 12_00, occurred_on: 1.day.ago) 29 | create(:income, amount_cents: 4_00, occurred_on: 1.year.ago) 30 | create(:expense, amount_cents: 10_00, occurred_on: 2.days.ago) 31 | create(:expense, amount_cents: 3_00, occurred_on: 6.months.ago) 32 | create(:transfer, amount_cents: 84_00, occurred_on: 1.day.ago) 33 | 34 | period = 3.days.ago..Time.zone.today 35 | cashflow = Insights::CalculateCashflow.call(period:).cashflow 36 | 37 | assert_equal Money.new(12_00), cashflow.income 38 | assert_equal Money.new(-10_00), cashflow.expense 39 | 40 | period = 2.years.ago..3.months.ago 41 | cashflow = Insights::CalculateCashflow.call(period:).cashflow 42 | 43 | assert_equal Money.new(4_00), cashflow.income 44 | assert_equal Money.new(-3_00), cashflow.expense 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/system/records/update_test.rb: -------------------------------------------------------------------------------- 1 | require "application_system_test_case" 2 | 3 | module Records 4 | class UpdateSystemTest < ApplicationSystemTestCase 5 | test "valid form" do 6 | record = create(:record) 7 | 8 | visit_edit_record_page(record) 9 | 10 | assert_css "h1", text: t("Edit record") 11 | assert_link t("Cancel"), href: account_records_path(record.account) 12 | 13 | fill_in field(Record, :amount_cents), 14 | with: "9582", 15 | fill_options: {clear: :backspace} 16 | fill_in field(Record, :occurred_on), with: "2020-01-01" 17 | 18 | click_button submit_text(Record, :update) 19 | 20 | assert_current_path account_records_path(record.account) 21 | assert_css ".alert-success", text: t("Record updated") 22 | assert_css "li", text: Money.new(95_82).format 23 | assert_text I18n.l(Date.parse("2020-01-01"), format: :full_date) 24 | end 25 | 26 | test "invalid form" do 27 | record = create(:record) 28 | 29 | visit_edit_record_page(record) 30 | 31 | fill_in field(Record, :occurred_on), with: "" 32 | 33 | click_button submit_text(Record, :update) 34 | 35 | assert_current_path edit_account_record_path(record.account, record) 36 | assert_field field(Record, :occurred_on), with: "" 37 | assert_css "span", text: t("errors.messages.blank").upcase_first 38 | end 39 | 40 | private 41 | 42 | def visit_edit_record_page(record) 43 | account = record.account 44 | 45 | visit root_path(as: create(:user)) 46 | 47 | click_link t("Accounts") 48 | 49 | click_link account.title, href: account_records_path(account) 50 | 51 | click_link record.category.title, 52 | href: edit_account_record_path(account, record) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/operations/insights/calculate_spending_breakdown_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Insights 4 | class CalculateSpendingBreakdownTest < ActiveSupport::TestCase 5 | test "total grouped by category" do 6 | food = create(:category, title: "Food", color: "#000000") 7 | car = create(:category, title: "Car", color: "#ffffff") 8 | 9 | create_list(:expense, 3, category: food, amount_cents: 3_00) 10 | create_list(:expense, 2, category: car, amount_cents: 50_00) 11 | create(:income) 12 | 13 | result = Insights::CalculateSpendingBreakdown.call 14 | spending_breakdown = result.spending_breakdown 15 | 16 | assert_equal 2, spending_breakdown.items.size 17 | 18 | assert_equal "Car", spending_breakdown.items.first.label 19 | assert_equal "#ffffff", spending_breakdown.items.first.color 20 | assert_equal Money.new(100_00), spending_breakdown.items.first.total 21 | 22 | assert_equal "Food", spending_breakdown.items.second.label 23 | assert_equal "#000000", spending_breakdown.items.second.color 24 | assert_equal Money.new(9_00), spending_breakdown.items.second.total 25 | end 26 | 27 | test "period filter" do 28 | expense = create(:expense, occurred_on: 1.day.ago) 29 | _other_expense = create(:expense, occurred_on: 1.year.ago) 30 | period = 7.days.ago..Time.zone.today 31 | 32 | result = Insights::CalculateSpendingBreakdown.call(period:) 33 | spending_breakdown = result.spending_breakdown 34 | 35 | assert_equal 1, spending_breakdown.items.size 36 | 37 | assert_equal expense.category.title, 38 | result.spending_breakdown.items.first.label 39 | 40 | assert_equal expense.amount.abs, 41 | result.spending_breakdown.items.first.total 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /app/controllers/templates_controller.rb: -------------------------------------------------------------------------------- 1 | class TemplatesController < ApplicationController 2 | def index 3 | templates = Template.order(:position) 4 | 5 | render Views::Templates::Index.new(templates:) 6 | end 7 | 8 | def new 9 | template = Template.new 10 | 11 | render Views::Templates::New.new(template:, accounts:, categories:) 12 | end 13 | 14 | def edit 15 | template = Template.find(params[:id]) 16 | 17 | render Views::Templates::Edit.new(template:, accounts:, categories:) 18 | end 19 | 20 | def create 21 | case Templates::Create.call(attributes: template_params) 22 | in Templates::Create::Success 23 | redirect_to templates_path, notice: t("Template created") 24 | in Templates::Create::Failure(template) 25 | render Views::Templates::New.new(template:, accounts:, categories:), 26 | status: :unprocessable_content 27 | end 28 | end 29 | 30 | def update 31 | case Templates::Update.call(id: params[:id], attributes: template_params) 32 | in Templates::Update::Success 33 | redirect_to templates_path, notice: t("Template updated") 34 | in Templates::Update::Failure(template) 35 | render Views::Templates::Edit.new(template:, accounts:, categories:), 36 | status: :unprocessable_content 37 | end 38 | end 39 | 40 | def destroy 41 | case Templates::Destroy.call(id: params[:id]) 42 | in Templates::Destroy::Success 43 | redirect_to templates_path, notice: t("Template removed") 44 | end 45 | end 46 | 47 | private 48 | 49 | def template_params 50 | params.expect( 51 | template: %i[title group account_id category_id amount_cents note] 52 | ).to_h 53 | end 54 | 55 | def accounts = Account.active.order(:position) 56 | 57 | def categories = Category.order(:position) 58 | end 59 | --------------------------------------------------------------------------------