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,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Budget
|
|
4
|
+
# Controller for managing payments within quotes
|
|
5
|
+
# Provides nested resource CRUD operations for payment tracking
|
|
6
|
+
class PaymentsController < ApplicationController
|
|
7
|
+
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
8
|
+
|
|
9
|
+
before_action :set_quote
|
|
10
|
+
before_action :set_payment, only: %i[show update destroy]
|
|
11
|
+
|
|
12
|
+
# GET /budget/quotes/:quote_id/payments
|
|
13
|
+
def index
|
|
14
|
+
@payments = @quote.payments.ordered
|
|
15
|
+
render json: @payments.map { |payment|
|
|
16
|
+
{
|
|
17
|
+
id: payment.id,
|
|
18
|
+
amount: payment.amount,
|
|
19
|
+
payment_date: payment.payment_date,
|
|
20
|
+
payment_method: payment.payment_method,
|
|
21
|
+
notes: payment.notes,
|
|
22
|
+
created_at: payment.created_at,
|
|
23
|
+
payment_method_name: payment.payment_method_name
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# GET /budget/quotes/:quote_id/payments/:id
|
|
29
|
+
def show
|
|
30
|
+
# @payment set by before_action
|
|
31
|
+
render json: {
|
|
32
|
+
id: @payment.id,
|
|
33
|
+
amount: @payment.amount,
|
|
34
|
+
payment_date: @payment.payment_date,
|
|
35
|
+
payment_method: @payment.payment_method,
|
|
36
|
+
notes: @payment.notes,
|
|
37
|
+
created_at: @payment.created_at,
|
|
38
|
+
updated_at: @payment.updated_at,
|
|
39
|
+
payment_method_name: @payment.payment_method_name,
|
|
40
|
+
quote_id: @payment.budget_quote_id
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# POST /budget/quotes/:quote_id/payments
|
|
45
|
+
def create
|
|
46
|
+
@payment = @quote.payments.new(payment_params)
|
|
47
|
+
|
|
48
|
+
if @payment.save
|
|
49
|
+
render json: {
|
|
50
|
+
id: @payment.id,
|
|
51
|
+
amount: @payment.amount,
|
|
52
|
+
payment_date: @payment.payment_date,
|
|
53
|
+
payment_method: @payment.payment_method,
|
|
54
|
+
notes: @payment.notes,
|
|
55
|
+
created_at: @payment.created_at,
|
|
56
|
+
updated_at: @payment.updated_at,
|
|
57
|
+
payment_method_name: @payment.payment_method_name,
|
|
58
|
+
quote_id: @payment.budget_quote_id
|
|
59
|
+
}, status: :created
|
|
60
|
+
else
|
|
61
|
+
render json: { errors: @payment.errors.full_messages }, status: :unprocessable_entity
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# PATCH/PUT /budget/quotes/:quote_id/payments/:id
|
|
66
|
+
def update
|
|
67
|
+
if @payment.update(payment_params)
|
|
68
|
+
render json: {
|
|
69
|
+
id: @payment.id,
|
|
70
|
+
amount: @payment.amount,
|
|
71
|
+
payment_date: @payment.payment_date,
|
|
72
|
+
payment_method: @payment.payment_method,
|
|
73
|
+
notes: @payment.notes,
|
|
74
|
+
created_at: @payment.created_at,
|
|
75
|
+
updated_at: @payment.updated_at,
|
|
76
|
+
payment_method_name: @payment.payment_method_name,
|
|
77
|
+
quote_id: @payment.budget_quote_id
|
|
78
|
+
}
|
|
79
|
+
else
|
|
80
|
+
render json: { errors: @payment.errors.full_messages }, status: :unprocessable_entity
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# DELETE /budget/quotes/:quote_id/payments/:id
|
|
85
|
+
def destroy
|
|
86
|
+
@payment.destroy
|
|
87
|
+
head :no_content
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def set_quote
|
|
93
|
+
@quote = Quote.find(params[:quote_id])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def set_payment
|
|
97
|
+
@payment = @quote.payments.find(params[:id])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def payment_params
|
|
101
|
+
params.require(:payment).permit(:amount, :payment_date, :payment_method, :notes)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def not_found
|
|
105
|
+
render json: { error: 'Not found' }, status: :not_found
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Budget
|
|
4
|
+
# Controller for managing budget quotes via REST API
|
|
5
|
+
# Provides CRUD operations and summary endpoints for quotes
|
|
6
|
+
class QuotesController < ApplicationController
|
|
7
|
+
rescue_from ActiveRecord::RecordNotFound, with: :not_found
|
|
8
|
+
|
|
9
|
+
before_action :set_quote, only: %i[show update destroy]
|
|
10
|
+
|
|
11
|
+
# GET /budget/quotes
|
|
12
|
+
def index
|
|
13
|
+
@quotes = Quote.includes(:line_items, :payments)
|
|
14
|
+
.order(created_at: :desc)
|
|
15
|
+
|
|
16
|
+
render json: @quotes.map { |quote|
|
|
17
|
+
{
|
|
18
|
+
id: quote.id,
|
|
19
|
+
customer_name: quote.customer_name,
|
|
20
|
+
customer_contact: quote.customer_contact,
|
|
21
|
+
quote_date: quote.quote_date,
|
|
22
|
+
created_at: quote.created_at,
|
|
23
|
+
line_items_count: quote.line_items.size,
|
|
24
|
+
payments_count: quote.payments.size,
|
|
25
|
+
totals: {
|
|
26
|
+
total: quote.total,
|
|
27
|
+
total_paid: quote.total_paid,
|
|
28
|
+
remaining_balance: quote.remaining_balance,
|
|
29
|
+
fully_paid: quote.fully_paid?
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# GET /budget/quotes/:id
|
|
36
|
+
def show
|
|
37
|
+
# @quote set by before_action
|
|
38
|
+
render json: {
|
|
39
|
+
id: @quote.id,
|
|
40
|
+
customer_name: @quote.customer_name,
|
|
41
|
+
customer_contact: @quote.customer_contact,
|
|
42
|
+
notes: @quote.notes,
|
|
43
|
+
quote_date: @quote.quote_date,
|
|
44
|
+
created_at: @quote.created_at,
|
|
45
|
+
updated_at: @quote.updated_at,
|
|
46
|
+
line_items: @quote.line_items.map do |item|
|
|
47
|
+
{
|
|
48
|
+
id: item.id,
|
|
49
|
+
description: item.description,
|
|
50
|
+
price: item.price,
|
|
51
|
+
category: item.category,
|
|
52
|
+
quantity: item.quantity,
|
|
53
|
+
created_at: item.created_at,
|
|
54
|
+
category_name: item.category_name,
|
|
55
|
+
subtotal: item.subtotal
|
|
56
|
+
}
|
|
57
|
+
end,
|
|
58
|
+
payments: @quote.payments.ordered.map do |payment|
|
|
59
|
+
{
|
|
60
|
+
id: payment.id,
|
|
61
|
+
amount: payment.amount,
|
|
62
|
+
payment_date: payment.payment_date,
|
|
63
|
+
payment_method: payment.payment_method,
|
|
64
|
+
notes: payment.notes,
|
|
65
|
+
created_at: payment.created_at,
|
|
66
|
+
payment_method_name: payment.payment_method_name
|
|
67
|
+
}
|
|
68
|
+
end,
|
|
69
|
+
totals: {
|
|
70
|
+
total: @quote.total,
|
|
71
|
+
total_paid: @quote.total_paid,
|
|
72
|
+
remaining_balance: @quote.remaining_balance,
|
|
73
|
+
fully_paid: @quote.fully_paid?
|
|
74
|
+
},
|
|
75
|
+
category_breakdown: @quote.category_breakdown
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# POST /budget/quotes
|
|
80
|
+
def create
|
|
81
|
+
@quote = Quote.new(quote_params)
|
|
82
|
+
|
|
83
|
+
if @quote.save
|
|
84
|
+
render json: {
|
|
85
|
+
id: @quote.id,
|
|
86
|
+
customer_name: @quote.customer_name,
|
|
87
|
+
customer_contact: @quote.customer_contact,
|
|
88
|
+
notes: @quote.notes,
|
|
89
|
+
quote_date: @quote.quote_date,
|
|
90
|
+
created_at: @quote.created_at,
|
|
91
|
+
updated_at: @quote.updated_at,
|
|
92
|
+
line_items: [],
|
|
93
|
+
payments: [],
|
|
94
|
+
totals: {
|
|
95
|
+
total: 0.0,
|
|
96
|
+
total_paid: 0.0,
|
|
97
|
+
remaining_balance: 0.0,
|
|
98
|
+
fully_paid: false
|
|
99
|
+
},
|
|
100
|
+
category_breakdown: {}
|
|
101
|
+
}, status: :created
|
|
102
|
+
else
|
|
103
|
+
render json: { errors: @quote.errors.full_messages }, status: :unprocessable_entity
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# PATCH/PUT /budget/quotes/:id
|
|
108
|
+
def update
|
|
109
|
+
if @quote.update(quote_params)
|
|
110
|
+
render json: {
|
|
111
|
+
id: @quote.id,
|
|
112
|
+
customer_name: @quote.customer_name,
|
|
113
|
+
customer_contact: @quote.customer_contact,
|
|
114
|
+
notes: @quote.notes,
|
|
115
|
+
quote_date: @quote.quote_date,
|
|
116
|
+
created_at: @quote.created_at,
|
|
117
|
+
updated_at: @quote.updated_at,
|
|
118
|
+
line_items: @quote.line_items.map do |item|
|
|
119
|
+
{
|
|
120
|
+
id: item.id,
|
|
121
|
+
description: item.description,
|
|
122
|
+
price: item.price,
|
|
123
|
+
category: item.category,
|
|
124
|
+
quantity: item.quantity,
|
|
125
|
+
created_at: item.created_at,
|
|
126
|
+
category_name: item.category_name,
|
|
127
|
+
subtotal: item.subtotal
|
|
128
|
+
}
|
|
129
|
+
end,
|
|
130
|
+
payments: @quote.payments.ordered.map do |payment|
|
|
131
|
+
{
|
|
132
|
+
id: payment.id,
|
|
133
|
+
amount: payment.amount,
|
|
134
|
+
payment_date: payment.payment_date,
|
|
135
|
+
payment_method: payment.payment_method,
|
|
136
|
+
notes: payment.notes,
|
|
137
|
+
created_at: payment.created_at,
|
|
138
|
+
payment_method_name: payment.payment_method_name
|
|
139
|
+
}
|
|
140
|
+
end,
|
|
141
|
+
totals: {
|
|
142
|
+
total: @quote.total,
|
|
143
|
+
total_paid: @quote.total_paid,
|
|
144
|
+
remaining_balance: @quote.remaining_balance,
|
|
145
|
+
fully_paid: @quote.fully_paid?
|
|
146
|
+
},
|
|
147
|
+
category_breakdown: @quote.category_breakdown
|
|
148
|
+
}
|
|
149
|
+
else
|
|
150
|
+
render json: { errors: @quote.errors.full_messages }, status: :unprocessable_entity
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# DELETE /budget/quotes/:id
|
|
155
|
+
def destroy
|
|
156
|
+
@quote.destroy
|
|
157
|
+
head :no_content
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# GET /budget/quotes/:id/summary
|
|
161
|
+
def summary
|
|
162
|
+
@quote = Quote.find(params[:id])
|
|
163
|
+
render json: @quote.summary
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def set_quote
|
|
169
|
+
@quote = Quote.includes(:line_items, :payments).find(params[:id])
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def quote_params
|
|
173
|
+
params.require(:quote).permit(:customer_name, :customer_contact, :quote_date, :notes)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def not_found
|
|
177
|
+
render json: { error: 'Not found' }, status: :not_found
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Budget
|
|
4
|
+
# ActiveRecord model for line items within quotes
|
|
5
|
+
# Represents individual items/services in a quote
|
|
6
|
+
class LineItem < ActiveRecord::Base
|
|
7
|
+
self.table_name = 'budget_line_items'
|
|
8
|
+
|
|
9
|
+
CATEGORIES = %w[lente montura tratamiento accesorio servicio other].freeze
|
|
10
|
+
|
|
11
|
+
# Associations
|
|
12
|
+
belongs_to :quote, class_name: 'Budget::Quote', foreign_key: 'budget_quote_id'
|
|
13
|
+
|
|
14
|
+
# Validations
|
|
15
|
+
validates :description, presence: true
|
|
16
|
+
validates :price, presence: true, numericality: { greater_than: 0 }
|
|
17
|
+
validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
|
18
|
+
validates :category, presence: true, inclusion: { in: CATEGORIES }
|
|
19
|
+
|
|
20
|
+
# Callbacks
|
|
21
|
+
before_validation :set_default_category
|
|
22
|
+
|
|
23
|
+
# Calculate subtotal (price * quantity)
|
|
24
|
+
# @return [Float] Subtotal
|
|
25
|
+
def subtotal
|
|
26
|
+
price * quantity
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Get category in Spanish
|
|
30
|
+
# @return [String] Category name in Spanish
|
|
31
|
+
def category_name
|
|
32
|
+
{
|
|
33
|
+
'lente' => 'Lente',
|
|
34
|
+
'montura' => 'Montura',
|
|
35
|
+
'tratamiento' => 'Tratamiento',
|
|
36
|
+
'accesorio' => 'Accesorio',
|
|
37
|
+
'servicio' => 'Servicio',
|
|
38
|
+
'other' => 'Otro'
|
|
39
|
+
}[category] || category.to_s.capitalize
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Format line item for display
|
|
43
|
+
# @return [String] Formatted line item
|
|
44
|
+
def to_s
|
|
45
|
+
if quantity > 1
|
|
46
|
+
"#{category_name} - #{description} (#{quantity} x $#{format('%.2f', price)}) = $#{format('%.2f', subtotal)}"
|
|
47
|
+
else
|
|
48
|
+
"#{category_name} - #{description}: $#{format('%.2f', price)}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def set_default_category
|
|
55
|
+
self.category = 'other' if category.blank?
|
|
56
|
+
self.category = 'other' unless CATEGORIES.include?(category)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Budget
|
|
4
|
+
# ActiveRecord model for payments within quotes
|
|
5
|
+
# Tracks payment transactions for quotes
|
|
6
|
+
class Payment < ActiveRecord::Base
|
|
7
|
+
self.table_name = 'budget_payments'
|
|
8
|
+
|
|
9
|
+
PAYMENT_METHODS = %w[efectivo tarjeta transferencia cheque other].freeze
|
|
10
|
+
|
|
11
|
+
# Associations
|
|
12
|
+
belongs_to :quote, class_name: 'Budget::Quote', foreign_key: 'budget_quote_id'
|
|
13
|
+
|
|
14
|
+
# Validations
|
|
15
|
+
validates :amount, presence: true, numericality: { greater_than: 0 }
|
|
16
|
+
validates :payment_date, presence: true
|
|
17
|
+
validates :payment_method, presence: true, inclusion: { in: PAYMENT_METHODS }
|
|
18
|
+
|
|
19
|
+
# Callbacks
|
|
20
|
+
before_validation :set_default_payment_method
|
|
21
|
+
before_validation :set_default_payment_date
|
|
22
|
+
|
|
23
|
+
# Scopes
|
|
24
|
+
scope :ordered, -> { order(:payment_date) }
|
|
25
|
+
scope :by_method, ->(method) { where(payment_method: method) }
|
|
26
|
+
|
|
27
|
+
# Get payment method in Spanish
|
|
28
|
+
# @return [String] Payment method in Spanish
|
|
29
|
+
def payment_method_name
|
|
30
|
+
{
|
|
31
|
+
'efectivo' => 'Efectivo',
|
|
32
|
+
'tarjeta' => 'Tarjeta',
|
|
33
|
+
'transferencia' => 'Transferencia',
|
|
34
|
+
'cheque' => 'Cheque',
|
|
35
|
+
'other' => 'Otro'
|
|
36
|
+
}[payment_method] || payment_method.to_s.capitalize
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Format payment for display
|
|
40
|
+
# @return [String] Formatted payment
|
|
41
|
+
def to_s
|
|
42
|
+
output = "$#{format('%.2f', amount)}, #{payment_method_name} (#{payment_date.strftime('%d/%m/%Y')})"
|
|
43
|
+
output += " - #{notes}" if notes.present?
|
|
44
|
+
output
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def set_default_payment_method
|
|
50
|
+
self.payment_method = 'efectivo' if payment_method.blank?
|
|
51
|
+
self.payment_method = 'other' unless PAYMENT_METHODS.include?(payment_method)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def set_default_payment_date
|
|
55
|
+
self.payment_date ||= Time.current
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Budget
|
|
4
|
+
# ActiveRecord model for budget quotes
|
|
5
|
+
# Manages quotes with line items and payments
|
|
6
|
+
class Quote < ActiveRecord::Base
|
|
7
|
+
self.table_name = 'budget_quotes'
|
|
8
|
+
|
|
9
|
+
# External associations (optional - can be defined in host application)
|
|
10
|
+
# Uncomment this line in your host app's Budget::Quote decorator if needed:
|
|
11
|
+
# belongs_to :prescription, optional: true
|
|
12
|
+
|
|
13
|
+
# Associations
|
|
14
|
+
has_many :line_items, class_name: 'Budget::LineItem', foreign_key: 'budget_quote_id', dependent: :destroy
|
|
15
|
+
has_many :payments, class_name: 'Budget::Payment', foreign_key: 'budget_quote_id', dependent: :destroy
|
|
16
|
+
|
|
17
|
+
# Validations
|
|
18
|
+
validates :customer_name, presence: true
|
|
19
|
+
validates :quote_date, presence: true
|
|
20
|
+
|
|
21
|
+
# Callbacks
|
|
22
|
+
before_validation :set_quote_date, on: :create
|
|
23
|
+
|
|
24
|
+
# Scopes
|
|
25
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
26
|
+
scope :by_customer, ->(name) { where('customer_name LIKE ?', "%#{name}%") }
|
|
27
|
+
scope :pending, lambda {
|
|
28
|
+
# Find quotes where payments don't cover all line items
|
|
29
|
+
# This requires calculating totals in Ruby since we don't have a total column
|
|
30
|
+
all.reject(&:fully_paid?)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Calculate total price of all line items
|
|
34
|
+
# @return [Float] Total price
|
|
35
|
+
def total
|
|
36
|
+
line_items.sum(&:subtotal)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Calculate total amount paid so far
|
|
40
|
+
# @return [Float] Total paid
|
|
41
|
+
def total_paid
|
|
42
|
+
payments.sum(:amount)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Calculate remaining balance to be paid
|
|
46
|
+
# @return [Float] Remaining amount
|
|
47
|
+
def remaining_balance
|
|
48
|
+
total - total_paid
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if quote is fully paid
|
|
52
|
+
# @return [Boolean]
|
|
53
|
+
def fully_paid?
|
|
54
|
+
remaining_balance <= 0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get the initial payment (adelanto)
|
|
58
|
+
# @return [Payment, nil] First payment or nil if no payments
|
|
59
|
+
def initial_payment
|
|
60
|
+
payments.order(:payment_date).first
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Get breakdown by category
|
|
64
|
+
# @return [Hash] Category totals
|
|
65
|
+
def category_breakdown
|
|
66
|
+
breakdown = Hash.new(0)
|
|
67
|
+
line_items.each do |item|
|
|
68
|
+
breakdown[item.category.to_sym] += item.subtotal
|
|
69
|
+
end
|
|
70
|
+
breakdown
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Generate a summary of the quote
|
|
74
|
+
# @return [Hash] Quote summary
|
|
75
|
+
def summary
|
|
76
|
+
{
|
|
77
|
+
id: id,
|
|
78
|
+
customer_name: customer_name,
|
|
79
|
+
customer_contact: customer_contact,
|
|
80
|
+
date: quote_date,
|
|
81
|
+
line_items_count: line_items.count,
|
|
82
|
+
total: total,
|
|
83
|
+
total_paid: total_paid,
|
|
84
|
+
remaining_balance: remaining_balance,
|
|
85
|
+
fully_paid: fully_paid?,
|
|
86
|
+
category_breakdown: category_breakdown,
|
|
87
|
+
payments_count: payments.count
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Format quote for display
|
|
92
|
+
# @return [String] Formatted quote
|
|
93
|
+
def to_s
|
|
94
|
+
output = []
|
|
95
|
+
output << ('=' * 60)
|
|
96
|
+
output << "PRESUPUESTO ##{id}"
|
|
97
|
+
output << ('=' * 60)
|
|
98
|
+
output << "Cliente: #{customer_name}"
|
|
99
|
+
output << "Contacto: #{customer_contact}" if customer_contact
|
|
100
|
+
output << "Fecha: #{quote_date.strftime('%d/%m/%Y')}"
|
|
101
|
+
output << "Notas: #{notes}" if notes
|
|
102
|
+
output << ''
|
|
103
|
+
output << 'DETALLE:'
|
|
104
|
+
output << ('-' * 60)
|
|
105
|
+
|
|
106
|
+
line_items.each_with_index do |item, index|
|
|
107
|
+
output << "#{index + 1}. #{item}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
output << ('-' * 60)
|
|
111
|
+
output << "TOTAL: $#{format('%.2f', total)}"
|
|
112
|
+
output << ''
|
|
113
|
+
|
|
114
|
+
if payments.any?
|
|
115
|
+
output << 'PAGOS:'
|
|
116
|
+
output << ('-' * 60)
|
|
117
|
+
payments.order(:payment_date).each_with_index do |payment, index|
|
|
118
|
+
output << "#{index + 1}. #{payment}"
|
|
119
|
+
end
|
|
120
|
+
output << ('-' * 60)
|
|
121
|
+
output << "Total Pagado: $#{format('%.2f', total_paid)}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
output << ''
|
|
125
|
+
output << "SALDO PENDIENTE: $#{format('%.2f', remaining_balance)}"
|
|
126
|
+
output << "Estado: #{fully_paid? ? 'PAGADO COMPLETO' : 'PENDIENTE'}"
|
|
127
|
+
output << ('=' * 60)
|
|
128
|
+
|
|
129
|
+
output.join("\n")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Add a line item to the quote
|
|
133
|
+
def add_line_item(description:, price:, category: 'other', quantity: 1)
|
|
134
|
+
line_items.create!(
|
|
135
|
+
description: description,
|
|
136
|
+
price: price,
|
|
137
|
+
category: category,
|
|
138
|
+
quantity: quantity
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Add a payment (adelanto or subsequent payment)
|
|
143
|
+
def add_payment(amount:, payment_date: nil, payment_method: 'efectivo', notes: nil)
|
|
144
|
+
payments.create!(
|
|
145
|
+
amount: amount,
|
|
146
|
+
payment_date: payment_date || Time.current,
|
|
147
|
+
payment_method: payment_method,
|
|
148
|
+
notes: notes
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def set_quote_date
|
|
155
|
+
self.quote_date ||= Time.current
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
json.array! @quotes do |quote|
|
|
4
|
+
json.extract! quote, :id, :customer_name, :customer_contact, :quote_date, :created_at
|
|
5
|
+
|
|
6
|
+
json.line_items_count quote.line_items.size
|
|
7
|
+
json.payments_count quote.payments.size
|
|
8
|
+
|
|
9
|
+
json.totals do
|
|
10
|
+
json.total quote.total
|
|
11
|
+
json.total_paid quote.total_paid
|
|
12
|
+
json.remaining_balance quote.remaining_balance
|
|
13
|
+
json.fully_paid quote.fully_paid?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
json.extract! @quote, :id, :customer_name, :customer_contact, :notes, :quote_date, :created_at, :updated_at
|
|
4
|
+
|
|
5
|
+
json.line_items @quote.line_items do |item|
|
|
6
|
+
json.extract! item, :id, :description, :price, :category, :quantity, :created_at
|
|
7
|
+
json.category_name item.category_name
|
|
8
|
+
json.subtotal item.subtotal
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
json.payments @quote.payments.ordered do |payment|
|
|
12
|
+
json.extract! payment, :id, :amount, :payment_date, :payment_method, :notes, :created_at
|
|
13
|
+
json.payment_method_name payment.payment_method_name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
json.totals do
|
|
17
|
+
json.total @quote.total
|
|
18
|
+
json.total_paid @quote.total_paid
|
|
19
|
+
json.remaining_balance @quote.remaining_balance
|
|
20
|
+
json.fully_paid @quote.fully_paid?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
json.category_breakdown @quote.category_breakdown
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Budget::Engine.routes.draw do
|
|
4
|
+
resources :quotes do
|
|
5
|
+
member do
|
|
6
|
+
get :summary
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
resources :line_items, only: %i[index show create update destroy]
|
|
10
|
+
resources :payments, only: %i[index show create update destroy]
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateBudgetQuotes < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :budget_quotes do |t|
|
|
6
|
+
t.string :customer_name, null: false
|
|
7
|
+
t.string :customer_contact
|
|
8
|
+
t.text :notes
|
|
9
|
+
t.datetime :quote_date
|
|
10
|
+
|
|
11
|
+
t.timestamps
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :budget_quotes, :customer_name
|
|
15
|
+
add_index :budget_quotes, :created_at
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateBudgetLineItems < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :budget_line_items do |t|
|
|
6
|
+
t.references :budget_quote, null: false, foreign_key: true, index: true
|
|
7
|
+
t.string :description, null: false
|
|
8
|
+
t.decimal :price, precision: 10, scale: 2, null: false, default: 0.0
|
|
9
|
+
t.string :category, default: 'other'
|
|
10
|
+
t.integer :quantity, default: 1, null: false
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :budget_line_items, :category
|
|
16
|
+
end
|
|
17
|
+
end
|