mirror of
https://github.com/we-promise/sure.git
synced 2026-07-18 07:45:24 +00:00
* 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>
231 lines
8.2 KiB
Ruby
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
|