lbyte-budget 0.1.1
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 +7 -0
- data/.simplecov +25 -0
- data/API_DOCUMENTATION.md +526 -0
- data/CHANGELOG.md +5 -0
- data/CLEANUP.md +85 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/INTEGRATION_GUIDE.md +452 -0
- data/LICENSE.txt +21 -0
- data/RAILS_USAGE.md +510 -0
- data/README.md +367 -0
- data/Rakefile +19 -0
- data/TESTING.md +243 -0
- data/TEST_IMPLEMENTATION_SUMMARY.md +246 -0
- data/TEST_SUITE.md +253 -0
- data/app/controllers/budget/application_controller.rb +10 -0
- data/app/controllers/budget/line_items_controller.rb +112 -0
- data/app/controllers/budget/payments_controller.rb +108 -0
- data/app/controllers/budget/quotes_controller.rb +180 -0
- data/app/models/budget/line_item.rb +59 -0
- data/app/models/budget/payment.rb +58 -0
- data/app/models/budget/quote.rb +158 -0
- data/app/views/budget/line_items/index.json.jbuilder +7 -0
- data/app/views/budget/line_items/show.json.jbuilder +6 -0
- data/app/views/budget/payments/index.json.jbuilder +6 -0
- data/app/views/budget/payments/show.json.jbuilder +5 -0
- data/app/views/budget/quotes/index.json.jbuilder +15 -0
- data/app/views/budget/quotes/show.json.jbuilder +23 -0
- data/config/routes.rb +12 -0
- data/db/migrate/20251129000001_create_budget_quotes.rb +17 -0
- data/db/migrate/20251129000002_create_budget_line_items.rb +17 -0
- data/db/migrate/20251129000003_create_budget_payments.rb +18 -0
- data/examples/basic_usage.rb +130 -0
- data/examples/test_examples.rb +113 -0
- data/lib/budget/engine.rb +25 -0
- data/lib/budget/line_item.rb +66 -0
- data/lib/budget/payment.rb +56 -0
- data/lib/budget/quote.rb +163 -0
- data/lib/budget/version.rb +5 -0
- data/lib/budget.rb +10 -0
- data/lib/generators/budget/INSTALL +46 -0
- data/lib/generators/budget/install_generator.rb +33 -0
- data/sig/budget.rbs +4 -0
- metadata +115 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateBudgetPayments < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :budget_payments do |t|
|
|
6
|
+
t.references :budget_quote, null: false, foreign_key: true, index: true
|
|
7
|
+
t.decimal :amount, precision: 10, scale: 2, null: false, default: 0.0
|
|
8
|
+
t.datetime :payment_date, null: false
|
|
9
|
+
t.string :payment_method, default: 'efectivo'
|
|
10
|
+
t.text :notes
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :budget_payments, :payment_date
|
|
16
|
+
add_index :budget_payments, :payment_method
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# This example requires a Rails environment with the Budget engine loaded
|
|
5
|
+
# Run from a Rails app that has the budget gem installed:
|
|
6
|
+
# rails runner examples/basic_usage.rb
|
|
7
|
+
|
|
8
|
+
puts '=' * 70
|
|
9
|
+
puts 'EJEMPLO DE USO DEL SISTEMA DE PRESUPUESTOS'
|
|
10
|
+
puts '=' * 70
|
|
11
|
+
puts
|
|
12
|
+
|
|
13
|
+
# 1. Create a new quote for a customer
|
|
14
|
+
puts '1. Creando un nuevo presupuesto para María González...'
|
|
15
|
+
quote = Budget::Quote.create!(
|
|
16
|
+
customer_name: 'María González',
|
|
17
|
+
customer_contact: '555-1234',
|
|
18
|
+
notes: 'Prefiere montura liviana'
|
|
19
|
+
)
|
|
20
|
+
puts "✓ Presupuesto ##{quote.id} creado"
|
|
21
|
+
puts
|
|
22
|
+
|
|
23
|
+
# 2. Add line items (lentes, montura, tratamiento)
|
|
24
|
+
puts '2. Agregando artículos al presupuesto...'
|
|
25
|
+
|
|
26
|
+
# Add lenses
|
|
27
|
+
quote.add_line_item(
|
|
28
|
+
description: 'Lentes progresivos alta gama',
|
|
29
|
+
price: 150.00,
|
|
30
|
+
category: 'lente'
|
|
31
|
+
)
|
|
32
|
+
puts '✓ Agregado: Lentes progresivos - $150.00'
|
|
33
|
+
|
|
34
|
+
# Add frame
|
|
35
|
+
quote.add_line_item(
|
|
36
|
+
description: 'Montura de titanio ligera',
|
|
37
|
+
price: 80.00,
|
|
38
|
+
category: 'montura'
|
|
39
|
+
)
|
|
40
|
+
puts '✓ Agregado: Montura de titanio - $80.00'
|
|
41
|
+
|
|
42
|
+
# Add treatment
|
|
43
|
+
quote.add_line_item(
|
|
44
|
+
description: 'Tratamiento anti-reflejante',
|
|
45
|
+
price: 35.00,
|
|
46
|
+
category: 'tratamiento'
|
|
47
|
+
)
|
|
48
|
+
puts '✓ Agregado: Tratamiento anti-reflejante - $35.00'
|
|
49
|
+
|
|
50
|
+
# Add additional treatment
|
|
51
|
+
quote.add_line_item(
|
|
52
|
+
description: 'Protección UV',
|
|
53
|
+
price: 25.00,
|
|
54
|
+
category: 'tratamiento'
|
|
55
|
+
)
|
|
56
|
+
puts '✓ Agregado: Protección UV - $25.00'
|
|
57
|
+
|
|
58
|
+
# Add case
|
|
59
|
+
quote.add_line_item(
|
|
60
|
+
description: 'Estuche premium',
|
|
61
|
+
price: 10.00,
|
|
62
|
+
category: 'other'
|
|
63
|
+
)
|
|
64
|
+
puts '✓ Agregado: Estuche premium - $10.00'
|
|
65
|
+
puts
|
|
66
|
+
|
|
67
|
+
# 3. Display quote
|
|
68
|
+
puts '3. Presupuesto completo:'
|
|
69
|
+
puts quote
|
|
70
|
+
puts
|
|
71
|
+
|
|
72
|
+
# 4. Add initial payment (adelanto)
|
|
73
|
+
puts '4. Cliente realiza adelanto del 50%...'
|
|
74
|
+
adelanto_amount = quote.total * 0.5
|
|
75
|
+
quote.add_payment(
|
|
76
|
+
amount: adelanto_amount,
|
|
77
|
+
payment_method: 'efectivo',
|
|
78
|
+
notes: 'Adelanto inicial (50%)'
|
|
79
|
+
)
|
|
80
|
+
puts "✓ Adelanto de $#{format('%.2f', adelanto_amount)} registrado"
|
|
81
|
+
puts " Saldo pendiente: $#{format('%.2f', quote.remaining_balance)}"
|
|
82
|
+
puts
|
|
83
|
+
|
|
84
|
+
# 5. Display updated quote
|
|
85
|
+
puts '5. Presupuesto actualizado:'
|
|
86
|
+
puts quote
|
|
87
|
+
puts
|
|
88
|
+
|
|
89
|
+
# Simulate time passing and customer returning
|
|
90
|
+
puts '=' * 70
|
|
91
|
+
puts '... 2 semanas después ...'
|
|
92
|
+
puts '=' * 70
|
|
93
|
+
puts
|
|
94
|
+
|
|
95
|
+
# 6. Customer returns to complete payment
|
|
96
|
+
puts '6. Cliente regresa para completar el pago...'
|
|
97
|
+
remaining = quote.remaining_balance
|
|
98
|
+
quote.add_payment(
|
|
99
|
+
amount: remaining,
|
|
100
|
+
payment_method: 'tarjeta',
|
|
101
|
+
notes: 'Pago final - retira lentes'
|
|
102
|
+
)
|
|
103
|
+
puts "✓ Pago final de $#{format('%.2f', remaining)} registrado"
|
|
104
|
+
puts "✓ Estado: #{quote.fully_paid? ? 'PAGADO COMPLETO ✓' : 'PENDIENTE'}"
|
|
105
|
+
puts
|
|
106
|
+
|
|
107
|
+
# 7. Display final quote
|
|
108
|
+
puts '7. Presupuesto final:'
|
|
109
|
+
puts quote
|
|
110
|
+
puts
|
|
111
|
+
|
|
112
|
+
# 8. Display summary
|
|
113
|
+
puts '8. Resumen del presupuesto:'
|
|
114
|
+
summary = quote.summary
|
|
115
|
+
puts " ID: #{summary[:id]}"
|
|
116
|
+
puts " Cliente: #{summary[:customer_name]}"
|
|
117
|
+
puts " Total: $#{format('%.2f', summary[:total])}"
|
|
118
|
+
puts " Pagado: $#{format('%.2f', summary[:total_paid])}"
|
|
119
|
+
puts " Pendiente: $#{format('%.2f', summary[:remaining_balance])}"
|
|
120
|
+
puts " Estado: #{summary[:fully_paid] ? 'COMPLETO' : 'PENDIENTE'}"
|
|
121
|
+
puts
|
|
122
|
+
puts ' Desglose por categoría:'
|
|
123
|
+
summary[:category_breakdown].each do |category, amount|
|
|
124
|
+
puts " - #{category}: $#{format('%.2f', amount)}"
|
|
125
|
+
end
|
|
126
|
+
puts
|
|
127
|
+
|
|
128
|
+
puts '=' * 70
|
|
129
|
+
puts 'EJEMPLO COMPLETADO'
|
|
130
|
+
puts '=' * 70
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Quick Test Examples - Shows what the test suite validates
|
|
5
|
+
# Run from a Rails app that has the budget gem installed:
|
|
6
|
+
# rails runner examples/test_examples.rb
|
|
7
|
+
|
|
8
|
+
puts '=' * 70
|
|
9
|
+
puts 'BUDGET GEM - TEST EXAMPLES'
|
|
10
|
+
puts '=' * 70
|
|
11
|
+
puts
|
|
12
|
+
|
|
13
|
+
# Example 1: LineItem validation
|
|
14
|
+
puts '1. LineItem - Category Validation'
|
|
15
|
+
puts '-' * 70
|
|
16
|
+
quote = Budget::Quote.create!(customer_name: 'Test Customer')
|
|
17
|
+
item1 = quote.line_items.create!(description: 'Lente', price: 100, category: 'lente')
|
|
18
|
+
item2 = quote.line_items.build(description: 'Invalid', price: 50, category: 'invalid_cat')
|
|
19
|
+
item2.valid? # This will fail validation
|
|
20
|
+
puts "Valid category ('lente'): #{item1.category}"
|
|
21
|
+
puts "Invalid category validation: #{item2.errors[:category].join(', ')}" if item2.errors[:category].any?
|
|
22
|
+
puts
|
|
23
|
+
|
|
24
|
+
# Example 2: Payment method validation
|
|
25
|
+
puts '2. Payment - Method Validation'
|
|
26
|
+
puts '-' * 70
|
|
27
|
+
payment1 = quote.payments.create!(amount: 100, payment_method: 'tarjeta')
|
|
28
|
+
payment2 = quote.payments.create!(amount: 50, payment_method: 'efectivo')
|
|
29
|
+
puts "tarjeta → #{payment1.payment_method}"
|
|
30
|
+
puts "efectivo → #{payment2.payment_method}"
|
|
31
|
+
puts
|
|
32
|
+
|
|
33
|
+
# Example 3: Quote calculations
|
|
34
|
+
puts '3. Quote - Total Calculations'
|
|
35
|
+
puts '-' * 70
|
|
36
|
+
calc_quote = Budget::Quote.create!(customer_name: 'Test Customer')
|
|
37
|
+
calc_quote.add_line_item(description: 'Lente', price: 150.0, category: 'lente')
|
|
38
|
+
calc_quote.add_line_item(description: 'Montura', price: 80.0, category: 'montura')
|
|
39
|
+
calc_quote.add_line_item(description: 'Tratamiento', price: 35.0, category: 'tratamiento', quantity: 2)
|
|
40
|
+
puts 'Items: Lente ($150) + Montura ($80) + Tratamiento ($35 x 2)'
|
|
41
|
+
puts "Total: $#{calc_quote.total}"
|
|
42
|
+
puts "Category breakdown: #{calc_quote.category_breakdown}"
|
|
43
|
+
puts
|
|
44
|
+
|
|
45
|
+
# Example 4: Payment tracking
|
|
46
|
+
puts '4. Quote - Payment Tracking'
|
|
47
|
+
puts '-' * 70
|
|
48
|
+
calc_quote.add_payment(amount: 150.0, notes: 'Adelanto (50%)')
|
|
49
|
+
puts "After adelanto: Paid=$#{calc_quote.total_paid}, Remaining=$#{calc_quote.remaining_balance}"
|
|
50
|
+
calc_quote.add_payment(amount: calc_quote.remaining_balance, notes: 'Final payment')
|
|
51
|
+
puts "After final payment: Paid=$#{calc_quote.total_paid}, Remaining=$#{calc_quote.remaining_balance}"
|
|
52
|
+
puts "Fully paid? #{quote.fully_paid?}"
|
|
53
|
+
puts
|
|
54
|
+
|
|
55
|
+
# Example 5: Type conversions
|
|
56
|
+
puts '5. Type Conversions'
|
|
57
|
+
puts '-' * 70
|
|
58
|
+
item = Budget::LineItem.new(description: 'Test', price: '99.99', quantity: '3')
|
|
59
|
+
puts "String price '99.99' → #{item.price.class}: #{item.price}"
|
|
60
|
+
puts "String quantity '3' → #{item.quantity.class}: #{item.quantity}"
|
|
61
|
+
puts "Subtotal: $#{item.subtotal}"
|
|
62
|
+
puts
|
|
63
|
+
|
|
64
|
+
# Example 6: Edge cases
|
|
65
|
+
puts '6. Edge Cases'
|
|
66
|
+
puts '-' * 70
|
|
67
|
+
edge_quote = Budget.create_quote(customer_name: 'Edge Test')
|
|
68
|
+
edge_quote.add_line_item(description: 'Item', price: 100.0)
|
|
69
|
+
edge_quote.add_payment(amount: 150.0) # Overpayment
|
|
70
|
+
puts 'Total: $100, Paid: $150'
|
|
71
|
+
puts "Remaining balance (overpayment): $#{edge_quote.remaining_balance}"
|
|
72
|
+
puts "Fully paid? #{edge_quote.fully_paid?}"
|
|
73
|
+
puts
|
|
74
|
+
|
|
75
|
+
# Example 7: Spanish formatting
|
|
76
|
+
puts '7. Spanish Translations'
|
|
77
|
+
puts '-' * 70
|
|
78
|
+
categories = %i[lente montura tratamiento other]
|
|
79
|
+
categories.each do |cat|
|
|
80
|
+
item = Budget::LineItem.new(description: 'Test', price: 50, category: cat)
|
|
81
|
+
puts "#{cat} → #{item.category_name}"
|
|
82
|
+
end
|
|
83
|
+
puts
|
|
84
|
+
payment_methods = %w[efectivo tarjeta transferencia cheque other]
|
|
85
|
+
payment_methods.each do |method|
|
|
86
|
+
payment = Budget::Payment.new(amount: 50, payment_method: method)
|
|
87
|
+
puts "#{method} → #{payment.payment_method_name}"
|
|
88
|
+
end
|
|
89
|
+
puts
|
|
90
|
+
|
|
91
|
+
# Example 8: Complete workflow
|
|
92
|
+
puts '8. Complete Workflow (Integration Test)'
|
|
93
|
+
puts '-' * 70
|
|
94
|
+
complete_quote = Budget.create_quote(
|
|
95
|
+
customer_name: 'María González',
|
|
96
|
+
customer_contact: '555-1234'
|
|
97
|
+
)
|
|
98
|
+
complete_quote.add_line_item(description: 'Lentes progresivos', price: 150.0, category: :lente)
|
|
99
|
+
complete_quote.add_line_item(description: 'Montura titanio', price: 80.0, category: :montura)
|
|
100
|
+
complete_quote.add_line_item(description: 'Anti-reflejante', price: 35.0, category: :tratamiento)
|
|
101
|
+
|
|
102
|
+
puts "Total: $#{complete_quote.total}"
|
|
103
|
+
adelanto = complete_quote.total * 0.5
|
|
104
|
+
complete_quote.add_payment(amount: adelanto, notes: 'Adelanto 50%')
|
|
105
|
+
puts "After 50% adelanto: Remaining=$#{complete_quote.remaining_balance}"
|
|
106
|
+
complete_quote.add_payment(amount: complete_quote.remaining_balance, notes: 'Pago final')
|
|
107
|
+
puts "After final payment: Status=#{complete_quote.fully_paid? ? 'COMPLETO' : 'PENDIENTE'}"
|
|
108
|
+
puts
|
|
109
|
+
|
|
110
|
+
puts '=' * 70
|
|
111
|
+
puts 'All examples demonstrate test coverage scenarios'
|
|
112
|
+
puts "Run 'bundle exec rspec' to execute the full test suite"
|
|
113
|
+
puts '=' * 70
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Budget
|
|
4
|
+
# Rails Engine for Budget gem
|
|
5
|
+
# Provides mountable engine functionality with isolated namespace
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace Budget
|
|
8
|
+
|
|
9
|
+
# Ensure app directory is in autoload paths
|
|
10
|
+
config.autoload_paths += %W[
|
|
11
|
+
#{root}/app/models
|
|
12
|
+
#{root}/app/controllers
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
# Add views path for JBuilder templates
|
|
16
|
+
config.paths['app/views'] ||= []
|
|
17
|
+
config.paths['app/views'] << File.expand_path('../../app/views', __dir__)
|
|
18
|
+
|
|
19
|
+
config.generators do |g|
|
|
20
|
+
g.test_framework :rspec
|
|
21
|
+
g.fixture_replacement :factory_bot
|
|
22
|
+
g.factory_bot dir: 'spec/factories'
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Budget
|
|
4
|
+
# Represents a single line item in a quote
|
|
5
|
+
# Examples: lente (lens), montura (frame), tratamiento (treatment)
|
|
6
|
+
class LineItem
|
|
7
|
+
attr_accessor :description, :price, :category, :quantity
|
|
8
|
+
|
|
9
|
+
CATEGORIES = %w[lente montura tratamiento accesorio servicio other].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(description:, price:, category: 'other', quantity: 1)
|
|
12
|
+
@description = description
|
|
13
|
+
@price = price.to_f
|
|
14
|
+
@category = validate_category(category)
|
|
15
|
+
@quantity = quantity.to_i
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Calculate subtotal (price * quantity)
|
|
19
|
+
# @return [Float] Subtotal
|
|
20
|
+
def subtotal
|
|
21
|
+
@price * @quantity
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Get category in Spanish
|
|
25
|
+
# @return [String] Category name in Spanish
|
|
26
|
+
def category_name
|
|
27
|
+
{
|
|
28
|
+
'lente' => 'Lente',
|
|
29
|
+
'montura' => 'Montura',
|
|
30
|
+
'tratamiento' => 'Tratamiento',
|
|
31
|
+
'accesorio' => 'Accesorio',
|
|
32
|
+
'servicio' => 'Servicio',
|
|
33
|
+
'other' => 'Otro'
|
|
34
|
+
}[@category] || @category.capitalize
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Format line item for display
|
|
38
|
+
# @return [String] Formatted line item
|
|
39
|
+
def to_s
|
|
40
|
+
if @quantity > 1
|
|
41
|
+
"#{category_name} - #{@description} (#{@quantity} x $#{format('%.2f', @price)}) = $#{format('%.2f', subtotal)}"
|
|
42
|
+
else
|
|
43
|
+
"#{category_name} - #{@description}: $#{format('%.2f', @price)}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Convert to hash
|
|
48
|
+
# @return [Hash] Line item as hash
|
|
49
|
+
def to_h
|
|
50
|
+
{
|
|
51
|
+
description: @description,
|
|
52
|
+
price: @price,
|
|
53
|
+
category: @category,
|
|
54
|
+
quantity: @quantity,
|
|
55
|
+
subtotal: subtotal
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def validate_category(category)
|
|
62
|
+
category = category.to_s
|
|
63
|
+
CATEGORIES.include?(category) ? category : 'other'
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Budget
|
|
4
|
+
# Represents a payment made towards a quote
|
|
5
|
+
# Can be initial payment (adelanto) or subsequent payments
|
|
6
|
+
class Payment
|
|
7
|
+
attr_accessor :amount, :payment_date, :payment_method, :notes
|
|
8
|
+
|
|
9
|
+
PAYMENT_METHODS = %w[efectivo tarjeta transferencia cheque other].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(amount:, payment_date: nil, payment_method: 'efectivo', notes: nil)
|
|
12
|
+
@amount = amount.to_f
|
|
13
|
+
@payment_date = payment_date || Time.now
|
|
14
|
+
@payment_method = validate_payment_method(payment_method)
|
|
15
|
+
@notes = notes
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Get payment method in Spanish
|
|
19
|
+
# @return [String] Payment method in Spanish
|
|
20
|
+
def payment_method_name
|
|
21
|
+
{
|
|
22
|
+
'efectivo' => 'Efectivo',
|
|
23
|
+
'tarjeta' => 'Tarjeta',
|
|
24
|
+
'transferencia' => 'Transferencia',
|
|
25
|
+
'cheque' => 'Cheque',
|
|
26
|
+
'other' => 'Otro'
|
|
27
|
+
}[@payment_method] || @payment_method.capitalize
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Format payment for display
|
|
31
|
+
# @return [String] Formatted payment
|
|
32
|
+
def to_s
|
|
33
|
+
output = "$#{format('%.2f', @amount)} - #{payment_method_name} (#{@payment_date.strftime('%d/%m/%Y')})"
|
|
34
|
+
output += " - #{@notes}" if @notes
|
|
35
|
+
output
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Convert to hash
|
|
39
|
+
# @return [Hash] Payment as hash
|
|
40
|
+
def to_h
|
|
41
|
+
{
|
|
42
|
+
amount: @amount,
|
|
43
|
+
payment_date: @payment_date,
|
|
44
|
+
payment_method: @payment_method,
|
|
45
|
+
notes: @notes
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def validate_payment_method(method)
|
|
52
|
+
method = method.to_s.downcase
|
|
53
|
+
PAYMENT_METHODS.include?(method) ? method : 'other'
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/budget/quote.rb
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Budget
|
|
4
|
+
# Represents a quote/budget for eyeglasses purchase
|
|
5
|
+
# Manages line items (lentes, montura, tratamiento) and payments
|
|
6
|
+
class Quote
|
|
7
|
+
attr_accessor :id, :customer_name, :customer_contact, :date, :notes
|
|
8
|
+
attr_reader :line_items, :payments
|
|
9
|
+
|
|
10
|
+
def initialize(customer_name:, id: nil, customer_contact: nil, date: nil, notes: nil)
|
|
11
|
+
@id = id || generate_id
|
|
12
|
+
@customer_name = customer_name
|
|
13
|
+
@customer_contact = customer_contact
|
|
14
|
+
@date = date || Time.now
|
|
15
|
+
@notes = notes
|
|
16
|
+
@line_items = []
|
|
17
|
+
@payments = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Add a line item to the quote
|
|
21
|
+
# @param description [String] Item description (e.g., "Lente progresivo", "Montura metal")
|
|
22
|
+
# @param price [Float] Item price
|
|
23
|
+
# @param category [Symbol] :lente, :montura, :tratamiento, :other
|
|
24
|
+
def add_line_item(description:, price:, category: :other, quantity: 1)
|
|
25
|
+
# Convert symbol to string for consistency
|
|
26
|
+
category = category.to_s if category.is_a?(Symbol)
|
|
27
|
+
line_item = LineItem.new(
|
|
28
|
+
description: description,
|
|
29
|
+
price: price,
|
|
30
|
+
category: category,
|
|
31
|
+
quantity: quantity
|
|
32
|
+
)
|
|
33
|
+
@line_items << line_item
|
|
34
|
+
line_item
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Remove a line item by index
|
|
38
|
+
def remove_line_item(index)
|
|
39
|
+
@line_items.delete_at(index)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Add a payment (adelanto or subsequent payment)
|
|
43
|
+
# @param amount [Float] Payment amount
|
|
44
|
+
# @param payment_date [Time] When payment was made
|
|
45
|
+
# @param payment_method [String] How payment was made (efectivo, tarjeta, transferencia)
|
|
46
|
+
# @param notes [String] Additional notes about payment
|
|
47
|
+
def add_payment(amount:, payment_date: nil, payment_method: 'efectivo', notes: nil)
|
|
48
|
+
payment = Payment.new(
|
|
49
|
+
amount: amount,
|
|
50
|
+
payment_date: payment_date || Time.now,
|
|
51
|
+
payment_method: payment_method,
|
|
52
|
+
notes: notes
|
|
53
|
+
)
|
|
54
|
+
@payments << payment
|
|
55
|
+
payment
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Calculate total price of all line items
|
|
59
|
+
# @return [Float] Total price
|
|
60
|
+
def total
|
|
61
|
+
@line_items.sum(&:subtotal)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Calculate total amount paid so far
|
|
65
|
+
# @return [Float] Total paid
|
|
66
|
+
def total_paid
|
|
67
|
+
@payments.sum(&:amount)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Calculate remaining balance to be paid
|
|
71
|
+
# @return [Float] Remaining amount
|
|
72
|
+
def remaining_balance
|
|
73
|
+
total - total_paid
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Check if quote is fully paid
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def fully_paid?
|
|
79
|
+
remaining_balance <= 0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Get the initial payment (adelanto)
|
|
83
|
+
# @return [Payment, nil] First payment or nil if no payments
|
|
84
|
+
def initial_payment
|
|
85
|
+
@payments.first
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Get breakdown by category
|
|
89
|
+
# @return [Hash] Category totals
|
|
90
|
+
def category_breakdown
|
|
91
|
+
breakdown = Hash.new(0)
|
|
92
|
+
@line_items.each do |item|
|
|
93
|
+
breakdown[item.category] += item.subtotal
|
|
94
|
+
end
|
|
95
|
+
breakdown
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Generate a summary of the quote
|
|
99
|
+
# @return [Hash] Quote summary
|
|
100
|
+
def summary
|
|
101
|
+
{
|
|
102
|
+
id: @id,
|
|
103
|
+
customer_name: @customer_name,
|
|
104
|
+
customer_contact: @customer_contact,
|
|
105
|
+
date: @date,
|
|
106
|
+
line_items_count: @line_items.count,
|
|
107
|
+
total: total,
|
|
108
|
+
total_paid: total_paid,
|
|
109
|
+
remaining_balance: remaining_balance,
|
|
110
|
+
fully_paid: fully_paid?,
|
|
111
|
+
category_breakdown: category_breakdown,
|
|
112
|
+
payments_count: @payments.count
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Format quote for display
|
|
117
|
+
# @return [String] Formatted quote
|
|
118
|
+
def to_s
|
|
119
|
+
output = []
|
|
120
|
+
output << ('=' * 60)
|
|
121
|
+
output << "PRESUPUESTO ##{@id}"
|
|
122
|
+
output << ('=' * 60)
|
|
123
|
+
output << "Cliente: #{@customer_name}"
|
|
124
|
+
output << "Contacto: #{@customer_contact}" if @customer_contact
|
|
125
|
+
output << "Fecha: #{@date.strftime('%d/%m/%Y')}"
|
|
126
|
+
output << "Notas: #{@notes}" if @notes
|
|
127
|
+
output << ''
|
|
128
|
+
output << 'DETALLE:'
|
|
129
|
+
output << ('-' * 60)
|
|
130
|
+
|
|
131
|
+
@line_items.each_with_index do |item, index|
|
|
132
|
+
output << "#{index + 1}. #{item}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
output << ('-' * 60)
|
|
136
|
+
output << "TOTAL: $#{format('%.2f', total)}"
|
|
137
|
+
output << ''
|
|
138
|
+
|
|
139
|
+
if @payments.any?
|
|
140
|
+
output << 'PAGOS:'
|
|
141
|
+
output << ('-' * 60)
|
|
142
|
+
@payments.each_with_index do |payment, index|
|
|
143
|
+
output << "#{index + 1}. #{payment}"
|
|
144
|
+
end
|
|
145
|
+
output << ('-' * 60)
|
|
146
|
+
output << "Total Pagado: $#{format('%.2f', total_paid)}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
output << ''
|
|
150
|
+
output << "SALDO PENDIENTE: $#{format('%.2f', remaining_balance)}"
|
|
151
|
+
output << "Estado: #{fully_paid? ? 'PAGADO COMPLETO' : 'PENDIENTE'}"
|
|
152
|
+
output << ('=' * 60)
|
|
153
|
+
|
|
154
|
+
output.join("\n")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def generate_id
|
|
160
|
+
"Q#{Time.now.strftime('%Y%m%d%H%M%S')}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
data/lib/budget.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'budget/version'
|
|
4
|
+
require_relative 'budget/engine'
|
|
5
|
+
|
|
6
|
+
# Budget module provides quote/budget management functionality
|
|
7
|
+
# Rails Engine for managing quotes with line items and payments
|
|
8
|
+
module Budget
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
===============================================================================
|
|
2
|
+
|
|
3
|
+
Budget Engine has been installed!
|
|
4
|
+
|
|
5
|
+
Next steps:
|
|
6
|
+
|
|
7
|
+
1. Run the migrations:
|
|
8
|
+
|
|
9
|
+
$ rails db:migrate
|
|
10
|
+
|
|
11
|
+
2. Start using Budget in your Rails app:
|
|
12
|
+
|
|
13
|
+
# Create a new quote
|
|
14
|
+
quote = Budget::Quote.create!(
|
|
15
|
+
customer_name: "María González",
|
|
16
|
+
customer_contact: "555-1234"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Add line items
|
|
20
|
+
quote.add_line_item(
|
|
21
|
+
description: "Lentes progresivos",
|
|
22
|
+
price: 150.00,
|
|
23
|
+
category: "lente"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Add payment
|
|
27
|
+
quote.add_payment(
|
|
28
|
+
amount: 75.00,
|
|
29
|
+
payment_method: "efectivo",
|
|
30
|
+
notes: "Adelanto 50%"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Check status
|
|
34
|
+
quote.total # => 150.0
|
|
35
|
+
quote.total_paid # => 75.0
|
|
36
|
+
quote.remaining_balance # => 75.0
|
|
37
|
+
quote.fully_paid? # => false
|
|
38
|
+
|
|
39
|
+
3. View the quote:
|
|
40
|
+
|
|
41
|
+
puts quote # Formatted output
|
|
42
|
+
|
|
43
|
+
For more information, visit:
|
|
44
|
+
https://github.com/rubenpazch/lbyte-budget
|
|
45
|
+
|
|
46
|
+
===============================================================================
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'rails/generators/migration'
|
|
5
|
+
|
|
6
|
+
module Budget
|
|
7
|
+
module Generators
|
|
8
|
+
# Rails generator for installing Budget migrations
|
|
9
|
+
# Creates migrations for quotes, line items, and payments tables
|
|
10
|
+
class InstallGenerator < Rails::Generators::Base
|
|
11
|
+
include Rails::Generators::Migration
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path('../../../db/migrate', __dir__)
|
|
14
|
+
|
|
15
|
+
desc 'Generates Budget migrations for quotes, line items, and payments'
|
|
16
|
+
|
|
17
|
+
def self.next_migration_number(dirname)
|
|
18
|
+
next_migration_number = current_migration_number(dirname) + 1
|
|
19
|
+
ActiveRecord::Migration.next_migration_number(next_migration_number)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def copy_migrations
|
|
23
|
+
migration_template '20251129000001_create_budget_quotes.rb', 'db/migrate/create_budget_quotes.rb'
|
|
24
|
+
migration_template '20251129000002_create_budget_line_items.rb', 'db/migrate/create_budget_line_items.rb'
|
|
25
|
+
migration_template '20251129000003_create_budget_payments.rb', 'db/migrate/create_budget_payments.rb'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def show_readme
|
|
29
|
+
readme 'INSTALL' if behavior == :invoke
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/sig/budget.rbs
ADDED