├── .gitignore ├── public ├── app.css └── app.js ├── app ├── inflector.rb ├── models │ ├── fio_transaction.rb │ ├── expense.rb │ ├── category.rb │ └── rule.rb ├── services │ ├── application_service.rb │ └── import_transactions.rb ├── views │ ├── flash.erb │ ├── icons │ │ ├── trash.erb │ │ ├── eye.erb │ │ └── eye_off.erb │ ├── categories │ │ ├── new.erb │ │ └── index.erb │ ├── expenses │ │ ├── new.erb │ │ ├── index.erb │ │ └── _expense.erb │ ├── navbar.erb │ ├── rules │ │ ├── new.erb │ │ └── index.erb │ ├── layout.erb │ └── home.erb ├── helpers.rb └── router.rb ├── config ├── types.rb ├── boot.rb ├── initializers │ ├── money.rb │ └── sequel.rb ├── settings.rb └── loader.rb ├── config.ru ├── README.md ├── db ├── migrations │ ├── 20221001115823_create_categories.rb │ ├── 20230114181444_nullify_expense_category_id_on_deletion.rb │ ├── 20230115121453_create_rules.rb │ ├── 20221020231423_create_fio_transactions.rb │ └── 20221001115843_create_expenses.rb └── schema.sql ├── Gemfile ├── Rakefile └── Gemfile.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | tmp/ 3 | -------------------------------------------------------------------------------- /public/app.css: -------------------------------------------------------------------------------- 1 | .tabular-nums { 2 | font-variant-numeric: tabular-nums; 3 | } 4 | -------------------------------------------------------------------------------- /app/inflector.rb: -------------------------------------------------------------------------------- 1 | require "dry/inflector" 2 | 3 | Inflector = Dry::Inflector.new 4 | -------------------------------------------------------------------------------- /config/types.rb: -------------------------------------------------------------------------------- 1 | require "dry/types" 2 | 3 | module Types 4 | include Dry.Types 5 | end 6 | -------------------------------------------------------------------------------- /app/models/fio_transaction.rb: -------------------------------------------------------------------------------- 1 | class FioTransaction < Sequel::Model 2 | one_to_one :expense 3 | end 4 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require_relative "config/boot" 2 | 3 | use ReloadMiddleware if Settings.env == "development" 4 | 5 | run -> (env) { Router.call(env) } 6 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require_relative "settings" 3 | 4 | Dir.glob("#{__dir__}/initializers/*.rb") { |file| require file } 5 | 6 | require_relative "loader" 7 | -------------------------------------------------------------------------------- /config/initializers/money.rb: -------------------------------------------------------------------------------- 1 | require "money" 2 | 3 | Money.default_currency = Money::Currency.new("CZK") 4 | Money.locale_backend = :currency 5 | Money.rounding_mode = BigDecimal::ROUND_HALF_EVEN 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Budget App 2 | 3 | This is a Roda & Sequel app for tracking my personal budget. 4 | 5 | I open sourced it to share an example of a Hanami-like project structure with Roda, with autoloading and reloading backed by Zeitwerk. 6 | -------------------------------------------------------------------------------- /app/services/application_service.rb: -------------------------------------------------------------------------------- 1 | require "dry/initializer" 2 | 3 | class ApplicationService 4 | extend Dry::Initializer 5 | 6 | def self.call(*args, **options, &) 7 | new(*args, **options).call(&) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrations/20221001115823_create_categories.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table :categories do 4 | primary_key :id 5 | String :name, null: false 6 | String :color, null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/views/flash.erb: -------------------------------------------------------------------------------- 1 | <% if flash["notice"] %> 2 |
<%= flash["notice"] %>
3 | <% end %> 4 | <% if flash["error"] %> 5 |
<%= flash["error"] %>
6 | <% end %> 7 | -------------------------------------------------------------------------------- /db/migrations/20230114181444_nullify_expense_category_id_on_deletion.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | alter_table :expenses do 4 | drop_foreign_key [:category_id] 5 | add_foreign_key [:category_id], :categories, on_delete: :set_null 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/models/expense.rb: -------------------------------------------------------------------------------- 1 | class Expense < Sequel::Model 2 | many_to_one :category 3 | many_to_one :fio_transaction 4 | 5 | def self.last_updated 6 | reverse(:updated_at).first 7 | end 8 | 9 | def apply_rules(rules) 10 | rules.each do |rule| 11 | rule.apply(self) if rule.matches_expense?(self) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/views/icons/trash.erb: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /db/migrations/20230115121453_create_rules.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table :rules do 4 | primary_key :id 5 | 6 | String :field, null: false 7 | String :operator, null: false 8 | String :value, null: false 9 | 10 | foreign_key :category_id, :categories, on_delete: :cascade 11 | boolean :ignore 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/views/categories/new.erb: -------------------------------------------------------------------------------- 1 | <% form @new_category, { method: :post, action: categories_path, class: "row row-cols-lg-auto g-3 align-items-center" }, wrapper: :div do |f| %> 2 | <%= f.input :name, class: "form-control", placeholder: "New Category...", label: false %> 3 | <%= f.input :color, type: :color, class: "form-control-color", label: false %> 4 | <%= f.button value: "Create Category", class: "btn btn-primary" %> 5 | <% end %> 6 | -------------------------------------------------------------------------------- /config/initializers/sequel.rb: -------------------------------------------------------------------------------- 1 | require "sequel" 2 | require "forme" 3 | 4 | DB = Sequel.connect(Settings.database_url) 5 | DB.logger = Settings.logger 6 | DB.extension :pg_json, :null_dataset, :pagination 7 | 8 | Sequel::Model.cache_associations = false if Settings.env == "development" 9 | 10 | Sequel::Model.plugin :forme_set 11 | Sequel::Model.plugin :timestamps, update_on_create: true 12 | Sequel::Model.plugin :boolean_readers 13 | -------------------------------------------------------------------------------- /app/models/category.rb: -------------------------------------------------------------------------------- 1 | class Category < Sequel::Model 2 | def self.monthly_breakdown 3 | Expense 4 | .select_group( 5 | Sequel.extract(:year, :date).cast(Integer).as(:year), 6 | Sequel.extract(:month, :date).cast(Integer).as(:month), 7 | :category_id, 8 | ) 9 | .exclude(:ignore) 10 | .select_append(Sequel.function(:sum, :amount).as(:total)) 11 | .reverse(:year, :month) 12 | .naked 13 | .to_a 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/views/icons/eye.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/views/icons/eye_off.erb: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /db/migrations/20221020231423_create_fio_transactions.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | change do 3 | create_table :fio_transactions do 4 | primary_key :id 5 | String :external_id, null: false, unique: true 6 | Float :amount, null: false 7 | Date :date, null: false 8 | String :account 9 | String :note 10 | String :message 11 | String :type 12 | end 13 | 14 | alter_table :expenses do 15 | add_foreign_key :fio_transaction_id, :fio_transactions, on_delete: :cascade, unique: true 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /db/migrations/20221001115843_create_expenses.rb: -------------------------------------------------------------------------------- 1 | Sequel.migration do 2 | up do 3 | extension :pg_json 4 | 5 | create_table :expenses do 6 | primary_key :id 7 | foreign_key :category_id, :categories 8 | String :description 9 | Float :amount, null: false 10 | Date :date, null: false 11 | TrueClass :ignore, null: false, default: false 12 | Time :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP 13 | Time :updated_at, null: false, default: Sequel::CURRENT_TIMESTAMP 14 | end 15 | end 16 | 17 | down do 18 | drop_table :expenses 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/views/expenses/new.erb: -------------------------------------------------------------------------------- 1 | <% form @new_expense, { method: :post, action: expenses_path, class: "row row-cols-lg-auto g-3 align-items-center" }, wrapper: :div do |f| %> 2 | <%= f.input :description, class: "form-control", placeholder: "Description...", label: false %> 3 | <%= f.input :amount, class: "form-control", placeholder: "CZK", label: false, attr: { inputmode: "numeric", pattern: "-?[0-9.]*" } %> 4 | <%= f.input :date, label: false, class: "form-control" %> 5 | <%= f.input :category, label: false, class: "form-select", add_blank: "Choose Category...", options: @categories.map { |category| [category.name, category.id] } %> 6 | <%= f.button value: "Create Expense", class: "btn btn-primary" %> 7 | <% end %> 8 | -------------------------------------------------------------------------------- /app/views/navbar.erb: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /app/models/rule.rb: -------------------------------------------------------------------------------- 1 | class Rule < Sequel::Model 2 | many_to_one :category 3 | 4 | def matching_expenses 5 | Expense 6 | .where(category_id: nil) 7 | .where(Sequel.like(field.to_sym, "%#{value}%")) 8 | end 9 | 10 | def matches_expense?(expense) 11 | return false if expense.category_id 12 | return false if ignore && expense.fio_transaction_id.nil? 13 | 14 | expense.send(field).to_s.include?(value) 15 | end 16 | 17 | def apply_all 18 | matching_expenses.each do |expense| 19 | apply(expense) 20 | end 21 | end 22 | 23 | def apply(expense) 24 | if category_id 25 | expense.update(category_id: category_id) 26 | elsif ignore 27 | expense.update(ignore: ignore) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /public/app.js: -------------------------------------------------------------------------------- 1 | import { Application, Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js" 2 | window.Stimulus = Application.start() 3 | 4 | Stimulus.register("category", class extends Controller { 5 | static targets = ["label", "input"] 6 | 7 | edit() { 8 | this.labelTarget.classList.add('d-none') 9 | this.inputTarget.classList.remove('d-none') 10 | } 11 | }) 12 | 13 | Stimulus.register("amount", class extends Controller { 14 | static targets = ["label", "input"] 15 | 16 | edit() { 17 | this.labelTarget.classList.add('d-none') 18 | this.inputTarget.classList.remove('d-none') 19 | } 20 | }) 21 | 22 | Stimulus.register("form", class extends Controller { 23 | submit() { 24 | this.element.requestSubmit() 25 | } 26 | }) 27 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "roda", "~> 3.60" 4 | gem "puma", "~> 6.4" 5 | gem "rackup", "~> 0.2.2" 6 | 7 | gem "sequel", "~> 5.60" 8 | gem "pg", "~> 1.4" 9 | gem "sequel_tools", "~> 0.1.14" 10 | gem "irb", "~> 1.15" 11 | 12 | gem "tilt", "~> 2.0" 13 | gem "erubi", "~> 1.11" 14 | gem "forme", "~> 2.2" 15 | gem "roda-turbo", "~> 1.0" 16 | gem "money", "~> 6.16" 17 | gem "pagy", "~> 6.0" 18 | gem "bigdecimal", "~> 3.1" 19 | 20 | gem "http", "~> 5.1" 21 | gem "base64", "~> 0.2.0" 22 | gem "dry-initializer", "~> 3.1" 23 | gem "dry-types", "~> 1.5" 24 | gem "dry-inflector", "~> 0.3.0" 25 | gem "logger" 26 | 27 | gem "zeitwerk", "~> 2.6" 28 | gem "listen", "~> 3.7" 29 | gem "dry-configurable", "~> 0.15.0" 30 | gem "dotenv", "~> 2.8" 31 | gem "concurrent-ruby", "~> 1.1" 32 | -------------------------------------------------------------------------------- /app/views/rules/new.erb: -------------------------------------------------------------------------------- 1 | <% form @new_rule, { method: :post, action: rules_path, class: "row row-cols-lg-auto g-3 align-items-center" }, wrapper: :div do |f| %> 2 | <%= f.input :field, type: :select, class: "form-select", label: false, options: { "Description" => "description" } %> 3 | <%= f.input :operator, type: :select, class: "form-select", label: false, options: { "Contains" => "contains" } %> 4 | <%= f.input :value, class: "form-control", label: false, placeholder: "Enter text..." %> 5 | <%= f.input :category_id, type: :select, class: "form-select", label: false, add_blank: "Set category...", options: @categories.map { |category| [category.name, category.id] } %> 6 | <%= f.input :ignore, as: :checkbox, class: "form-check-input", label: "Ignore" %> 7 | <%= f.button value: "Create Rule", class: "btn btn-primary" %> 8 | <% end %> 9 | -------------------------------------------------------------------------------- /config/settings.rb: -------------------------------------------------------------------------------- 1 | require "dry/configurable" 2 | require "concurrent/atomic/read_write_lock" 3 | require "dotenv" 4 | require "pathname" 5 | require_relative "types" 6 | 7 | class Settings 8 | extend Dry::Configurable 9 | 10 | setting :root, default: "#{__dir__}/..", constructor: -> (path) { Pathname(path).expand_path }, reader: true 11 | setting :env, default: ENV.fetch("RACK_ENV", "development"), reader: true 12 | setting :logger, default: Logger.new($stdout), reader: true 13 | 14 | Dotenv.load(root.join(".env")) 15 | 16 | setting :database_url, default: ENV.fetch("DATABASE_URL"), reader: true 17 | setting :secret_key, default: ENV.fetch("SECRET_KEY"), reader: true 18 | setting :fio_token_business, default: ENV.fetch("FIO_TOKEN_BUSINESS"), reader: true 19 | setting :fio_token_shared, default: ENV.fetch("FIO_TOKEN_SHARED"), reader: true 20 | end 21 | -------------------------------------------------------------------------------- /app/views/categories/index.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title, "Categories" %> 2 | 3 |

Categories

4 | 5 |
6 | <%= render "categories/new" %> 7 |
8 | 9 | <% if @categories.any? %> 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | <% @categories.each do |category| %> 21 | 22 | 23 | 24 | 25 | 26 | <% end %> 27 | 28 |
NameColor
<%= category.name %><%= category.color %><%= link_to icon(:trash), path(category), "data-turbo-method": "delete", "data-turbo-confirm": "Are you Sure?", class: "text-danger" %>
29 | <% end %> 30 | -------------------------------------------------------------------------------- /app/helpers.rb: -------------------------------------------------------------------------------- 1 | require "pagy" 2 | require "pagy/extras/bootstrap" 3 | 4 | module Helpers 5 | include Pagy::Frontend 6 | 7 | def nav_links 8 | { 9 | "Home" => root_path, 10 | "Expenses" => expenses_path, 11 | "Rules" => rules_path, 12 | "Categories" => categories_path, 13 | } 14 | end 15 | 16 | def date(date) 17 | date.strftime("%d.%m.%Y") 18 | end 19 | 20 | def price(value, currency: true) 21 | text = Money.from_amount(value).format 22 | text.sub!(/,\d{2}/, "") # remove cents 23 | text.sub!(" Kč", "") unless currency 24 | text 25 | end 26 | 27 | def icon(name) 28 | render "icons/#{name}" 29 | end 30 | 31 | def truncate(text, length: 60) 32 | if text.length >= length 33 | text[0...length] + "..." 34 | else 35 | text 36 | end 37 | end 38 | 39 | def dom_id(record) 40 | "#{Inflector.dasherize(Inflector.singularize(record.model.table_name))}-#{record.id}" 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /app/views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Budget • <%= content_for :title %> 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | <%= assets(:css) %> 13 | <%= assets(:js, type: "module") %> 14 | 15 | 16 | 17 | <%= render "navbar" %> 18 | 19 |
20 | <%= render "flash" %> 21 | <%= yield %> 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /app/views/expenses/index.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title, "Expenses" %> 2 | 3 |

Expenses

4 | 5 |
6 | <%= render "expenses/new" %> 7 |
8 | 9 |
10 | <% form({ method: :get, class: "row row-cols-lg-auto g-3 align-items-center" }, wrapper: :div) do |f| %> 11 | <%= f.input :select, name: "category_id", value: request.params["category_id"]&.to_i, class: "form-select", add_blank: "No category selected", options: @categories.map { |category| [category.name, category.id] } %> 12 | <%= f.button value: "Search", class: "btn btn-success" %> 13 | <% end %> 14 |
15 | 16 | <% if @expenses.any? %> 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | <% @expenses.each do |expense| %> 30 | <%= partial "expenses/expense", locals: { expense: expense, categories: @categories } %> 31 | <% end %> 32 | 33 |
AmountDescriptionDateCategory
34 | <% end %> 35 | 36 | <%= pagy_bootstrap_nav(@pagy) %> 37 | -------------------------------------------------------------------------------- /config/loader.rb: -------------------------------------------------------------------------------- 1 | require "zeitwerk" 2 | 3 | loader = Zeitwerk::Loader.new 4 | loader.push_dir Settings.root.join("app") 5 | loader.collapse Settings.root.join("app/*") 6 | loader.enable_reloading if Settings.env == "development" 7 | loader.setup 8 | loader.eager_load if Settings.env == "production" || ENV["CI"] 9 | 10 | if Settings.env == "development" 11 | require "listen" 12 | require "concurrent/atomic/read_write_lock" 13 | require "singleton" 14 | 15 | class Reloader 16 | include Singleton 17 | 18 | LISTEN_PATHS = [ 19 | Settings.root.join("app"), 20 | Settings.root.join("db"), 21 | ] 22 | 23 | def initialize 24 | @lock = Concurrent::ReadWriteLock.new 25 | @listener = Listen.to(*LISTEN_PATHS) { reload } 26 | @listener.start 27 | end 28 | 29 | def wrap 30 | @lock.with_read_lock { yield } 31 | end 32 | 33 | def reload 34 | @lock.with_write_lock { loaders.each(&:reload) } 35 | end 36 | 37 | private 38 | 39 | def loaders 40 | Zeitwerk::Registry.loaders.select(&:reloading_enabled?) 41 | end 42 | end 43 | 44 | class ReloadMiddleware 45 | def initialize(app) 46 | @app = app 47 | end 48 | 49 | def call(env) 50 | Reloader.instance.wrap { @app.call(env) } 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /app/views/rules/index.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title, "Rules" %> 2 | 3 |

Rules

4 | 5 |
6 | <%= render "rules/new" %> 7 |
8 | 9 | <% if @rules.any? %> 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | <% @rules.each do |rule| %> 23 | 24 | 25 | 26 | 27 | 34 | 38 | 39 | <% end %> 40 | 41 |
FieldOperatorValueAction
<%= rule.field.capitalize %><%= rule.operator %><%= rule.value %> 28 | <% if rule.category %> 29 | <%= rule.category.name %> 30 | <% elsif rule.ignore %> 31 | Ignore 32 | <% end %> 33 | 35 | <%= link_to "Apply", apply_rule_path(rule), class: "btn btn-outline-success", "data-turbo-method": "post" %> 36 | <%= link_to icon(:trash), path(rule), "data-turbo-method": "delete", "data-turbo-confirm": "Are you Sure?", class: "text-danger" %> 37 |
42 | <% end %> 43 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require_relative "config/settings" 3 | require "sequel_tools" 4 | require "sequel/core" 5 | 6 | db = Sequel.connect(Settings.database_url, test: false, keep_reference: false) 7 | 8 | namespace :db do 9 | SequelTools.inject_rake_tasks({ 10 | dbadapter: db.opts[:adapter], 11 | dbname: db.opts[:database], 12 | dump_schema_on_migrate: Settings.env == "development", 13 | schema_location: "db/schema.sql", 14 | log_level: :info, 15 | sql_log_level: :info, 16 | }, self) 17 | end 18 | 19 | desc "Open a ruby console with the application loaded" 20 | task :console => :boot do 21 | require "irb" 22 | ARGV.clear 23 | IRB.start 24 | end 25 | 26 | desc "Import new transactions into the database" 27 | task :import => :boot do 28 | from = [DB[:expenses].max(:date), Date.today - 90].max 29 | to = Date.today 30 | 31 | ImportTransactions.call(from: from, to: to, fio_token: Settings.fio_token_business) 32 | ImportTransactions.call(from: from, to: to, fio_token: Settings.fio_token_shared) 33 | end 34 | 35 | task :backup do 36 | system "pg_dump", db.opts[:database], out: "tmp/backup-#{Date.today.strftime("%Y%m%d")}.sql" 37 | end 38 | 39 | task :restore do 40 | dump = Dir["tmp/backup-*.sql", sort: true].last 41 | system "dropdb", db.opts[:database] 42 | system "createdb", db.opts[:database] 43 | system "psql", db.opts[:database], in: dump 44 | end 45 | 46 | task :boot do 47 | require_relative "config/boot" 48 | end 49 | -------------------------------------------------------------------------------- /app/services/import_transactions.rb: -------------------------------------------------------------------------------- 1 | require "http" 2 | require "date" 3 | 4 | # https://www.fio.cz/docs/cz/API_Bankovnictvi.pdf 5 | class ImportTransactions < ApplicationService 6 | FIO_API_URL = "https://www.fio.cz/ib_api/rest" 7 | 8 | option :fio_token, Types::String 9 | option :from, Types::Date 10 | option :to, Types::Date 11 | 12 | def call 13 | transactions = fetch_transactions 14 | 15 | import_transactions(transactions) 16 | 17 | sync_expenses 18 | end 19 | 20 | private 21 | 22 | def sync_expenses 23 | new_transactions = FioTransaction 24 | .where{amount < 0} 25 | .exclude(expense: Expense.dataset) 26 | 27 | rules = Rule.order(:id).all 28 | 29 | values = new_transactions.each do |transaction| 30 | expense = Expense.create( 31 | fio_transaction: transaction, 32 | description: transaction.message || transaction.note, 33 | amount: -transaction.amount, 34 | date: transaction.date, 35 | ) 36 | expense.apply_rules(rules) 37 | end 38 | end 39 | 40 | def import_transactions(transactions) 41 | values = transactions.map do |transaction| 42 | { 43 | external_id: transaction.fetch("ID pohybu"), 44 | amount: transaction.fetch("Objem"), 45 | date: Date.parse(transaction.fetch("Datum")), 46 | account: transaction["Protiúčet"] ? [transaction.fetch("Protiúčet"), transaction["Kód banky"]].compact.join(" / ") : nil, 47 | message: transaction["Zpráva pro příjemce"], 48 | note: transaction["Komentář"], 49 | type: transaction.fetch("Typ"), 50 | } 51 | rescue KeyError 52 | p transaction 53 | raise 54 | end 55 | 56 | DB[:fio_transactions].insert_ignore.multi_insert(values, commit_every: 1000) 57 | end 58 | 59 | def fetch_transactions 60 | transactions = request_transactions 61 | transactions.map do |transaction| 62 | transaction.values.each_with_object({}) do |col, result| 63 | result[col.fetch("name")] = col.fetch("value") if col 64 | end 65 | end 66 | end 67 | 68 | def request_transactions 69 | response = HTTP.get("#{FIO_API_URL}/periods/#{fio_token}/#{from}/#{to}/transactions.json") 70 | 71 | fail response.body.to_s unless response.status.ok? 72 | 73 | data = response.parse(:json) 74 | data["accountStatement"]["transactionList"]["transaction"] 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /app/views/expenses/_expense.erb: -------------------------------------------------------------------------------- 1 | " id="<%= dom_id(expense) %>"> 2 | 3 | <%# %> 4 | <%= price expense.amount %> 5 | <%# %> 6 | 7 | <%# <% form expense, { method: :put, action: path(expense), "data-controller": "form" }, wrapper: :div do |f| %1> %> 8 | <%# <%= f.input :amount, label: false, class: "form-control d-none", attr: { "data-action": "blur->form#submit", "data-amount-target": "input" } %1> %> 9 | <%# <% end %1> %> 10 | 11 | <%= truncate expense.description.to_s %> 12 | <%= date(expense.date) %> 13 | 14 | <% unless expense.ignore %> 15 |
16 | <% if expense.category %> 17 | <%= expense.category.name %> 18 | <% else %> 19 | Uncategorized 20 | <% end %> 21 |
22 | 23 | <% form expense, { method: :put, action: path(expense), "data-controller": "form" }, wrapper: :div do |f| %> 24 | <%= f.input :category, label: false, class: "form-select d-none", 25 | add_blank: "Uncategorized", 26 | options: categories.map { |category| [category.name, category.id] }, 27 | attr: { "data-action": "form#submit", "data-category-target": "input" } %> 28 | <% end %> 29 | <% end %> 30 | 31 | 32 | <%= link_to icon(:trash), path(expense), "data-turbo-method": "delete", "data-turbo-confirm": "Are you Sure?", class: "text-danger" unless expense.fio_transaction_id %> 33 | <% if expense.fio_transaction_id %> 34 | <% form expense, { method: :put, action: path(expense), "data-controller": "form" }, wrapper: :div do |f| %> 35 | 38 | <%= f.input :ignore, label: false, class: "d-none", id: "expense-#{expense.id}-ignore", attr: { "data-action": "form#submit" } %> 39 | <% end %> 40 | <% end %> 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/views/home.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title, "Home" %> 2 | 3 | <% @spending.group_by { |item| item[:year] }.each do |year, data| %> 4 |

Year <%= year %>

5 | 6 | <% months = year == Date.today.year ? Date.today.mon : 12 %> 7 | 8 |
9 | 10 | 11 | 12 | 13 | <% @categories.each do |category| %> 14 | 15 | <% end %> 16 | 17 | 18 | 19 | 20 | 21 | <% data.group_by { |item| item[:month] }.each do |month, items| %> 22 | 23 | 24 | <% @categories.each do |category| %> 25 | 26 | <% end %> 27 | 28 | 29 | 30 | <% end %> 31 | 32 | 33 | <% @categories.each do |category| %> 34 | 39 | <% end %> 40 | 45 | 50 | 51 | 52 |
Month<%= category.name %>UncategorizedTotal
<%= Date.new(year, month).strftime("%B") %><%= price items.find { |item| item[:category_id] == category.id }&.fetch(:total) || 0, currency: false %><%= price items.find { |item| item[:category_id] == nil }&.fetch(:total) || 0, currency: false %><%= price items.sum { |item| item.fetch(:total) }, currency: false %>
Total 35 | <%= price data.select { |item| item[:category_id] == category.id }.sum(0) { |item| item[:total] }, currency: false %> 36 |
37 | (<%= price data.select { |item| item[:category_id] == category.id }.sum(0) { |item| item[:total] } / months, currency: false %>) 38 |
41 | <%= price data.select { |item| item[:category_id] == nil }.sum(0) { |item| item[:total] }, currency: false %> 42 |
43 | (<%= price data.select { |item| item[:category_id] == nil }.sum(0) { |item| item[:total] } / months, currency: false %>) 44 |
46 | <%= price data.sum { |item| item.fetch(:total) }, currency: false %> 47 |
48 | (<%= price data.sum { |item| item.fetch(:total) } / months, currency: false %>) 49 |
53 |
54 | <% end %> 55 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.8.7) 5 | public_suffix (>= 2.0.2, < 7.0) 6 | base64 (0.2.0) 7 | bigdecimal (3.1.8) 8 | concurrent-ruby (1.3.5) 9 | date (3.4.1) 10 | domain_name (0.6.20240107) 11 | dotenv (2.8.1) 12 | dry-configurable (0.15.0) 13 | concurrent-ruby (~> 1.0) 14 | dry-core (~> 0.6) 15 | dry-container (0.11.0) 16 | concurrent-ruby (~> 1.0) 17 | dry-core (0.9.1) 18 | concurrent-ruby (~> 1.0) 19 | zeitwerk (~> 2.6) 20 | dry-inflector (0.3.0) 21 | dry-initializer (3.2.0) 22 | dry-logic (1.3.0) 23 | concurrent-ruby (~> 1.0) 24 | dry-core (~> 0.9, >= 0.9) 25 | zeitwerk (~> 2.6) 26 | dry-types (1.6.1) 27 | concurrent-ruby (~> 1.0) 28 | dry-container (~> 0.3) 29 | dry-core (~> 0.9, >= 0.9) 30 | dry-inflector (~> 0.1, >= 0.1.2) 31 | dry-logic (~> 1.3, >= 1.3) 32 | zeitwerk (~> 2.6) 33 | erb (5.0.1) 34 | erubi (1.11.0) 35 | ffi (1.17.2-arm64-darwin) 36 | ffi-compiler (1.3.2) 37 | ffi (>= 1.15.5) 38 | rake 39 | forme (2.2.0) 40 | http (5.2.0) 41 | addressable (~> 2.8) 42 | base64 (~> 0.1) 43 | http-cookie (~> 1.0) 44 | http-form_data (~> 2.2) 45 | llhttp-ffi (~> 0.5.0) 46 | http-cookie (1.0.8) 47 | domain_name (~> 0.5) 48 | http-form_data (2.3.0) 49 | i18n (1.14.5) 50 | concurrent-ruby (~> 1.0) 51 | io-console (0.8.0) 52 | irb (1.15.2) 53 | pp (>= 0.6.0) 54 | rdoc (>= 4.0.0) 55 | reline (>= 0.4.2) 56 | listen (3.7.1) 57 | rb-fsevent (~> 0.10, >= 0.10.3) 58 | rb-inotify (~> 0.9, >= 0.9.10) 59 | llhttp-ffi (0.5.1) 60 | ffi-compiler (~> 1.0) 61 | rake (~> 13.0) 62 | logger (1.7.0) 63 | money (6.19.0) 64 | i18n (>= 0.6.4, <= 2) 65 | nio4r (2.7.3) 66 | pagy (6.0.1) 67 | pg (1.4.3) 68 | pp (0.6.2) 69 | prettyprint 70 | prettyprint (0.2.0) 71 | psych (5.2.6) 72 | date 73 | stringio 74 | public_suffix (6.0.2) 75 | puma (6.4.2) 76 | nio4r (~> 2.0) 77 | rack (3.1.15) 78 | rackup (0.2.2) 79 | rack (>= 3.0.0.beta1) 80 | webrick 81 | rake (13.3.0) 82 | rb-fsevent (0.11.2) 83 | rb-inotify (0.10.1) 84 | ffi (~> 1.0) 85 | rdoc (6.14.0) 86 | erb 87 | psych (>= 4.0.0) 88 | reline (0.6.1) 89 | io-console (~> 0.5) 90 | roda (3.60.0) 91 | rack 92 | roda-turbo (1.0.0) 93 | roda (~> 3.50) 94 | sequel (5.61.0) 95 | sequel_tools (0.1.14) 96 | sequel 97 | stringio (3.1.7) 98 | tilt (2.0.11) 99 | webrick (1.7.0) 100 | zeitwerk (2.6.0) 101 | 102 | PLATFORMS 103 | arm64-darwin-21 104 | arm64-darwin-22 105 | arm64-darwin-23 106 | arm64-darwin-24 107 | 108 | DEPENDENCIES 109 | base64 (~> 0.2.0) 110 | bigdecimal (~> 3.1) 111 | concurrent-ruby (~> 1.1) 112 | dotenv (~> 2.8) 113 | dry-configurable (~> 0.15.0) 114 | dry-inflector (~> 0.3.0) 115 | dry-initializer (~> 3.1) 116 | dry-types (~> 1.5) 117 | erubi (~> 1.11) 118 | forme (~> 2.2) 119 | http (~> 5.1) 120 | irb (~> 1.15) 121 | listen (~> 3.7) 122 | logger 123 | money (~> 6.16) 124 | pagy (~> 6.0) 125 | pg (~> 1.4) 126 | puma (~> 6.4) 127 | rackup (~> 0.2.2) 128 | roda (~> 3.60) 129 | roda-turbo (~> 1.0) 130 | sequel (~> 5.60) 131 | sequel_tools (~> 0.1.14) 132 | tilt (~> 2.0) 133 | zeitwerk (~> 2.6) 134 | 135 | BUNDLED WITH 136 | 2.5.13 137 | -------------------------------------------------------------------------------- /app/router.rb: -------------------------------------------------------------------------------- 1 | require "roda" 2 | 3 | class Router < Roda 4 | include Helpers 5 | 6 | plugin :sessions, secret: Settings.secret_key 7 | plugin :assets, css: "app.css", js: "app.js", path: Settings.root.join("public"), css_dir: "", js_dir: "", timestamp_paths: true 8 | plugin :render, views: Settings.root.join("app/views") 9 | plugin :partials 10 | plugin :route_csrf, require_request_specific_tokens: false, check_header: true 11 | plugin :flash 12 | plugin :link_to 13 | plugin :path 14 | plugin :all_verbs 15 | plugin :status_303 # for Turbo 16 | plugin :turbo 17 | plugin :forme_set, secret: Settings.secret_key 18 | plugin :content_for 19 | plugin :typecast_params 20 | 21 | path(:root, "/") 22 | path(:categories, "/categories") 23 | path(Category) { |category| "/categories/#{category.id}" } 24 | path(:expenses, "/expenses") 25 | path(:ignore_expense) { |expense| "/expenses/#{expense.id}/ignore" } 26 | path(Expense) { |expense| "/expenses/#{expense.id}" } 27 | path(:rules, "/rules") 28 | path(:apply_rule) { |rule| "/rules/#{rule.id}/apply" } 29 | path(Rule) { |rule| "/rules/#{rule.id}" } 30 | 31 | route do |r| 32 | r.assets 33 | 34 | check_csrf! 35 | 36 | r.root do 37 | @spending = Category.monthly_breakdown 38 | @categories = Category.order(:name).all 39 | 40 | view "home" 41 | end 42 | 43 | r.on "categories" do 44 | r.get true do 45 | @categories = Category.order(:name).all 46 | @new_category = Category.new 47 | 48 | view "categories/index" 49 | end 50 | 51 | r.post true do 52 | category = forme_set(Category.new).save 53 | 54 | r.redirect categories_path 55 | end 56 | 57 | r.on Integer do |id| 58 | category = Category.with_pk!(id) 59 | 60 | r.delete true do 61 | category.destroy 62 | 63 | r.redirect categories_path 64 | end 65 | end 66 | end 67 | 68 | r.on "expenses" do 69 | r.get true do 70 | expenses = Expense.dataset 71 | expenses = expenses.where(category_id: r.params["category_id"]) if !r.params["category_id"].to_s.empty? 72 | 73 | @pagy, @expenses = pagy expenses.reverse(:date, :id).eager(:category) 74 | @new_expense = Expense.new(date: Expense.last_updated&.date) 75 | @categories = Category.order(:name).all 76 | 77 | view "expenses/index" 78 | end 79 | 80 | r.post true do 81 | expense = forme_set(Expense.new).save 82 | expense.apply_rules(Rule.order(:id).all) 83 | 84 | flash["notice"] = "Expense \"#{expense.description}\" created with category \"#{expense.category&.name}\"" 85 | 86 | r.redirect expenses_path 87 | end 88 | 89 | r.on Integer do |id| 90 | expense = Expense.with_pk!(id) 91 | 92 | r.put true do 93 | forme_set(expense).save 94 | categories = Category.order(:name).all 95 | 96 | turbo_stream.replace dom_id(expense), partial("expenses/expense", locals: { expense: expense, categories: categories }) 97 | end 98 | 99 | r.delete true do 100 | expense.destroy 101 | 102 | turbo_stream.remove dom_id(expense) 103 | end 104 | end 105 | end 106 | 107 | r.on "rules" do 108 | r.get true do 109 | @rules = Rule.order(:id).all 110 | @new_rule = Rule.new 111 | @categories = Category.order(:name).all 112 | 113 | view "rules/index" 114 | end 115 | 116 | r.post true do 117 | rule = forme_set(Rule.new).save 118 | rule.apply_all 119 | 120 | flash["notice"] = "Rule created and applied" 121 | 122 | r.redirect rules_path 123 | end 124 | 125 | r.on Integer do |rule_id| 126 | rule = Rule.with_pk!(rule_id) 127 | 128 | r.post "apply" do 129 | rule.apply_all 130 | 131 | flash["notice"] = "Rule applied" 132 | 133 | r.redirect rules_path 134 | end 135 | 136 | r.delete true do 137 | rule.destroy 138 | 139 | r.redirect rules_path 140 | end 141 | end 142 | end 143 | end 144 | 145 | private 146 | 147 | def pagy(dataset) 148 | tp = typecast_params 149 | dataset = dataset.paginate(tp.pos_int("page") || 1, 100) 150 | pagy = Pagy.new(count: dataset.pagination_record_count, page: dataset.current_page, items: dataset.page_size) 151 | 152 | [pagy, dataset.all] 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /db/schema.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- PostgreSQL database dump 3 | -- 4 | 5 | -- Dumped from database version 14.5 (Homebrew) 6 | -- Dumped by pg_dump version 14.5 (Homebrew) 7 | 8 | SET statement_timeout = 0; 9 | SET lock_timeout = 0; 10 | SET idle_in_transaction_session_timeout = 0; 11 | SET client_encoding = 'UTF8'; 12 | SET standard_conforming_strings = on; 13 | SELECT pg_catalog.set_config('search_path', '', false); 14 | SET check_function_bodies = false; 15 | SET xmloption = content; 16 | SET client_min_messages = warning; 17 | SET row_security = off; 18 | 19 | SET default_tablespace = ''; 20 | 21 | SET default_table_access_method = heap; 22 | 23 | -- 24 | -- Name: categories; Type: TABLE; Schema: public; Owner: janko 25 | -- 26 | 27 | CREATE TABLE public.categories ( 28 | id integer NOT NULL, 29 | name text NOT NULL, 30 | color text NOT NULL 31 | ); 32 | 33 | 34 | ALTER TABLE public.categories OWNER TO janko; 35 | 36 | -- 37 | -- Name: categories_id_seq; Type: SEQUENCE; Schema: public; Owner: janko 38 | -- 39 | 40 | ALTER TABLE public.categories ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( 41 | SEQUENCE NAME public.categories_id_seq 42 | START WITH 1 43 | INCREMENT BY 1 44 | NO MINVALUE 45 | NO MAXVALUE 46 | CACHE 1 47 | ); 48 | 49 | 50 | -- 51 | -- Name: expenses; Type: TABLE; Schema: public; Owner: janko 52 | -- 53 | 54 | CREATE TABLE public.expenses ( 55 | id integer NOT NULL, 56 | category_id integer, 57 | description text, 58 | amount double precision NOT NULL, 59 | date date NOT NULL, 60 | ignore boolean DEFAULT false NOT NULL, 61 | created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 62 | updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, 63 | fio_transaction_id integer 64 | ); 65 | 66 | 67 | ALTER TABLE public.expenses OWNER TO janko; 68 | 69 | -- 70 | -- Name: expenses_id_seq; Type: SEQUENCE; Schema: public; Owner: janko 71 | -- 72 | 73 | ALTER TABLE public.expenses ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( 74 | SEQUENCE NAME public.expenses_id_seq 75 | START WITH 1 76 | INCREMENT BY 1 77 | NO MINVALUE 78 | NO MAXVALUE 79 | CACHE 1 80 | ); 81 | 82 | 83 | -- 84 | -- Name: fio_transactions; Type: TABLE; Schema: public; Owner: janko 85 | -- 86 | 87 | CREATE TABLE public.fio_transactions ( 88 | id integer NOT NULL, 89 | external_id text NOT NULL, 90 | amount double precision NOT NULL, 91 | date date NOT NULL, 92 | account text, 93 | note text, 94 | message text, 95 | type text 96 | ); 97 | 98 | 99 | ALTER TABLE public.fio_transactions OWNER TO janko; 100 | 101 | -- 102 | -- Name: fio_transactions_id_seq; Type: SEQUENCE; Schema: public; Owner: janko 103 | -- 104 | 105 | ALTER TABLE public.fio_transactions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( 106 | SEQUENCE NAME public.fio_transactions_id_seq 107 | START WITH 1 108 | INCREMENT BY 1 109 | NO MINVALUE 110 | NO MAXVALUE 111 | CACHE 1 112 | ); 113 | 114 | 115 | -- 116 | -- Name: rules; Type: TABLE; Schema: public; Owner: janko 117 | -- 118 | 119 | CREATE TABLE public.rules ( 120 | id integer NOT NULL, 121 | field text NOT NULL, 122 | operator text NOT NULL, 123 | value text NOT NULL, 124 | category_id integer, 125 | ignore boolean 126 | ); 127 | 128 | 129 | ALTER TABLE public.rules OWNER TO janko; 130 | 131 | -- 132 | -- Name: rules_id_seq; Type: SEQUENCE; Schema: public; Owner: janko 133 | -- 134 | 135 | ALTER TABLE public.rules ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( 136 | SEQUENCE NAME public.rules_id_seq 137 | START WITH 1 138 | INCREMENT BY 1 139 | NO MINVALUE 140 | NO MAXVALUE 141 | CACHE 1 142 | ); 143 | 144 | 145 | -- 146 | -- Name: schema_migrations; Type: TABLE; Schema: public; Owner: janko 147 | -- 148 | 149 | CREATE TABLE public.schema_migrations ( 150 | filename text NOT NULL 151 | ); 152 | 153 | 154 | ALTER TABLE public.schema_migrations OWNER TO janko; 155 | 156 | -- 157 | -- Name: categories categories_pkey; Type: CONSTRAINT; Schema: public; Owner: janko 158 | -- 159 | 160 | ALTER TABLE ONLY public.categories 161 | ADD CONSTRAINT categories_pkey PRIMARY KEY (id); 162 | 163 | 164 | -- 165 | -- Name: expenses expenses_fio_transaction_id_key; Type: CONSTRAINT; Schema: public; Owner: janko 166 | -- 167 | 168 | ALTER TABLE ONLY public.expenses 169 | ADD CONSTRAINT expenses_fio_transaction_id_key UNIQUE (fio_transaction_id); 170 | 171 | 172 | -- 173 | -- Name: expenses expenses_pkey; Type: CONSTRAINT; Schema: public; Owner: janko 174 | -- 175 | 176 | ALTER TABLE ONLY public.expenses 177 | ADD CONSTRAINT expenses_pkey PRIMARY KEY (id); 178 | 179 | 180 | -- 181 | -- Name: fio_transactions fio_transactions_external_id_key; Type: CONSTRAINT; Schema: public; Owner: janko 182 | -- 183 | 184 | ALTER TABLE ONLY public.fio_transactions 185 | ADD CONSTRAINT fio_transactions_external_id_key UNIQUE (external_id); 186 | 187 | 188 | -- 189 | -- Name: fio_transactions fio_transactions_pkey; Type: CONSTRAINT; Schema: public; Owner: janko 190 | -- 191 | 192 | ALTER TABLE ONLY public.fio_transactions 193 | ADD CONSTRAINT fio_transactions_pkey PRIMARY KEY (id); 194 | 195 | 196 | -- 197 | -- Name: rules rules_pkey; Type: CONSTRAINT; Schema: public; Owner: janko 198 | -- 199 | 200 | ALTER TABLE ONLY public.rules 201 | ADD CONSTRAINT rules_pkey PRIMARY KEY (id); 202 | 203 | 204 | -- 205 | -- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: janko 206 | -- 207 | 208 | ALTER TABLE ONLY public.schema_migrations 209 | ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (filename); 210 | 211 | 212 | -- 213 | -- Name: expenses expenses_category_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: janko 214 | -- 215 | 216 | ALTER TABLE ONLY public.expenses 217 | ADD CONSTRAINT expenses_category_id_fkey FOREIGN KEY (category_id) REFERENCES public.categories(id) ON DELETE SET NULL; 218 | 219 | 220 | -- 221 | -- Name: expenses expenses_fio_transaction_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: janko 222 | -- 223 | 224 | ALTER TABLE ONLY public.expenses 225 | ADD CONSTRAINT expenses_fio_transaction_id_fkey FOREIGN KEY (fio_transaction_id) REFERENCES public.fio_transactions(id) ON DELETE CASCADE; 226 | 227 | 228 | -- 229 | -- Name: rules rules_category_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: janko 230 | -- 231 | 232 | ALTER TABLE ONLY public.rules 233 | ADD CONSTRAINT rules_category_id_fkey FOREIGN KEY (category_id) REFERENCES public.categories(id) ON DELETE CASCADE; 234 | 235 | 236 | -- 237 | -- PostgreSQL database dump complete 238 | -- 239 | 240 | 241 | 242 | -- 243 | -- PostgreSQL database dump 244 | -- 245 | 246 | -- Dumped from database version 14.5 (Homebrew) 247 | -- Dumped by pg_dump version 14.5 (Homebrew) 248 | 249 | SET statement_timeout = 0; 250 | SET lock_timeout = 0; 251 | SET idle_in_transaction_session_timeout = 0; 252 | SET client_encoding = 'UTF8'; 253 | SET standard_conforming_strings = on; 254 | SELECT pg_catalog.set_config('search_path', '', false); 255 | SET check_function_bodies = false; 256 | SET xmloption = content; 257 | SET client_min_messages = warning; 258 | SET row_security = off; 259 | 260 | -- 261 | -- Data for Name: schema_migrations; Type: TABLE DATA; Schema: public; Owner: janko 262 | -- 263 | 264 | INSERT INTO public.schema_migrations VALUES ('20221001115823_create_categories.rb'); 265 | INSERT INTO public.schema_migrations VALUES ('20221001115843_create_expenses.rb'); 266 | INSERT INTO public.schema_migrations VALUES ('20221020231423_create_fio_transactions.rb'); 267 | INSERT INTO public.schema_migrations VALUES ('20230114181444_nullify_expense_category_id_on_deletion.rb'); 268 | INSERT INTO public.schema_migrations VALUES ('20230115121453_create_rules.rb'); 269 | 270 | 271 | -- 272 | -- PostgreSQL database dump complete 273 | -- 274 | 275 | --------------------------------------------------------------------------------