Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FFS-2490 Wrap Argyle Data Fetch API's #496

Open
wants to merge 28 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
973101e
created end-to-end mocks for item fetching
jeffcatania-usds Mar 5, 2025
9bc2068
testing fetch_identity
jeffcatania-usds Mar 5, 2025
36a07fa
save changes to response_object and fetching
jeffcatania-usds Mar 6, 2025
ee98bdb
Merge branch 'main' of https://github.com/DSACMS/iv-cbv-payroll into …
jeffcatania-usds Mar 6, 2025
ba1fd41
add sample api responses for all 3 sandbox users
jeffcatania-usds Mar 6, 2025
79f94e4
latest changes
jeffcatania-usds Mar 7, 2025
0224ae9
working on paystubs
jeffcatania-usds Mar 7, 2025
e069451
Use format_date
tdooner Mar 7, 2025
48a39eb
fix hours_by_earning
jeffcatania-usds Mar 7, 2025
506d963
Fix test data for bob
tdooner Mar 7, 2025
444fe29
update bob's paystubs to be the actual api response, not the response…
jeffcatania-usds Mar 7, 2025
7952367
added test cases for fetch_paystubs. changed all currency amounts fro…
jeffcatania-usds Mar 7, 2025
57bcfce
got income, employment, and paystubs to be populating properly!
jeffcatania-usds Mar 7, 2025
bd2e454
updated TODO
jeffcatania-usds Mar 7, 2025
daf83a2
Merge branch 'main' of https://github.com/DSACMS/iv-cbv-payroll into …
jeffcatania-usds Mar 10, 2025
dbea9db
fix merge conflicts
jeffcatania-usds Mar 10, 2025
99c59a1
fix indent issues
jeffcatania-usds Mar 10, 2025
4736834
fix tests
jeffcatania-usds Mar 10, 2025
16db288
update comment
jeffcatania-usds Mar 10, 2025
cacc70a
extract response_objects format methods into their own lib
jeffcatania-usds Mar 10, 2025
d4e30cf
missed a stray
jeffcatania-usds Mar 10, 2025
8c85c97
code error
jeffcatania-usds Mar 10, 2025
e002c24
refactor mock data helper methods. We might want to delete these anyway
jeffcatania-usds Mar 11, 2025
e0c3db9
remove requires
jeffcatania-usds Mar 11, 2025
413ff47
ensured endpoint constant definitions for all to match whats in Pinwh…
jeffcatania-usds Mar 11, 2025
f60e817
remove TODO.txt
jeffcatania-usds Mar 11, 2025
057f5d1
make sure the functions are named to be plural if they return multiple
jeffcatania-usds Mar 11, 2025
c9949c4
moved format_methods into proper folders
jeffcatania-usds Mar 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion app/app/models/response_objects/employment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
employer_phone_number
employer_address
]

module ResponseObjects
Employment = Struct.new(*EMPLOYMENT_FIELDS, keyword_init: true) do
def self.from_pinwheel(response_body)
Expand All @@ -21,5 +20,14 @@ def self.from_pinwheel(response_body)
employer_address: response_body.dig("employer_address", "raw")
)
end
def self.from_argyle(identity_response_body)
new(
account_id: identity_response_body["account"],
employer_name: identity_response_body["employer"],
start_date: identity_response_body["hire_date"],
termination_date: identity_response_body["termination_date"],
status: ResponseObjects::FormatMethods::Argyle.format_employment_status(identity_response_body["employment_status"]),
)
end
end
end
32 changes: 32 additions & 0 deletions app/app/models/response_objects/format_methods/argyle.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
module ResponseObjects::FormatMethods::Argyle
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whew, the module name is a bit of a mouthful, but scoping it here makes the intention clear so it seems good to me.

def self.format_employment_status(employment_status)
return unless employment_status

case employment_status
when "active"
"employed"
when "inactive"
"furloughed"
else
employment_status
end
end

def self.format_date(date)
return unless date

DateTime.parse(date).strftime("%Y-%m-%d")
end

def self.format_currency(amount)
return unless amount
amount.to_f
end

def self.hours_by_earning_category(gross_pay_list)
gross_pay_list
.filter { |e| e["hours"].present? }
.group_by { |e| e["type"] }
.transform_values { |earnings| earnings.sum { |e| e["hours"].to_f } }
end
end
28 changes: 28 additions & 0 deletions app/app/models/response_objects/format_methods/pinwheel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
module ResponseObjects::FormatMethods::Pinwheel
def self.hours(earnings)
base_hours = earnings
.filter { |e| e["category"] != "overtime" }
.map { |e| e["hours"] }
.compact
.max
return unless base_hours

# Add overtime hours to the base hours, because they tend to be additional
# work beyond the other entries. (As opposed to category="premium", which
# often duplicates other earnings' hours.)
#
# See FFS-1773.
overtime_hours = earnings
.filter { |e| e["category"] == "overtime" }
.sum { |e| e["hours"] || 0.0 }

base_hours + overtime_hours
end

def self.hours_by_earning_category(earnings)
earnings
.filter { |e| e["hours"] && e["hours"] > 0 }
.group_by { |e| e["category"] }
.transform_values { |earnings| earnings.sum { |e| e["hours"] } }
end
end
6 changes: 6 additions & 0 deletions app/app/models/response_objects/identity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,11 @@ def self.from_pinwheel(response_body)
full_name: response_body["full_name"],
)
end
def self.from_argyle(identity_response_body)
new(
account_id: identity_response_body["account"],
full_name: identity_response_body["full_name"],
)
end
end
end
8 changes: 8 additions & 0 deletions app/app/models/response_objects/income.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,13 @@ def self.from_pinwheel(response_body)
compensation_unit: response_body["compensation_unit"],
)
end
def self.from_argyle(identities_response_body)
new(
account_id: identities_response_body["account"],
pay_frequency: identities_response_body["base_pay"]["period"],
compensation_amount: ResponseObjects::FormatMethods::Argyle.format_currency(identities_response_body["base_pay"]["amount"]),
compensation_unit: identities_response_body["base_pay"]["currency"],
)
end
end
end
52 changes: 22 additions & 30 deletions app/app/models/response_objects/paystub.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ def self.from_pinwheel(response_body)
pay_period_start: response_body["pay_period_start"],
pay_period_end: response_body["pay_period_end"],
pay_date: response_body["pay_date"],
hours: PinwheelMethods.hours(response_body["earnings"]),
hours_by_earning_category: PinwheelMethods.hours_by_earning_category(response_body["earnings"]),
hours: ResponseObjects::FormatMethods::Pinwheel.hours(response_body["earnings"]),
hours_by_earning_category: ResponseObjects::FormatMethods::Pinwheel.hours_by_earning_category(response_body["earnings"]),
deductions: response_body["deductions"].map do |deduction|
OpenStruct.new(
category: deduction["category"],
Expand All @@ -33,36 +33,28 @@ def self.from_pinwheel(response_body)
)
end

alias_attribute :start, :pay_period_start
alias_attribute :end, :pay_period_end
end

module PinwheelMethods
def self.hours(earnings)
base_hours = earnings
.filter { |e| e["category"] != "overtime" }
.map { |e| e["hours"] }
.compact
.max
return unless base_hours

# Add overtime hours to the base hours, because they tend to be additional
# work beyond the other entries. (As opposed to category="premium", which
# often duplicates other earnings' hours.)
#
# See FFS-1773.
overtime_hours = earnings
.filter { |e| e["category"] == "overtime" }
.sum { |e| e["hours"] || 0.0 }

base_hours + overtime_hours
def self.from_argyle(response_body)
new(
account_id: response_body["account"],
gross_pay_amount: ResponseObjects::FormatMethods::Argyle.format_currency(response_body["gross_pay"]),
net_pay_amount: ResponseObjects::FormatMethods::Argyle.format_currency(response_body["net_pay"]),
gross_pay_ytd: ResponseObjects::FormatMethods::Argyle.format_currency(response_body["gross_pay_ytd"]),
pay_period_start: ResponseObjects::FormatMethods::Argyle.format_date(response_body["paystub_period"]["start_date"]),
pay_period_end: ResponseObjects::FormatMethods::Argyle.format_date(response_body["paystub_period"]["end_date"]),
pay_date: ResponseObjects::FormatMethods::Argyle.format_date(response_body["paystub_date"]),
hours: response_body["hours"],
hours_by_earning_category: ResponseObjects::FormatMethods::Argyle.hours_by_earning_category(response_body["gross_pay_list"]),
deductions: response_body["deduction_list"].map do |deduction|
OpenStruct.new(
category: deduction["name"],
amount: ResponseObjects::FormatMethods::Argyle.format_currency(deduction["amount"]),
)
end,
)
end

def self.hours_by_earning_category(earnings)
earnings
.filter { |e| e["hours"] && e["hours"] > 0 }
.group_by { |e| e["category"] }
.transform_values { |earnings| earnings.sum { |e| e["hours"] } }
end
alias_attribute :start, :pay_period_start
alias_attribute :end, :pay_period_end
end
end
109 changes: 106 additions & 3 deletions app/app/services/argyle_service.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# frozen_string_literal: true

require "faraday"
require "fileutils"
require "json"

class ArgyleService
ENVIRONMENTS = {
Expand All @@ -11,7 +13,12 @@ class ArgyleService
}
}

USERS_ENDPOINT = "https://api-sandbox.argyle.com/v2/users"
ITEMS_ENDPOINT = "items"
PAYSTUBS_ENDPOINT = "paystubs"
IDENTITIES_ENDPOINT = "identities"
USERS_ENDPOINT = "users"
ACCOUNTS_ENDPOINT = "accounts"
EMPLOYMENTS_ENDPOINT = "employments"

def initialize(environment, api_key_id = nil, api_key_secret = nil)
@api_key_id = api_key_id || ENVIRONMENTS.fetch(environment.to_sym)[:api_key_id]
Expand All @@ -38,12 +45,108 @@ def initialize(environment, api_key_id = nil, api_key_secret = nil)
end
end

def fetch_paystubs(**params)
json = fetch_paystubs_api(**params)
json["results"].map { |paystub_json| ResponseObjects::Paystub.from_argyle(paystub_json) }
end

def fetch_employments(**params)
# Note: we actually fetch Argyle's identity API instead of employment for the correct data
json = fetch_identities_api(**params)
json["results"].map { |identity_json| ResponseObjects::Employment.from_argyle(identity_json) }
end

def fetch_incomes(**params)
# Note: we actually fetch Argyle's identity API instead of employment for the correct data
json = fetch_identities_api(**params)
json["results"].map { |identity_json| ResponseObjects::Income.from_argyle(identity_json) }
end

# https://docs.argyle.com/api-reference/identities#retrieve
def fetch_identities(**params)
# todo: paginate
json = fetch_identities_api(**params)
json["results"].map { |identity_json| ResponseObjects::Identity.from_argyle(identity_json) }
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully we can rework this when we do the AggregatorDataHelper method so that we're not making the same API call three times in succession.


# Fetch all Argyle items
# https://docs.argyle.com/api-reference/items#list
def items(query = nil)
@http.get("items", { q: query }).body
@http.get(ITEMS_ENDPOINT, { q: query }).body
end

# https://docs.argyle.com/api-reference/users#retrieve
def fetch_user_api(user:)
@http.get(build_url("#{USERS_ENDPOINT}/#{user}")).body
end

# https://docs.argyle.com/api-reference/identities#list
def fetch_identities_api(**params)
# todo: paginate
@http.get(IDENTITIES_ENDPOINT, params).body
end

# https://docs.argyle.com/api-reference/accounts#list
def fetch_accounts_api(**params)
# TODO: paginate
# json["data"].map { |paystub_json| ResponseObjects::Paystub.from_pinwheel(paystub_json) }
@http.get(ACCOUNTS_ENDPOINT, params).body
end

# https://docs.argyle.com/api-reference/paystubs#list
def fetch_paystubs_api(**params)
# TODO: paginate
@http.get(PAYSTUBS_ENDPOINT, params).body
end

def create_user
@http.post("users").body
@http.post(USERS_ENDPOINT).body
end

# https://docs.argyle.com/api-reference/employments#list
def fetch_employments_api(**params)
# json["data"].map { |paystub_json| ResponseObjects::Paystub.from_pinwheel(paystub_json) }
@http.get(EMPLOYMENTS_ENDPOINT, params).body
end

# TODO: refactor this into common function between argyle_service/pinwheel_service
def build_url(endpoint)
@http.build_url(endpoint).to_s
end

def store_mock_response(responsePayload:, folderName: "other", fileName:)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idiomatic ruby would have these as snake_case. I'm going to follow up with a Rubocop rule to enforce this automatically.

FileUtils.mkdir_p "spec/support/fixtures/argyle/#{folderName}"

File.open("spec/support/fixtures/argyle/#{folderName}/#{fileName}.json", "wb") do
|f| f.puts(JSON.pretty_generate(responsePayload))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style nitpick - generally the block's argument is on the same line as the do, i.e.

File.open(...) do |f|      # <--- |f| goes on this line
  f.puts(...)
end

I couldn't find a rubocop rule for this.

end
end

# Only for use in sandbox environment for test mocking
def fetch__and_store_mock_data_for_user(argyle_user_id:, folderName:)
store_mock_response(
folderName: folderName,
fileName: "request_user",
responsePayload: fetch_user_api(user: argyle_user_id))

store_mock_response(
folderName: folderName,
fileName: "request_identity",
responsePayload: fetch_identities_api(user: argyle_user_id))

store_mock_response(
folderName: folderName,
fileName: "request_employment",
responsePayload: fetch_employment_api(user: argyle_user_id))

store_mock_response(
folderName: folderName,
fileName: "request_accounts",
responsePayload: fetch_accounts_api(user: argyle_user_id))

store_mock_response(
folderName: folderName,
fileName: "request_paystubs",
responsePayload: fetch_paystubs_api(user: argyle_user_id))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, maybe at some point we should move these methods into a helper module of some sort so they're not chilling on ArgyleService for the future.

end
end
Loading
Loading