Files
sure/app/models/provider/up.rb
threatsurfer ab32388326 feat(up): flag internal transfers and round-ups as funds_movement (#2460)
* feat(up): flag internal transfers and round-ups as funds_movement

Up populates relationships.transferAccount on transactions that move money
between the user's own accounts (including round-ups swept into a Saver), but
flatten_transaction dropped it, so these imported as ordinary income/expense
and distorted budgets and cashflow.

- Provider::Up#flatten_transaction: lift transfer_account_id from
  relationships.transferAccount.data.id.
- UpEntry::Processor: import transfers as funds_movement and persist
  transfer_account_id in extra["up"].
- Account::ProviderImportAdapter#import_transaction: optional kind: param; an
  explicit provider kind takes precedence over account-type auto-detection and
  is applied after the sync-protection check, so user re-categorisations
  survive re-sync.

Complementary to Family#auto_match_transfers!: two-sided transfers between
linked accounts are still paired into a Transfer (the matcher does not filter
on kind); one-sided movements and round-ups, which the matcher cannot pair,
are the cases this fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(up): account-type kind wins over provider transfer hint

Codex review caught that Up HOME_LOAN accounts map to a Loan account, so a
repayment carrying transferAccount would be reclassified from loan_payment to
funds_movement (budget-excluded). Make the provider kind: a fallback:
activity-label and account-type classification now take precedence, so
loan_payment and cc_payment survive. Adds a regression test (loan repayment
stays loan_payment), a depository-applies test, and a note that the up_test
stub ignores query: intentionally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Gavin Matthews <matthews.gav@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:19:25 +02:00

231 lines
8.2 KiB
Ruby

class Provider::Up
include HTTParty
extend SslConfigurable
DEFAULT_BASE_URL = "https://api.up.com.au/api/v1".freeze
DEFAULT_PAGE_SIZE = 100
# Host that authenticated requests (bearer token) may be sent to. Absolute URLs
# taken from API responses (links.next) are validated against this.
ALLOWED_HOST = URI.parse(DEFAULT_BASE_URL).host.freeze
headers "User-Agent" => "Sure Finance Up Client"
default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
attr_reader :access_token
# Build a client with the family's Up personal access token. Raises if blank.
def initialize(access_token)
@access_token = access_token.to_s.strip
raise UpError.new("Up access token is required", :configuration_error) if @access_token.blank?
end
# GET /util/ping - validates the personal access token.
# Returns the parsed payload (contains meta.id / meta.statusEmoji) or raises UpError.
def ping
get("util/ping")
end
# GET /accounts - returns an array of flattened account hashes.
# Each hash: { id:, displayName:, accountType:, ownershipType:, balance: {...}, createdAt: }
def get_accounts
fetch_all_resources("accounts").map { |resource| flatten_account(resource) }
end
# GET /accounts/{id}/transactions - returns an array of flattened transaction hashes.
# Both HELD (pending) and SETTLED (posted) transactions are returned; callers derive
# pending status from the :status field.
def get_account_transactions(account_id:, since: nil, until_date: nil, page_size: DEFAULT_PAGE_SIZE)
query = { "page[size]" => page_size }
query["filter[since]"] = format_api_time(since) if since.present?
query["filter[until]"] = format_api_time(until_date) if until_date.present?
path = "accounts/#{ERB::Util.url_encode(account_id.to_s)}/transactions"
fetch_all_resources(path, query: query).map { |resource| flatten_transaction(resource) }
end
private
RETRYABLE_ERRORS = [
SocketError,
Net::OpenTimeout,
Net::ReadTimeout,
Errno::ECONNRESET,
Errno::ECONNREFUSED,
Errno::ETIMEDOUT,
EOFError
].freeze
MAX_RETRIES = 3
INITIAL_RETRY_DELAY = 2
# Follows JSON:API cursor pagination via links.next (absolute URLs) until exhausted,
# concatenating each page's `data` array.
def fetch_all_resources(path, query: {})
results = []
payload = get(path, query: query.presence)
seen_urls = Set.new
loop do
results.concat(Array(payload[:data]))
next_url = payload.dig(:links, :next)
break if next_url.blank?
# Guard against an API that returns the same cursor repeatedly.
break unless seen_urls.add?(next_url)
payload = get(next_url)
end
results
end
# Flattens a JSON:API account resource into a single hash with id + attributes.
def flatten_account(resource)
data = resource.with_indifferent_access
attributes = data[:attributes].is_a?(Hash) ? data[:attributes] : {}
attributes.merge(
id: data[:id],
type: data[:type]
).with_indifferent_access
end
# Flattens a JSON:API transaction resource, lifting attributes to the top level and
# extracting the related account/category ids from relationships.
#
# transfer_account_id is the other side of an internal money movement: Up populates
# relationships.transferAccount on any transaction that moves funds between the
# user's own accounts (including round-ups swept into a Saver). It is nil for
# ordinary income/expense.
def flatten_transaction(resource)
data = resource.with_indifferent_access
attributes = data[:attributes].is_a?(Hash) ? data[:attributes] : {}
attributes.merge(
id: data[:id],
account_id: data.dig(:relationships, :account, :data, :id),
category_id: data.dig(:relationships, :category, :data, :id),
transfer_account_id: data.dig(:relationships, :transferAccount, :data, :id)
).with_indifferent_access
end
def format_api_time(value)
return value if value.is_a?(String)
# A bare Date has no time/zone, so interpret it as UTC midnight rather than
# the server's local zone (which would shift `filter[since]` by the offset).
return value.to_time(:utc).iso8601 if value.instance_of?(Date)
value.to_time.utc.iso8601
end
# Issues a GET request. `path_or_url` may be a relative path (prefixed with the base URL)
# or an absolute URL (used when following pagination links).
def get(path_or_url, query: nil)
with_retries("GET #{path_or_url}") do
url = resolve_url(path_or_url)
response = self.class.get(url, headers: auth_headers, query: query)
handle_response(response)
end
end
# Resolves a relative path against the base URL, or validates an absolute URL
# so the bearer token is only ever sent to Up's HTTPS host.
def resolve_url(path_or_url)
value = path_or_url.to_s
return "#{DEFAULT_BASE_URL}/#{value}" unless value.start_with?("http")
uri = URI.parse(value)
unless uri.scheme == "https" && uri.host == ALLOWED_HOST
raise UpError.new("Refusing to send credentials to untrusted host: #{uri.host.inspect}", :invalid_url)
end
value
rescue URI::InvalidURIError
raise UpError.new("Invalid Up API URL", :invalid_url)
end
# Bearer-auth headers sent with every request.
def auth_headers
{
"Authorization" => "Bearer #{access_token}",
"Accept" => "application/json"
}
end
# Run the block, retrying transient network errors with exponential backoff.
def with_retries(operation_name, max_retries: MAX_RETRIES)
retries = 0
begin
yield
rescue *RETRYABLE_ERRORS => e
retries += 1
if retries <= max_retries
delay = calculate_retry_delay(retries)
Rails.logger.warn(
"Up API: #{operation_name} failed (attempt #{retries}/#{max_retries}): " \
"#{e.class}: #{e.message}. Retrying in #{delay}s..."
)
Kernel.sleep(delay)
retry
end
Rails.logger.error("Up API: #{operation_name} failed after #{max_retries} retries: #{e.class}: #{e.message}")
raise UpError.new("Network error after #{max_retries} retries: #{e.message}", :network_error)
end
end
# Exponential backoff delay (with jitter), capped at 30 seconds.
def calculate_retry_delay(retry_count)
base_delay = INITIAL_RETRY_DELAY * (2 ** (retry_count - 1))
jitter = base_delay * rand * 0.25
[ base_delay + jitter, 30 ].min
end
# Map an HTTP response to parsed data or a typed UpError by status code.
def handle_response(response)
case response.code
when 200, 201
parse_response_body(response)
when 204
{}
when 400
raise UpError.new("Bad request to Up API (status=#{response.code})", :bad_request)
when 401
raise UpError.new("Invalid Up access token", :unauthorized)
when 403
raise UpError.new("Up access forbidden - check token permissions", :access_forbidden)
when 404
raise UpError.new("Up resource not found", :not_found)
when 429
raise UpError.new("Up rate limit exceeded. Please try again later.", :rate_limited)
when 500..599
raise UpError.new("Up server error (#{response.code}). Please try again later.", :server_error)
else
Rails.logger.error "Up API: Unexpected response status=#{response.code}"
raise UpError.new("Failed to fetch Up data", :fetch_failed)
end
end
# Parse a JSON response body into a symbol-keyed hash, raising on bad JSON.
def parse_response_body(response)
return {} if response.body.blank?
JSON.parse(response.body, symbolize_names: true)
rescue JSON::ParserError => e
Rails.logger.error "Up API: Failed to parse response: #{e.class}"
raise UpError.new("Failed to parse Up API response", :parse_error)
end
# Error raised for Up API failures, tagged with a symbolic +error_type+.
class UpError < StandardError
attr_reader :error_type
# Build the error with a +message+ and a categorizing +error_type+.
def initialize(message, error_type = :unknown)
super(message)
@error_type = error_type
end
end
end