aspire_budget 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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