Files
sure/app/models/akahu_entry/processor.rb
Brad 1b8b21760b feat(provider): Akahu integration (#1921)
* First pass of Akahu

* fix up sync all

* conflicts

* fix db migration issue? - fix auto selection of akahu account type

* Address Akahu PR feedback

* Complete provider metadata

* Fix PR 1921 CI tests

* PR feedback

* PR feedback

* post merge

---------

Co-authored-by: failing <failing@users.noreply.github.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-06-02 21:44:57 +02:00

242 lines
7.4 KiB
Ruby

require "digest/md5"
class AkahuEntry::Processor
include CurrencyNormalizable
def self.canonical_external_id(akahu_transaction)
data = akahu_transaction.with_indifferent_access
id = data[:_id].presence || data[:id].presence
return "akahu_#{id}" if id.present?
"akahu_pending_#{content_hash_for(data)}"
end
def self.pending?(akahu_transaction)
data = akahu_transaction.with_indifferent_access
ActiveModel::Type::Boolean.new.cast(data[:_pending]) == true ||
ActiveModel::Type::Boolean.new.cast(data[:pending]) == true
end
def self.content_hash_for(data)
merchant = data[:merchant].is_a?(Hash) ? data[:merchant].with_indifferent_access : {}
attributes = [
data[:_account],
data[:account],
data[:date],
data[:amount],
data[:description],
merchant[:name].to_s.strip.presence,
data[:type]
].compact.join("|")
Digest::MD5.hexdigest(attributes)
end
def initialize(akahu_transaction, akahu_account:)
@akahu_transaction = akahu_transaction
@akahu_account = akahu_account
end
def process
unless account.present?
Rails.logger.warn "AkahuEntry::Processor - No linked account for akahu_account #{akahu_account.id}, skipping transaction #{external_id}"
return nil
end
import_adapter.import_transaction(
external_id: external_id,
amount: amount,
currency: currency,
date: date,
name: name,
source: "akahu",
merchant: merchant,
notes: notes,
extra: extra_metadata
)
rescue ArgumentError => e
Rails.logger.error "AkahuEntry::Processor - Validation error for transaction #{external_id}: #{e.message}"
raise
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
Rails.logger.error "AkahuEntry::Processor - Failed to save transaction #{external_id}: #{e.message}"
raise StandardError.new("Failed to import transaction: #{e.message}")
rescue => e
Rails.logger.error "AkahuEntry::Processor - Unexpected error processing transaction #{external_id}: #{e.class} - #{e.message}"
Rails.logger.error e.backtrace.join("\n")
raise StandardError.new("Unexpected error importing transaction: #{e.message}")
end
private
attr_reader :akahu_transaction, :akahu_account
def import_adapter
@import_adapter ||= Account::ProviderImportAdapter.new(account)
end
def account
@account ||= akahu_account.current_account
end
def data
@data ||= akahu_transaction.with_indifferent_access
end
def external_id
@external_id ||= begin
id = data[:_id].presence || data[:id].presence
if id.present?
"akahu_#{id}"
else
base_id = self.class.canonical_external_id(data)
if existing_pending_entry?(base_id)
base_id
else
final_id = base_id
counter = 1
while entry_exists_with_external_id?(final_id)
final_id = "#{base_id}_#{counter}"
counter += 1
end
final_id
end
end
end
end
def existing_pending_entry?(external_id)
existing_entry = account&.entries&.find_by(external_id: external_id, source: "akahu")
existing_entry&.entryable.is_a?(Transaction) && existing_entry.entryable.pending?
end
def entry_exists_with_external_id?(external_id)
account.present? && account.entries.exists?(external_id: external_id, source: "akahu")
end
def name
merchant_name.presence || data[:description].presence || I18n.t("transactions.unknown_name")
end
def notes
meta = meta_data
parts = []
parts << data[:description] if data[:description].present? && data[:description] != name
parts << "#{t('akahu_entry.notes.reference')}: #{meta[:reference]}" if meta[:reference].present?
parts << "#{t('akahu_entry.notes.particulars')}: #{meta[:particulars]}" if meta[:particulars].present?
parts << "#{t('akahu_entry.notes.code')}: #{meta[:code]}" if meta[:code].present?
parts << "#{t('akahu_entry.notes.other_account')}: #{meta[:other_account]}" if meta[:other_account].present?
parts.presence&.join(" | ")
end
def merchant
return nil unless merchant_name.present?
provider_merchant_id = merchant_data[:_id].presence || merchant_data[:id].presence
provider_merchant_id ||= "akahu_merchant_#{Digest::MD5.hexdigest(merchant_name.downcase)}"
@merchant ||= import_adapter.find_or_create_merchant(
provider_merchant_id: provider_merchant_id,
name: merchant_name,
source: "akahu",
website_url: merchant_data[:website]
)
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "AkahuEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}"
nil
end
def amount
parsed_amount = case data[:amount]
when String
BigDecimal(data[:amount])
when Numeric
BigDecimal(data[:amount].to_s)
else
BigDecimal("0")
end
# Akahu uses banking convention: negative is money out, positive is money in.
# Sure stores expenses as positive and income as negative.
-parsed_amount
rescue ArgumentError => e
Rails.logger.error "Failed to parse Akahu transaction amount: #{e.class}"
raise ArgumentError, "Invalid transaction amount"
end
def currency
parse_currency(data[:currency]) || akahu_account.currency || account&.currency || "NZD"
end
def date
value = data[:date]
case value
when String
Date.parse(value)
when Integer, Float
Time.at(value).to_date
when Time, DateTime
value.to_date
when Date
value
else
Rails.logger.error("Akahu transaction has invalid date value")
raise ArgumentError, "Invalid date format"
end
rescue ArgumentError, TypeError => e
Rails.logger.error("Failed to parse Akahu transaction date: #{e.class}")
raise ArgumentError, "Unable to parse transaction date"
end
def extra_metadata
{
"akahu" => {
"pending" => pending?,
"type" => data[:type],
"category" => category_data[:name],
"category_id" => category_data[:_id].presence || category_data[:id],
"category_group" => category_group_name,
"reference" => meta_data[:reference],
"particulars" => meta_data[:particulars],
"code" => meta_data[:code],
"other_account" => meta_data[:other_account]
}.compact
}
end
def pending?
self.class.pending?(data)
end
def merchant_data
@merchant_data ||= data[:merchant].is_a?(Hash) ? data[:merchant].with_indifferent_access : {}
end
def merchant_name
merchant_data[:name].to_s.strip.presence
end
def category_data
@category_data ||= data[:category].is_a?(Hash) ? data[:category].with_indifferent_access : {}
end
def category_group_name
groups = category_data[:groups]
return nil unless groups.is_a?(Hash)
groups.with_indifferent_access.dig(:personal_finance, :name)
end
def meta_data
@meta_data ||= data[:meta].is_a?(Hash) ? data[:meta].with_indifferent_access : {}
end
def t(key, **options)
I18n.t(key, **options)
end
def log_invalid_currency(currency_value)
Rails.logger.warn("Invalid currency code '#{currency_value}' in Akahu transaction #{external_id}, falling back to account currency")
end
end