aspire_budget 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19c4c01c53dfa0197f18c5c9106e860982bbc3456e3104f7c9587505cad730c1
4
- data.tar.gz: a7665ac0e37ae4f6b9a6d125eb3845ed1ebac66228b7aad8b95dda2e725e3593
3
+ metadata.gz: cffb2693a537cc0bb7e7e5a73f8d2ac478d89be2a226ecde8b97d28f61642625
4
+ data.tar.gz: dea6ce0b436d527c8d668f4e9652dd8ac18327cdfe08304bc194c753fba08b29
5
5
  SHA512:
6
- metadata.gz: 48e8701f8b7ded9c829d2c83e3d2dcf8f3ce51c8ca5ee5605574d3ce65bd1db0db089cac7a79a1d0f95ce31a1abe5f12122d343d3bd957efc41e89bf02c5259e
7
- data.tar.gz: 227a8bf87a812c688592d8fac317bebe51abd9517d5ad5176416ee79aa50dd8d5eb4a50da023a28a1a8abaeaf7021a162121b527bdc405554cd02c1688e04e1d
6
+ metadata.gz: 8a18685dd70429d75a36578082b26656012aa79f40eda357400c22ce151cbc016ec3d0faaaf8e20097a7ec97bb43c6dfec0849ff058ddff814e277f553dee27c
7
+ data.tar.gz: 21eeeb99b5d606d7416134c95ca9acb5e2f7b5547ed9cf18d165b4ae0047a5315383c234c828fd8b0c87663583ef7569dd729ea5d038fae7c1ad13978a1c200a
data/README.md CHANGED
@@ -1,21 +1,102 @@
1
- # Aspire Budget - Ruby
1
+ # Aspire Budget - Ruby
2
2
 
3
- This is an independent project implementing a Ruby for Aspire Budgeting spreadsheets, leveraging from the use of another great gem: `google_drive`.
3
+ [![CI Status](https://github.com/drowze/aspirebudgeting_ruby/workflows/CI/badge.svg)](https://github.com/drowze/aspirebudgeting_ruby)
4
+ [![Ruby Style Guide](https://img.shields.io/badge/code_style-rubocop-brightgreen.svg)](https://github.com/rubocop-hq/rubocop)
5
+ [![codecov](https://codecov.io/gh/Drowze/aspirebudgeting_ruby/branch/master/graph/badge.svg)](https://codecov.io/gh/Drowze/aspirebudgeting_ruby)
6
+ [![Maintainability](https://api.codeclimate.com/v1/badges/03531044f88452981597/maintainability)](https://codeclimate.com/github/Drowze/aspirebudgeting_ruby/maintainability)
7
+ [![Test Coverage](https://api.codeclimate.com/v1/badges/03531044f88452981597/test_coverage)](https://codeclimate.com/github/Drowze/aspirebudgeting_ruby/test_coverage)
8
+
9
+ This is an independent project implementing a Ruby for Aspire Budgeting spreadsheets, leveraging from the use of another great gem: `google_drive`.
4
10
  The idea of this gem is to enable a good API to be easily implemented, allowing more powerful and complex tools to emerge.
5
11
 
6
12
  If you don't know Aspire Budgeting please refer to: https://aspirebudget.com/
7
13
 
8
14
  ## Installation
9
15
 
10
- todo
16
+ ```bash
17
+ $ gem install aspire_budget
18
+ ```
11
19
 
12
20
  ## Usage
13
21
 
22
+ Either have an initializer with your config:
23
+
14
24
  ```ruby
15
- session = GoogleDrive::Session.from_config('path_to_your_credentials.json')
16
- client = AspireBudget::Client.new(session: session, spreadsheet_key: 'YOUR_SPREADSHEET_KEY')
25
+ # Use this method if you plan to work on a single spreadsheet with your application
26
+ AspireBudget.configure do |config|
27
+ config.session = GoogleDrive.from_config('path_to_your_credentials.json')
28
+ config.spreadsheet_key = 'YOUR_SPREADSHEET_KEY'
29
+ end
17
30
  ```
18
31
 
32
+ Or specify the config when initializing a worksheet like below.
33
+
34
+ ```ruby
35
+ # Use this method if you plan working with multiple spreadsheets with your application
36
+ AspireBudget::Worksheets::Transactions.new(
37
+ session: GoogleDrive.from_config('path_to_your_credentials.json'),
38
+ spreadsheet_key: 'YOUR_SPREADSHEET_KEY'
39
+ )
40
+ ```
41
+
42
+ List transactions:
43
+
44
+ ```ruby
45
+ # or AspireBudget::Worksheets::Transactions.new(...).all
46
+ AspireBudget::Worksheets::Transactions.all
47
+ => #[<AspireBudget::Models::Transaction:0x0000564acc1ae088
48
+ # @account="Revolut",
49
+ # @category="Groceries",
50
+ # @date=#<Date: 2020-05-31 ((2459001j,0s,0n),+0s,2299161j)>,
51
+ # @inflow=0.0,
52
+ # @memo="Tesco",
53
+ # @outflow=22.51,
54
+ # @status=:approved>,
55
+ # <AspireBudget::Models::Transaction:0x0000564acc1541a0
56
+ # @account="Revolut",
57
+ # @category="Electric Bill",
58
+ # @date=#<Date: 2020-06-22 ((2459023j,0s,0n),+0s,2299161j)>,
59
+ # @inflow=0.0,
60
+ # @memo="Amazon",
61
+ # @outflow=21.54,
62
+ # @status=:approved>]
63
+ ```
64
+
65
+ Insert transaction:
66
+
67
+ ```ruby
68
+ # or AspireBudget::Worksheets::Transactions.new(...).all
69
+ # you can also pass a Transaction record instead of a hash
70
+ AspireBudget::Worksheets::Transactions.insert(date: '25/06/2020', outflow: 10.0, inflow: 12.0, category: 'test', account: 'AIB', memo: 'ruby', status: :pending)
71
+ => #<AspireBudget::Models::Transaction:0x0000564acc1522b0 ... >
72
+ ```
73
+
74
+ List category transfers
75
+
76
+ ```ruby
77
+ AspireBudget::Worksheets::CategoryTransfers.all
78
+ => [#<AspireBudget::Models::CategoryTransfer:0x0000559501fddab8
79
+ # @amount=46.37,
80
+ # @date=#<Date: 2020-06-29 ((2459030j,0s,0n),+0s,2299161j)>,
81
+ # @from="Available to budget",
82
+ # @to="Lunch / Breakfast out",
83
+ # @memo="Monthly target">,
84
+ # <AspireBudget::Models::CategoryTransfer:0x0000559501fdd810
85
+ # @amount=15.0,
86
+ # @date=#<Date: 2020-06-29 ((2459030j,0s,0n),+0s,2299161j)>,
87
+ # @from="Public Transport",
88
+ # @memo="",
89
+ # @to="Available to budget">]
90
+ ```
91
+
92
+ Insert category transfer
93
+
94
+ ```ruby
95
+ AspireBudget::Worksheets::CategoryTransfers.insert(amount: 10, date: '25/06/2020', from: 'Available to budget', to: 'Electric Bill', memo: 'test')
96
+ => #<AspireBudget::Models::CategoryTransfer:0x0000264acc1529b0 ... >
97
+ ```
98
+
99
+
19
100
  ## Development
20
101
 
21
102
  todo
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'aspire_budget/core_extensions'
4
+
5
+ require_relative 'aspire_budget/version'
6
+ require_relative 'aspire_budget/configuration'
7
+
8
+ require_relative 'aspire_budget/worksheets/backend_data'
9
+ require_relative 'aspire_budget/worksheets/transactions'
10
+ require_relative 'aspire_budget/worksheets/category_transfers'
11
+
12
+ require_relative 'aspire_budget/models/transaction'
13
+ require_relative 'aspire_budget/models/category_transfer'
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AspireBudget
4
+ # Configures default values
5
+ def self.configure
6
+ yield(configuration) if block_given?
7
+ end
8
+
9
+ # @return [AspireBudget::Configuration] the current configured defaults
10
+ def self.configuration
11
+ Thread.current[:aspire_budget_configuration] ||= Configuration.new
12
+ end
13
+
14
+ # Overwrite the current configured defaults
15
+ # @param [AspireBudget::Configuration]
16
+ def self.configuration=(other)
17
+ Thread.current[:aspire_budget_configuration] = other
18
+ end
19
+
20
+ # Resets the set configuration. Useful on e.g. testing
21
+ def self.reset!
22
+ Thread.current[:aspire_budget_configuration] = nil
23
+ end
24
+
25
+ class Configuration
26
+ # Authenticated GoogleDrive session
27
+ # @return [GoogleDrive::Session]
28
+ attr_accessor :session
29
+
30
+ # Google spreadsheet key (as it is in the url)
31
+ # @return [String]
32
+ attr_accessor :spreadsheet_key
33
+
34
+ # Build an agent using given +session+ and +spreadsheet_key+ (falling back
35
+ # to the configured ones).
36
+ # @return [GoogleDrive::Spreadsheet] an spreadsheet agent
37
+ # @param session [GoogleDrive::Session] will fallback to configured one if
38
+ # not defined
39
+ # @param spreadsheet_key [String] will fallback to configured one if not
40
+ # defined
41
+ def agent(session = nil, spreadsheet_key = nil)
42
+ @agents ||= Hash.new do |h, k|
43
+ h[k] = k.first.spreadsheet_by_key(k.last)
44
+ end
45
+ @agents[
46
+ [session || self.session, spreadsheet_key || self.spreadsheet_key]
47
+ ]
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'google_drive'
4
+
5
+ module AspireBudget
6
+ module CoreExtensions
7
+ # rubocop:disable all
8
+ module Worksheet
9
+ def rows(skip = 0)
10
+ nc = num_cols
11
+ result = ((1 + skip)..num_rows).map do |row|
12
+ (1..nc).map do |col|
13
+ block_given? ? yield(row, col) : self[row, col]
14
+ end.freeze
15
+ end
16
+ result.freeze
17
+ end
18
+
19
+ # Same as +#rows+, but replacing cells with numeric values where they exist.
20
+ # Please note that this will NOT replace dirty cells with their numeric
21
+ # value.
22
+ #
23
+ # @see #rows
24
+ # @see #numeric_value
25
+ def rows_with_numerics(skip = 0)
26
+ rows(skip) { |row, col| numeric_value(row, col) || self[row, col] }
27
+ end
28
+
29
+ def []=(*args) # rubocop:disable all
30
+ (row, col) = parse_cell_args(args[0...-1])
31
+ value = args[-1]
32
+
33
+ reload_cells unless @cells
34
+ @numeric_values[[row, col]] = value.is_a?(Numeric) ? value : nil
35
+ value = value.to_s
36
+ validate_cell_value(value)
37
+
38
+ @cells[[row, col]] = value
39
+ @input_values[[row, col]] = value
40
+ @modified.add([row, col])
41
+ self.max_rows = row if row > @max_rows
42
+ self.max_cols = col if col > @max_cols
43
+ if value.empty?
44
+ @num_rows = nil
45
+ @num_cols = nil
46
+ else
47
+ @num_rows = row if @num_rows && row > @num_rows
48
+ @num_cols = col if @num_cols && col > @num_cols
49
+ end
50
+ end
51
+
52
+ def save # rubocop:disable all
53
+ sent = false
54
+
55
+ if @meta_modified
56
+ add_request({
57
+ update_sheet_properties: {
58
+ properties: {
59
+ sheet_id: sheet_id,
60
+ title: title,
61
+ index: index,
62
+ grid_properties: {row_count: max_rows, column_count: max_cols},
63
+ },
64
+ fields: '*',
65
+ },
66
+ })
67
+ end
68
+
69
+ if !@v4_requests.empty?
70
+ self.spreadsheet.batch_update(@v4_requests)
71
+ @v4_requests = []
72
+ sent = true
73
+ end
74
+
75
+ @remote_title = @title
76
+
77
+ unless @modified.empty?
78
+ min_modified_row = 1.0 / 0.0
79
+ max_modified_row = 0
80
+ min_modified_col = 1.0 / 0.0
81
+ max_modified_col = 0
82
+ @modified.each do |r, c|
83
+ min_modified_row = r if r < min_modified_row
84
+ max_modified_row = r if r > max_modified_row
85
+ min_modified_col = c if c < min_modified_col
86
+ max_modified_col = c if c > max_modified_col
87
+ end
88
+
89
+ # Uses update_spreadsheet_value instead batch_update_spreadsheet with
90
+ # update_cells. batch_update_spreadsheet has benefit that the request
91
+ # can be batched with other requests. But it has drawback that the
92
+ # type of the value (string_value, number_value, etc.) must be
93
+ # explicitly specified in user_entered_value. Since I don't know exact
94
+ # logic to determine the type from text, I chose to use
95
+ # update_spreadsheet_value here.
96
+ range = "'%s'!R%dC%d:R%dC%d" %
97
+ [@title, min_modified_row, min_modified_col, max_modified_row, max_modified_col]
98
+ values = (min_modified_row..max_modified_row).map do |r|
99
+ (min_modified_col..max_modified_col).map do |c|
100
+ next unless @modified.include?([r, c])
101
+
102
+ @numeric_values[[r, c]] || @cells[[r, c]] || ''
103
+ end
104
+ end
105
+ value_range = Google::Apis::SheetsV4::ValueRange.new(values: values)
106
+ @session.sheets_service.update_spreadsheet_value(
107
+ spreadsheet.id, range, value_range, value_input_option: 'USER_ENTERED')
108
+
109
+ @modified.clear
110
+ sent = true
111
+ end
112
+
113
+ sent
114
+ end
115
+ end
116
+ # rubocop:enable all
117
+ end
118
+ end
119
+
120
+ # https://github.com/gimite/google-drive-ruby/issues/378 (PR #377)
121
+ # https://github.com/gimite/google-drive-ruby/issues/380 (PR #379)
122
+ GoogleDrive::Worksheet.prepend AspireBudget::CoreExtensions::Worksheet
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../utils'
4
+
5
+ module AspireBudget
6
+ module Models
7
+ class CategoryTransfer
8
+ attr_reader :date, :amount, :from, :to, :memo
9
+
10
+ def self.from_row(header, row)
11
+ params = header.zip(row).to_h
12
+
13
+ params.tap do |h|
14
+ h[:date] = Utils.parse_date(h[:date])
15
+ end
16
+
17
+ new(**params)
18
+ end
19
+
20
+ def initialize(date:, amount:, from:, to:, memo:)
21
+ @date = date.nil? ? Date.today : Utils.parse_date(date)
22
+ @amount = amount.to_f
23
+ @from = from || 'Available to Budget'
24
+ @to = to
25
+ @memo = memo
26
+ end
27
+
28
+ def to_row(header)
29
+ header.map do |h|
30
+ value = send(h)
31
+ next Utils.serialize_date(value) if h == :date
32
+
33
+ value
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'utils'
3
+ require_relative '../utils'
4
4
 
5
5
  module AspireBudget
6
6
  module Models
@@ -12,30 +12,29 @@ module AspireBudget
12
12
 
13
13
  params.tap do |h|
14
14
  h[:date] = Utils.parse_date(h[:date])
15
- h[:outflow] = Utils.parse_currency(h[:outflow])
16
- h[:inflow] = Utils.parse_currency(h[:inflow])
17
15
  h[:status] = Utils.parse_status(h[:status])
18
16
  end
19
17
 
20
18
  new(**params)
21
19
  end
22
20
 
21
+ # rubocop:disable Metrics/ParameterLists
23
22
  def initialize(date:, outflow:, inflow:, category:, account:, memo:, status:)
24
- @date = Utils.parse_date(date) || Date.today
25
- @outflow = outflow || 0.0
26
- @inflow = inflow || 0.0
23
+ @date = date.nil? ? Date.today : Utils.parse_date(date)
24
+ @outflow = outflow.to_f
25
+ @inflow = inflow.to_f
27
26
  @category = category
28
27
  @account = account
29
28
  @memo = memo
30
29
  @status = status
31
30
  end
31
+ # rubocop:enable Metrics/ParameterLists
32
32
 
33
33
  def to_row(header)
34
34
  header.map do |h|
35
35
  value = send(h)
36
36
  next Utils.serialize_date(value) if h == :date
37
37
  next Utils.serialize_status(value) if h == :status
38
- next Utils.serialize_currency(value) if %i[inflow outflow].include?(h)
39
38
 
40
39
  value
41
40
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module AspireBudget
6
+ module Utils
7
+ class << self
8
+ # Parses a value to a date object
9
+ # @return [Date]
10
+ # @param value either a numeric object or an object responding to +to_date+
11
+ def parse_date(value)
12
+ return parse_serial_date(value) if value.is_a?(Numeric)
13
+ return value.to_date if value.respond_to?(:to_date)
14
+
15
+ raise 'Unsupported date format'
16
+ end
17
+
18
+ # Parses a value to a serial date
19
+ # @return [Float]
20
+ # @param value either a numeric object or an object responding to +to_date+
21
+ def serialize_date(value)
22
+ return Float(value) if value.is_a?(Numeric) && value >= 0
23
+
24
+ value = value.to_date if value.respond_to?(:to_date)
25
+ raise 'Unsupported date value' unless value.is_a?(Date)
26
+ raise "Date should be after #{LOTUS_DAY_ONE}" if LOTUS_DAY_ONE > value
27
+
28
+ Float(value - LOTUS_DAY_ONE)
29
+ end
30
+
31
+ # Parses a status icon
32
+ # @return [Symbol]
33
+ # @param value [String]
34
+ def parse_status(value)
35
+ TRANSACTION_STATUS_MAPPING.fetch(value, nil)
36
+ end
37
+
38
+ # Serialize a status symbol
39
+ # @return [String]
40
+ # @param value [Symbol]
41
+ def serialize_status(value)
42
+ TRANSACTION_STATUS_MAPPING.key(value) || ''
43
+ end
44
+
45
+ private
46
+
47
+ TRANSACTION_STATUS_MAPPING = {
48
+ '✅' => :approved,
49
+ '🅿️' => :pending,
50
+ '*️⃣' => :reconciliation
51
+ }.freeze
52
+ private_constant :TRANSACTION_STATUS_MAPPING
53
+
54
+ LOTUS_DAY_ONE = Date.new(1899, 12, 30).freeze
55
+ private_constant :LOTUS_DAY_ONE
56
+
57
+ def parse_serial_date(days_after_lotus_day_one)
58
+ LOTUS_DAY_ONE + days_after_lotus_day_one
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AspireBudget
4
+ # Full release version.
5
+ # @return [String]
6
+ VERSION = '0.0.2'
7
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'worksheet_base'
4
+
5
+ module AspireBudget
6
+ module Worksheets
7
+ class BackendData < WorksheetBase
8
+ WS_TITLE = 'BackendData'
9
+
10
+ # @return [String] the spreadsheet version
11
+ def version
12
+ version_column = ws.rows[0].index { |header| header.include?('Update') }
13
+ ws.rows[1][version_column]
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'worksheet_base'
4
+ require_relative 'transactions'
5
+ require_relative '../models/category_transfer'
6
+
7
+ module AspireBudget
8
+ module Worksheets
9
+ class CategoryTransfers < Transactions
10
+ WS_TITLE = 'Category Transfers'
11
+
12
+ private
13
+
14
+ def klass
15
+ Models::CategoryTransfer
16
+ end
17
+
18
+ def header
19
+ @header ||=
20
+ super.map { |k| k == :'from category' ? :from : k }
21
+ .map { |k| k == :'to category' ? :to : k }[0...-1]
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'worksheet_base'
4
+ require_relative '../models/transaction'
5
+
6
+ module AspireBudget
7
+ module Worksheets
8
+ class Transactions < WorksheetBase
9
+ WS_TITLE = 'Transactions'
10
+
11
+ # @return [Array<AspireBudget::Transaction>] all transactions
12
+ def all
13
+ rows.map do |row|
14
+ klass.from_row(header, row)
15
+ end
16
+ end
17
+
18
+ # Inserts a transaction to the spreadsheet. Accepts either a transaction
19
+ # record or a hash (that is passed to the transaction initializer)
20
+ # @see AspireBudget::Models::Transaction#initialize
21
+ # @param record [AspireBudget::Transaction, Hash]
22
+ # @return [AspireBudget::Transaction] a transaction
23
+ def insert(record, sync: true)
24
+ record = klass.new(**record) if record.is_a?(Hash)
25
+ row = record.to_row(header)
26
+ ws.update_cells(*next_row_col, [row])
27
+ ws.synchronize if sync
28
+ record
29
+ end
30
+
31
+ private
32
+
33
+ def klass
34
+ Models::Transaction
35
+ end
36
+
37
+ # There is a 1 cell margin before the spreadsheet content
38
+ def margin_left
39
+ 1
40
+ end
41
+
42
+ def next_row_col
43
+ [ws.num_rows + 1, margin_left + 1]
44
+ end
45
+
46
+ def sanitize(row)
47
+ return if row.all? { |cell| cell == '' }
48
+
49
+ row.drop(margin_left)
50
+ end
51
+
52
+ def rows
53
+ ws.rows_with_numerics(header_location)
54
+ .map(&method(:sanitize)).compact
55
+ end
56
+
57
+ def header
58
+ @header ||=
59
+ ws.rows(header_location - 1)
60
+ .first
61
+ .drop(margin_left)
62
+ .map(&:downcase)
63
+ .map(&:to_sym)
64
+ end
65
+
66
+ def header_location
67
+ @header_location ||=
68
+ (1..ws.num_rows).find { |i| ws[i, margin_left + 1].casecmp?('date') }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../configuration'
4
+
5
+ module AspireBudget
6
+ module Worksheets
7
+ # @abstract Subclass and reimplement ws_title to implement a custom
8
+ # worksheet
9
+ class WorksheetBase
10
+ class << self
11
+ # @return an instance of the current object
12
+ def instance
13
+ Thread.current[to_s] ||= new
14
+ end
15
+
16
+ private
17
+
18
+ def method_missing(method_name, *args, &block)
19
+ if instance.respond_to?(method_name)
20
+ instance.public_send(method_name, *args, &block)
21
+ else
22
+ super
23
+ end
24
+ end
25
+
26
+ def respond_to_missing?(method_name, _include_private)
27
+ instance.respond_to?(method_name) || super
28
+ end
29
+ end
30
+
31
+ # @see AspireBudget::Configuration#agent
32
+ # @return a new instance of the calling class configured with an agent
33
+ # @param session [GoogleDrive::Session]
34
+ # @param spreadsheet_key [String] spreadsheet key as per its url
35
+ def initialize(session: nil, spreadsheet_key: nil)
36
+ @session = session
37
+ @spreadsheet_key = spreadsheet_key
38
+ @agent = AspireBudget.configuration.agent(@session, @spreadsheet_key)
39
+ end
40
+
41
+ # @return [Boolean] Whether the worksheet has unsaved changes
42
+ def dirty?
43
+ ws.dirty?
44
+ end
45
+
46
+ private
47
+
48
+ def ws
49
+ worksheets[self.class::WS_TITLE]
50
+ end
51
+
52
+ def worksheets
53
+ @worksheets ||=
54
+ @agent.worksheets.reduce({}) { |h, sheet| h.merge(sheet.title => sheet) }
55
+ end
56
+ end
57
+ end
58
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aspire_budget
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Drowze
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2020-06-25 00:00:00.000000000 Z
11
+ date: 2020-08-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: google_drive
@@ -24,37 +24,188 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '3.0'
27
- description: Aspire Budget Ruby Wrapper
27
+ - !ruby/object:Gem::Dependency
28
+ name: pry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: pry-byebug
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 3.9.0
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 3.9.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '13.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '13.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 0.89.1
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 0.89.1
97
+ - !ruby/object:Gem::Dependency
98
+ name: rubocop-packaging
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 0.2.0
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 0.2.0
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop-performance
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: 1.7.1
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: 1.7.1
125
+ - !ruby/object:Gem::Dependency
126
+ name: rubocop-rspec
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: 1.42.0
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: 1.42.0
139
+ - !ruby/object:Gem::Dependency
140
+ name: rubocop-thread_safety
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: 0.4.1
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: 0.4.1
153
+ - !ruby/object:Gem::Dependency
154
+ name: simplecov
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - "~>"
158
+ - !ruby/object:Gem::Version
159
+ version: 0.17.0
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - "~>"
165
+ - !ruby/object:Gem::Version
166
+ version: 0.17.0
167
+ - !ruby/object:Gem::Dependency
168
+ name: webmock
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ description: |2
182
+ Aspire Budget is a free zero-based envelope-style budgeting spreadsheet
183
+ built with Google Sheets by Matthew Alcorn. This gem aims to provide an
184
+ expressive Ruby interface to it.
28
185
  email:
29
186
  - gibim6+aspire@gmail.com
30
187
  executables: []
31
188
  extensions: []
32
189
  extra_rdoc_files: []
33
190
  files:
34
- - ".gitignore"
35
- - ".rspec"
36
- - ".rubocop.yml"
37
- - ".travis.yml"
38
- - Gemfile
39
- - Gemfile.lock
40
191
  - LICENSE.txt
41
192
  - README.md
42
- - Rakefile
43
- - aspire_budget.gemspec
44
- - bin/console
45
- - bin/setup
46
- - lib/client.rb
47
- - lib/models/transaction.rb
48
- - lib/utils.rb
49
- - lib/version.rb
50
- - lib/worksheets/backend_data.rb
51
- - lib/worksheets/transactions.rb
52
- - lib/worksheets/worksheet_base.rb
53
- homepage: https://google.com
193
+ - lib/aspire_budget.rb
194
+ - lib/aspire_budget/configuration.rb
195
+ - lib/aspire_budget/core_extensions.rb
196
+ - lib/aspire_budget/models/category_transfer.rb
197
+ - lib/aspire_budget/models/transaction.rb
198
+ - lib/aspire_budget/utils.rb
199
+ - lib/aspire_budget/version.rb
200
+ - lib/aspire_budget/worksheets/backend_data.rb
201
+ - lib/aspire_budget/worksheets/category_transfers.rb
202
+ - lib/aspire_budget/worksheets/transactions.rb
203
+ - lib/aspire_budget/worksheets/worksheet_base.rb
204
+ homepage: https://github.com/drowze/aspirebudgeting_ruby
54
205
  licenses:
55
206
  - MIT
56
207
  metadata:
57
- homepage_uri: https://google.com
208
+ homepage_uri: https://github.com/drowze/aspirebudgeting_ruby
58
209
  source_code_uri: https://github.com/drowze/aspirebudgeting_ruby
59
210
  post_install_message:
60
211
  rdoc_options: []
@@ -64,13 +215,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
64
215
  requirements:
65
216
  - - ">="
66
217
  - !ruby/object:Gem::Version
67
- version: 2.3.0
218
+ version: 2.4.0
68
219
  required_rubygems_version: !ruby/object:Gem::Requirement
69
220
  requirements:
70
221
  - - ">="
71
222
  - !ruby/object:Gem::Version
72
223
  version: '0'
73
- requirements: []
224
+ requirements:
225
+ - Aspire Budget spreadsheet v3.1.0+
74
226
  rubygems_version: 3.1.2
75
227
  signing_key:
76
228
  specification_version: 4
data/.gitignore DELETED
@@ -1,11 +0,0 @@
1
- /.bundle/
2
- /.yardoc
3
- /_yardoc/
4
- /coverage/
5
- /doc/
6
- /pkg/
7
- /spec/reports/
8
- /tmp/
9
-
10
- # rspec failure tracking
11
- .rspec_status
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper
@@ -1,11 +0,0 @@
1
- AllCops:
2
- Exclude:
3
- - 'bin/console'
4
-
5
- NewCops: enable
6
-
7
- Metrics/AbcSize:
8
- Max: 20
9
-
10
- Style/Documentation:
11
- Enabled: false
@@ -1,6 +0,0 @@
1
- ---
2
- language: ruby
3
- cache: bundler
4
- rvm:
5
- - 2.7.1
6
- before_install: gem install bundler -v 2.1.4
data/Gemfile DELETED
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- source 'https://rubygems.org'
4
-
5
- # Specify your gem's dependencies in aspire_budget.gemspec
6
- gemspec
7
-
8
- gem 'pry'
9
- gem 'rake', '~> 12.0'
10
- gem 'rspec', '~> 3.0'
11
- gem 'rubocop'
@@ -1,109 +0,0 @@
1
- PATH
2
- remote: .
3
- specs:
4
- aspire_budget (0.0.1)
5
- google_drive (~> 3.0)
6
-
7
- GEM
8
- remote: https://rubygems.org/
9
- specs:
10
- addressable (2.7.0)
11
- public_suffix (>= 2.0.2, < 5.0)
12
- ast (2.4.1)
13
- coderay (1.1.3)
14
- declarative (0.0.10)
15
- declarative-option (0.1.0)
16
- diff-lcs (1.4.1)
17
- faraday (1.0.1)
18
- multipart-post (>= 1.2, < 3)
19
- google-api-client (0.41.0)
20
- addressable (~> 2.5, >= 2.5.1)
21
- googleauth (~> 0.9)
22
- httpclient (>= 2.8.1, < 3.0)
23
- mini_mime (~> 1.0)
24
- representable (~> 3.0)
25
- retriable (>= 2.0, < 4.0)
26
- signet (~> 0.12)
27
- google_drive (3.0.5)
28
- google-api-client (>= 0.11.0, < 1.0.0)
29
- googleauth (>= 0.5.0, < 1.0.0)
30
- nokogiri (>= 1.5.3, < 2.0.0)
31
- googleauth (0.13.0)
32
- faraday (>= 0.17.3, < 2.0)
33
- jwt (>= 1.4, < 3.0)
34
- memoist (~> 0.16)
35
- multi_json (~> 1.11)
36
- os (>= 0.9, < 2.0)
37
- signet (~> 0.14)
38
- httpclient (2.8.3)
39
- jwt (2.2.1)
40
- memoist (0.16.2)
41
- method_source (1.0.0)
42
- mini_mime (1.0.2)
43
- mini_portile2 (2.4.0)
44
- multi_json (1.14.1)
45
- multipart-post (2.1.1)
46
- nokogiri (1.10.9)
47
- mini_portile2 (~> 2.4.0)
48
- os (1.1.0)
49
- parallel (1.19.2)
50
- parser (2.7.1.4)
51
- ast (~> 2.4.1)
52
- pry (0.13.1)
53
- coderay (~> 1.1)
54
- method_source (~> 1.0)
55
- public_suffix (4.0.5)
56
- rainbow (3.0.0)
57
- rake (12.3.3)
58
- regexp_parser (1.7.1)
59
- representable (3.0.4)
60
- declarative (< 0.1.0)
61
- declarative-option (< 0.2.0)
62
- uber (< 0.2.0)
63
- retriable (3.1.2)
64
- rexml (3.2.4)
65
- rspec (3.9.0)
66
- rspec-core (~> 3.9.0)
67
- rspec-expectations (~> 3.9.0)
68
- rspec-mocks (~> 3.9.0)
69
- rspec-core (3.9.2)
70
- rspec-support (~> 3.9.3)
71
- rspec-expectations (3.9.2)
72
- diff-lcs (>= 1.2.0, < 2.0)
73
- rspec-support (~> 3.9.0)
74
- rspec-mocks (3.9.1)
75
- diff-lcs (>= 1.2.0, < 2.0)
76
- rspec-support (~> 3.9.0)
77
- rspec-support (3.9.3)
78
- rubocop (0.86.0)
79
- parallel (~> 1.10)
80
- parser (>= 2.7.0.1)
81
- rainbow (>= 2.2.2, < 4.0)
82
- regexp_parser (>= 1.7)
83
- rexml
84
- rubocop-ast (>= 0.0.3, < 1.0)
85
- ruby-progressbar (~> 1.7)
86
- unicode-display_width (>= 1.4.0, < 2.0)
87
- rubocop-ast (0.0.3)
88
- parser (>= 2.7.0.1)
89
- ruby-progressbar (1.10.1)
90
- signet (0.14.0)
91
- addressable (~> 2.3)
92
- faraday (>= 0.17.3, < 2.0)
93
- jwt (>= 1.5, < 3.0)
94
- multi_json (~> 1.10)
95
- uber (0.1.0)
96
- unicode-display_width (1.7.0)
97
-
98
- PLATFORMS
99
- ruby
100
-
101
- DEPENDENCIES
102
- aspire_budget!
103
- pry
104
- rake (~> 12.0)
105
- rspec (~> 3.0)
106
- rubocop
107
-
108
- BUNDLED WITH
109
- 2.1.4
data/Rakefile DELETED
@@ -1,8 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'bundler/gem_tasks'
4
- require 'rspec/core/rake_task'
5
-
6
- RSpec::Core::RakeTask.new(:spec)
7
-
8
- task default: :spec
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'lib/version'
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = 'aspire_budget'
7
- spec.version = AspireBudget::VERSION
8
- spec.authors = ['Drowze']
9
- spec.email = ['gibim6+aspire@gmail.com']
10
-
11
- spec.summary = 'Aspire Budget Ruby Wrapper'
12
- spec.description = 'Aspire Budget Ruby Wrapper'
13
- spec.homepage = 'https://google.com'
14
- spec.license = 'MIT'
15
- spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')
16
-
17
- spec.metadata['homepage_uri'] = spec.homepage
18
- spec.metadata['source_code_uri'] = 'https://github.com/drowze/aspirebudgeting_ruby'
19
-
20
- # Specify which files should be added to the gem when it is released.
21
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
- spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
24
- end
25
- spec.bindir = 'exe'
26
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
27
- spec.require_paths = ['lib']
28
-
29
- spec.add_runtime_dependency 'google_drive', '~> 3.0'
30
- end
@@ -1,18 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- require 'pry'
5
- require 'google_drive'
6
-
7
- require 'bundler/setup'
8
- require 'client'
9
-
10
- session = GoogleDrive::Session.from_config(ENV['GOOGLE_CREDENTIALS_PATH'])
11
- client = AspireBudget::Client.new(session: session, spreadsheet_key: ENV['SPREADSHEET_KEY'])
12
-
13
- binding.pry
14
-
15
- # params = { date: '25/06/2020', outflow: 10.0, inflow: 12.0, category: 'test', account: 'AIB', memo: 'ruby', status: :pending }
16
- # client.insert_transaction(params)
17
-
18
- # client.transactions_list
data/bin/setup DELETED
@@ -1,8 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- IFS=$'\n\t'
4
- set -vx
5
-
6
- bundle install
7
-
8
- # Do any other automated setup that you need to do here
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'worksheets/backend_data'
4
- require 'worksheets/transactions'
5
-
6
- require 'models/transaction'
7
-
8
- module AspireBudget
9
- class Client
10
- def initialize(session:, spreadsheet_key:)
11
- @session = session
12
- @spreadsheet_key = spreadsheet_key
13
- end
14
-
15
- def categories
16
- backend_data.categories
17
- end
18
-
19
- def transaction_list
20
- transactions.all
21
- end
22
-
23
- def insert_transaction(params)
24
- transaction = Models::Transaction.new(params)
25
- transactions.insert(transaction)
26
- end
27
-
28
- private
29
-
30
- def transactions
31
- @transactions ||= Worksheets::Transactions.new(agent: agent)
32
- end
33
-
34
- def backend_data
35
- @backend_data ||= Worksheets::BackendData.new(agent: agent)
36
- end
37
-
38
- def agent
39
- @agent ||= begin
40
- @session.spreadsheet_by_key(@spreadsheet_key)
41
- end
42
- end
43
- end
44
- end
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AspireBudget
4
- module Utils
5
- class << self
6
- DATE_FORMAT = '%d/%m/%y'
7
- CURRENCY_SYMBOL = '€'
8
- TRANSACTION_STATUS_MAPPING = {
9
- '✅' => :approved,
10
- '🅿️' => :pending,
11
- '*️⃣' => :reconciliation
12
- }.freeze
13
-
14
- def parse_date(value)
15
- return value unless value.is_a?(String)
16
-
17
- Date.strptime(value, DATE_FORMAT)
18
- end
19
-
20
- def serialize_date(value)
21
- return value unless value.respond_to?(:strftime)
22
-
23
- value.strftime(DATE_FORMAT)
24
- end
25
-
26
- def parse_currency(value)
27
- value && value[/\d+\.\d+/].to_f || 0.0
28
- end
29
-
30
- def serialize_currency(value)
31
- "#{CURRENCY_SYMBOL}#{format('%.2f', value)}"
32
- end
33
-
34
- def parse_status(value)
35
- TRANSACTION_STATUS_MAPPING.fetch(value)
36
- end
37
-
38
- def serialize_status(value)
39
- TRANSACTION_STATUS_MAPPING.key(value)
40
- end
41
- end
42
- end
43
- end
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AspireBudget
4
- VERSION = '0.0.1'
5
- end
@@ -1,27 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative './worksheet_base'
4
-
5
- module AspireBudget
6
- module Worksheets
7
- class BackendData < WorksheetBase
8
- WS_TITLE = 'BackendData'
9
-
10
- def categories
11
- fetch_data(data_title: 'Categories')
12
- end
13
-
14
- def category_exists?(category)
15
- categories.include?(category)
16
- end
17
-
18
- def fetch_data(data_title: 'Categories')
19
- col = (1..ws.num_cols).find_index do |i|
20
- ws[1, i] == data_title
21
- end
22
-
23
- ws.rows.transpose[col].reject(&:empty?) - [data_title]
24
- end
25
- end
26
- end
27
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'worksheets/worksheet_base'
4
- require 'models/transaction'
5
-
6
- module AspireBudget
7
- module Worksheets
8
- class Transactions < WorksheetBase
9
- WS_TITLE = 'Transactions'
10
- MARGIN_LEFT = 1
11
-
12
- def all
13
- transaction_rows.map do |row|
14
- Models::Transaction.from_row(transactions_header, row)
15
- end
16
- end
17
-
18
- def insert(transaction, sync: true)
19
- row = transaction.to_row(transactions_header)
20
- ws.update_cells(ws.rows.size + 1, MARGIN_LEFT + 1, [row])
21
- ws.synchronize if sync
22
- Models::Transaction.from_row(transactions_header, sanitize(ws.rows.last))
23
- end
24
-
25
- private
26
-
27
- def sanitize(row)
28
- return if row.all?(&:empty?)
29
-
30
- row.drop(MARGIN_LEFT)
31
- end
32
-
33
- def transaction_rows
34
- ws.rows(transactions_header_location)
35
- .map(&method(:sanitize)).compact
36
- end
37
-
38
- def transactions_header
39
- @transactions_header ||=
40
- ws.rows(transactions_header_location - 1)
41
- .first
42
- .drop(MARGIN_LEFT)
43
- .map(&:downcase)
44
- .map(&:to_sym)
45
- end
46
-
47
- def transactions_header_location
48
- @transactions_header_location ||=
49
- ((MARGIN_LEFT + 1)..ws.num_rows).find { |i| ws[i, MARGIN_LEFT + 1] == 'DATE' }
50
- end
51
- end
52
- end
53
- end
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'utils'
4
-
5
- module AspireBudget
6
- module Worksheets
7
- class WorksheetBase
8
- include Utils
9
-
10
- def initialize(agent:)
11
- @agent = agent
12
- end
13
-
14
- private
15
-
16
- def ws
17
- worksheets[self.class::WS_TITLE]
18
- end
19
-
20
- def worksheets
21
- @worksheets ||=
22
- @agent.worksheets.reduce({}) { |h, sheet| h.merge(sheet.title => sheet) }
23
- end
24
- end
25
- end
26
- end