-
Notifications
You must be signed in to change notification settings - Fork 292
Expand file tree
/
Copy pathtransaction.rb
More file actions
389 lines (314 loc) · 14.6 KB
/
Copy pathtransaction.rb
File metadata and controls
389 lines (314 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
class Transaction < ApplicationRecord
include Entryable, Transferable, Ruleable, Splittable
belongs_to :category, optional: true
belongs_to :merchant, optional: true
has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings
# File attachments (receipts, invoices, etc.) using Active Storage
# Supports images (JPEG, PNG, GIF, WebP) and PDFs up to 10MB each
# Maximum 10 attachments per transaction, family-scoped access
has_many_attached :attachments do |attachable|
attachable.variant :thumbnail, resize_to_limit: [ 150, 150 ]
end
# Attachment validation constants
MAX_ATTACHMENTS_PER_TRANSACTION = 10
MAX_ATTACHMENT_SIZE = 10.megabytes
ALLOWED_CONTENT_TYPES = %w[
image/jpeg image/jpg image/png image/gif image/webp
application/pdf
].freeze
validate :validate_attachments, if: -> { attachments.attached? }
accepts_nested_attributes_for :taggings, allow_destroy: true
after_save :clear_merchant_unlinked_association, if: :merchant_id_previously_changed?
# Accessors for exchange_rate stored in extra jsonb field
def exchange_rate
extra&.dig("exchange_rate")
end
def exchange_rate=(value)
if value.blank?
self.extra = (extra || {}).merge("exchange_rate" => nil, "exchange_rate_invalid" => false)
else
begin
normalized_value = Float(value)
raise ArgumentError unless normalized_value.finite?
self.extra = (extra || {}).merge("exchange_rate" => normalized_value, "exchange_rate_invalid" => false)
rescue ArgumentError, TypeError
# Store the raw value for validation error reporting
self.extra = (extra || {}).merge("exchange_rate" => value, "exchange_rate_invalid" => true)
end
end
end
validate :exchange_rate_must_be_valid
private
def exchange_rate_must_be_valid
if extra&.dig("exchange_rate_invalid")
errors.add(:exchange_rate, "must be a number")
elsif exchange_rate.present?
numeric_rate = Float(exchange_rate) rescue nil
if numeric_rate.nil? || !numeric_rate.finite? || numeric_rate <= 0
errors.add(:exchange_rate, "must be greater than 0")
end
end
end
public
enum :kind, {
standard: "standard", # A regular transaction, included in budget analytics
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics
cc_payment: "cc_payment", # A CC payment, excluded from budget analytics (CC payments offset the sum of expense transactions)
loan_payment: "loan_payment", # A payment to a Loan account, treated as an expense in budgets
one_time: "one_time", # A one-time expense/income; included in historical reports, excluded from budget medians
investment_contribution: "investment_contribution" # Transfer to investment/crypto account, treated as an expense in budgets
}
# All kinds where money moves between accounts (transfer? returns true).
# Used for search filters, rule conditions, and UI display.
TRANSFER_KINDS = %w[funds_movement cc_payment loan_payment investment_contribution].freeze
# Kinds excluded from historical reports and period totals (transfers / CC payments only).
REPORT_EXCLUDED_KINDS = %w[funds_movement cc_payment].freeze
# Kinds excluded from budget medians, burn-rate stats, and budget category actuals.
# loan_payment and investment_contribution are intentionally NOT here —
# they represent real cash outflow from a budgeting perspective.
BUDGET_EXCLUDED_KINDS = (REPORT_EXCLUDED_KINDS + %w[one_time]).freeze
# All valid investment activity labels (for UI dropdown)
ACTIVITY_LABELS = [
"Buy", "Sell", "Sweep In", "Sweep Out", "Dividend", "Reinvestment",
"Interest", "Fee", "Transfer", "Contribution", "Withdrawal", "Exchange", "Other"
].freeze
# Internal movement labels that should be excluded from budget (auto cash management)
INTERNAL_MOVEMENT_LABELS = [ "Transfer", "Sweep In", "Sweep Out", "Exchange" ].freeze
# Providers that support pending transaction flags
PENDING_PROVIDERS = %w[simplefin plaid lunchflow enable_banking].freeze
# Pre-computed SQL fragment for subqueries that check if a transaction (aliased as "t") is pending.
# Stored as a constant so static analysis can verify it contains no user input.
PENDING_CHECK_SQL = PENDING_PROVIDERS
.map { |p| "(t.extra -> '#{p}' ->> 'pending')::boolean = true" }
.join(" OR ")
.freeze
# Pending transaction scopes - filter based on provider pending flags in extra JSONB
# Works with any provider that stores pending status in extra["provider_name"]["pending"]
scope :pending, -> {
conditions = PENDING_PROVIDERS.map { |provider| "(transactions.extra -> '#{provider}' ->> 'pending')::boolean = true" }
where(conditions.join(" OR "))
}
scope :excluding_pending, -> {
conditions = PENDING_PROVIDERS.map { |provider| "(transactions.extra -> '#{provider}' ->> 'pending')::boolean IS DISTINCT FROM true" }
where(conditions.join(" AND "))
}
# SQL snippet for raw queries that must exclude pending transactions.
# Use in income statements, balance sheets, and raw analytics.
def self.pending_providers_sql(table_alias = "t")
PENDING_PROVIDERS.map do |provider|
"AND (#{table_alias}.extra -> '#{provider}' ->> 'pending')::boolean IS DISTINCT FROM true"
end.join("\n")
end
# Family-scoped query for Enrichable#clear_ai_cache
def self.family_scope(family)
joins(entry: :account).where(accounts: { family_id: family.id })
end
# Overarching grouping method for all transfer-type transactions
def transfer?
TRANSFER_KINDS.include?(kind)
end
def set_category!(category)
if category.is_a?(String)
category = entry.account.family.categories.find_or_create_by!(
name: category
)
end
update!(category: category)
end
def pending?
extra_data = extra.is_a?(Hash) ? extra : {}
PENDING_PROVIDERS.any? do |provider|
ActiveModel::Type::Boolean.new.cast(extra_data.dig(provider, "pending"))
end
rescue StandardError
false
end
def activity_security_id
extra&.dig("security_id").presence || extra&.dig("security", "id").presence
end
def activity_security
security_id = activity_security_id.to_s
return @activity_security = nil if security_id.blank?
return @activity_security if defined?(@activity_security_id) && @activity_security_id == security_id
@activity_security_id = security_id
@activity_security = Security.find_by(id: security_id)
end
def set_preloaded_activity_security(security)
@activity_security_id = security&.id&.to_s
@activity_security = security
end
# Potential duplicate matching methods
# These help users review and resolve fuzzy-matched pending/posted pairs
def has_potential_duplicate?
potential_posted_match_data.present? && !potential_duplicate_dismissed?
end
def potential_duplicate_entry
return nil unless has_potential_duplicate?
Entry.find_by(id: potential_posted_match_data["entry_id"])
end
def potential_duplicate_reason
potential_posted_match_data&.dig("reason")
end
def potential_duplicate_confidence
potential_posted_match_data&.dig("confidence") || "medium"
end
def low_confidence_duplicate?
potential_duplicate_confidence == "low"
end
def potential_duplicate_posted_amount
potential_posted_match_data&.dig("posted_amount")&.to_d
end
def potential_duplicate_dismissed?
potential_posted_match_data&.dig("dismissed") == true
end
# Merge this pending transaction with its suggested posted match.
# The pending entry is destroyed; the posted entry survives with attributes inherited from both sides.
# Attribute inheritance: Date + Category from pending, Name + Merchant from posted (booked).
def merge_with_duplicate!
return false unless pending?
return false unless has_potential_duplicate?
posted_entry = potential_duplicate_entry
return false unless posted_entry
pending_entry = entry
# Guard: cross-account merges are never valid
if posted_entry.account_id != pending_entry.account_id
Rails.logger.warn("merge_with_duplicate! rejected: posted_entry #{posted_entry.id} belongs to different account than pending entry #{pending_entry.id}")
return false
end
pending_entry_id = pending_entry.id
merge_succeeded = false
ApplicationRecord.transaction(requires_new: true) do
# Row-level locks prevent concurrent merges on the same pair of entries.
# If a concurrent request already destroyed the pending entry, lock! raises
# RecordNotFound — treat that as an idempotent success.
begin
pending_entry.lock!
rescue ActiveRecord::RecordNotFound
Rails.logger.info("Pending entry #{pending_entry_id} already destroyed (concurrent merge), skipping")
return true
end
begin
posted_entry.lock!
rescue ActiveRecord::RecordNotFound
Rails.logger.info("Posted entry #{posted_entry.id} deleted concurrently; aborting merge")
raise ActiveRecord::Rollback
end
# Capture after lock! (which reloads) to guarantee DB-fresh values and avoid
# stale in-memory cached associations (e.g., loaded via touch: true).
external_id = pending_entry.external_id
pending_entry_date = pending_entry.date
# Batch all changes to the surviving posted Transaction into a single update!
# to avoid firing after_save callbacks twice on the same row.
# Lock the Transaction row so concurrent merges into the same posted entry
# cannot race on reading/writing extra (e.g., the manual_merge array).
posted_tx = posted_entry.entryable
posted_tx.lock! if posted_tx.is_a?(Transaction)
if posted_tx.is_a?(Transaction)
tx_attrs = {}
# Merge metadata — always written so the sync engine can skip re-importing.
# Stored as an array so multiple pending entries merged into the same posted
# transaction each preserve their external_id for future sync exclusion.
# Legacy records written as a plain Hash are migrated to a single-element array
# on first append, maintaining backward compatibility.
if external_id.present?
new_record = {
"merged_from_entry_id" => pending_entry_id,
"merged_from_external_id" => external_id,
"merged_at" => Time.current.iso8601,
"source" => pending_entry.source
}
prior = case posted_tx.extra["manual_merge"]
when Array then posted_tx.extra["manual_merge"]
when Hash then [ posted_tx.extra["manual_merge"] ]
else []
end
tx_attrs[:extra] = posted_tx.extra.merge("manual_merge" => prior + [ new_record ])
end
# Attribute inheritance — only when the posted entry is not already user-protected.
unless posted_entry.protected_from_sync?
pending_transaction = pending_entry.entryable
if pending_transaction.is_a?(Transaction) && pending_transaction.category_id.present?
tx_attrs[:category_id] = pending_transaction.category_id
end
end
posted_tx.update!(tx_attrs) if tx_attrs.any?
end
# Date inheritance on the Entry row — separate from the Transaction update above.
unless posted_entry.protected_from_sync?
# Date: pending dates reflect actual transaction initiation time
posted_entry.update!(date: pending_entry_date) if posted_entry.date != pending_entry_date
# Name + Merchant intentionally NOT inherited — booked values are canonical
end
# Lock the posted entry so future syncs cannot overwrite the merged state
posted_entry.mark_user_modified!
Rails.logger.info("User merged pending entry #{pending_entry_id} (ext: #{external_id}) into posted entry #{posted_entry.id}")
pending_entry.destroy!
merge_succeeded = true
end
merge_succeeded
end
# Dismiss the duplicate suggestion - user says these are NOT the same transaction
def dismiss_duplicate_suggestion!
return false unless potential_posted_match_data.present?
updated_extra = (extra || {}).deep_dup
updated_extra["potential_posted_match"]["dismissed"] = true
update!(extra: updated_extra)
Rails.logger.info("User dismissed duplicate suggestion for entry #{entry.id}")
true
end
# Clear the duplicate suggestion entirely
def clear_duplicate_suggestion!
return false unless potential_posted_match_data.present?
updated_extra = (extra || {}).deep_dup
updated_extra.delete("potential_posted_match")
update!(extra: updated_extra)
true
end
# Find potential posted transactions that might be duplicates of this pending transaction
# Returns entries (not transactions) for UI consistency with transfer matcher
# Lists recent posted transactions from the same account for manual merging
def pending_duplicate_candidates(limit: 20, offset: 0)
return Entry.none unless pending? && entry.present?
account = entry.account
currency = entry.currency
# Find recent posted transactions from the same account
conditions = PENDING_PROVIDERS.map { |provider| "(transactions.extra -> '#{provider}' ->> 'pending')::boolean IS NOT TRUE" }
account.entries
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where.not(id: entry.id)
.where(currency: currency)
.where(conditions.join(" AND "))
.order(date: :desc, created_at: :desc)
.limit(limit)
.offset(offset)
end
private
def validate_attachments
# Check attachment count limit
if attachments.size > MAX_ATTACHMENTS_PER_TRANSACTION
errors.add(:attachments, :too_many, max: MAX_ATTACHMENTS_PER_TRANSACTION)
end
# Validate each attachment
attachments.each_with_index do |attachment, index|
# Check file size
if attachment.byte_size > MAX_ATTACHMENT_SIZE
errors.add(:attachments, :too_large, index: index + 1, max_mb: MAX_ATTACHMENT_SIZE / 1.megabyte)
end
# Check content type
unless ALLOWED_CONTENT_TYPES.include?(attachment.content_type)
errors.add(:attachments, :invalid_format, index: index + 1, file_format: attachment.content_type)
end
end
end
def potential_posted_match_data
return nil unless extra.is_a?(Hash)
extra["potential_posted_match"]
end
def clear_merchant_unlinked_association
return unless merchant_id.present? && merchant.is_a?(ProviderMerchant)
family = entry&.account&.family
return unless family
FamilyMerchantAssociation.where(family: family, merchant: merchant).delete_all
end
end