├── .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 |
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 | | Name |
14 | Color |
15 | |
16 |
17 |
18 |
19 |
20 | <% @categories.each do |category| %>
21 |
22 | | <%= category.name %> |
23 | <%= category.color %> |
24 | <%= link_to icon(:trash), path(category), "data-turbo-method": "delete", "data-turbo-confirm": "Are you Sure?", class: "text-danger" %> |
25 |
26 | <% end %>
27 |
28 |
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 | | Amount |
21 | Description |
22 | Date |
23 | Category |
24 | |
25 |
26 |
27 |
28 |
29 | <% @expenses.each do |expense| %>
30 | <%= partial "expenses/expense", locals: { expense: expense, categories: @categories } %>
31 | <% end %>
32 |
33 |
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 | | Field |
14 | Operator |
15 | Value |
16 | Action |
17 | |
18 |
19 |
20 |
21 |
22 | <% @rules.each do |rule| %>
23 |
24 | | <%= rule.field.capitalize %> |
25 | <%= rule.operator %> |
26 | <%= rule.value %> |
27 |
28 | <% if rule.category %>
29 | <%= rule.category.name %>
30 | <% elsif rule.ignore %>
31 | Ignore
32 | <% end %>
33 | |
34 |
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 | |
38 |
39 | <% end %>
40 |
41 |
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 | | Month |
13 | <% @categories.each do |category| %>
14 | <%= category.name %> |
15 | <% end %>
16 | Uncategorized |
17 | Total |
18 |
19 |
20 |
21 | <% data.group_by { |item| item[:month] }.each do |month, items| %>
22 |
23 | | <%= Date.new(year, month).strftime("%B") %> |
24 | <% @categories.each do |category| %>
25 | <%= price items.find { |item| item[:category_id] == category.id }&.fetch(:total) || 0, currency: false %> |
26 | <% end %>
27 | <%= price items.find { |item| item[:category_id] == nil }&.fetch(:total) || 0, currency: false %> |
28 | <%= price items.sum { |item| item.fetch(:total) }, currency: false %> |
29 |
30 | <% end %>
31 |
32 | | Total |
33 | <% @categories.each do |category| %>
34 |
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 | |
39 | <% end %>
40 |
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 | |
45 |
46 | <%= price data.sum { |item| item.fetch(:total) }, currency: false %>
47 |
48 | (<%= price data.sum { |item| item.fetch(:total) } / months, currency: false %>)
49 | |
50 |
51 |
52 |
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 |
--------------------------------------------------------------------------------