Skip to content

Instantly share code, notes, and snippets.

@heyitsjames
Created December 18, 2024 23:32
Show Gist options
  • Select an option

  • Save heyitsjames/c0afaba4059cfe7ea4309f0d50c72cec to your computer and use it in GitHub Desktop.

Select an option

Save heyitsjames/c0afaba4059cfe7ea4309f0d50c72cec to your computer and use it in GitHub Desktop.
defmodule Dozer.Integrations.Yardi do
@moduledoc """
Yardi is a 3rd party app that many of our customers use, and they do lots of things related to
vendor payments and property management.
The main purpose for this integration is that while our mutual customers use Rabbet to track and
approve documents for payment, we don't have any way to actually initiate the money transfers
and tie that back to balance sheets etc. So we offer this and other similar integrations (Avid, Nexus)
to allow the customers to pay out balances and tie into their accounting systems.
We send them our Invoices / Pay Apps as what they call Payables. Within that, each Line Item
from our side is a PayableDetail on theirs.
Future improvements include:
* Pulling / syncing our Vendors list with Yardi, as this is typically the System of Record for our
mutual customers
* 2-way syncing of Invoices with Yardi. We will need to poll periodically and pull in new changes:
new or updated Invoices
For more high-level information about the Yardi integration, check out this great deck that our Revenue
team uses: https://docs.google.com/presentation/d/1Qh78IrzazILEb5i_Kbv2-rVMpZ0di0nZWUlPsclsXkY/edit#slide=id.gfe77c39f55_0_5
"""
require Logger
use Dozer.Support.Utilities
import Ecto.Query
import Dozer.Support.Utilities.ToMoney
import Dozer.Support.Utilities.FormatMoney
import Dozer.Support.Utilities.FormatDate
import Dozer.Support.Utilities.SanitizeAddressState
alias Ecto.UUID
alias Dozer.Accounts
alias Dozer.Accounts.Organization
alias Dozer.Accounts.Permissions
alias Dozer.Accounts.Vendor
alias Dozer.Accounts.VendorRelationship
alias Dozer.CostCodes.JobCostCode
alias Dozer.Documentation.Document
alias Dozer.DocumentReviews
alias Dozer.DrawRequest.BudgetLineItem
alias Dozer.Integrations.VendorSync
alias Dozer.Integrations.Yardi.Api
alias Dozer.Integrations.Yardi.Api.AuthParams
alias Dozer.Integrations.Yardi.Api.Custom
alias Dozer.Integrations.Yardi.Api.Payable
alias Dozer.Integrations.Yardi.Api.PayableDetail
alias Dozer.Integrations.Yardi.Workers.YardiJournalEntryWorker
alias Dozer.Integrations.Yardi.Workers.YardiPayableWorker
alias Dozer.Integrations.Yardi.Workers.YardiVendorWorker
alias Dozer.Invoices
alias Dozer.Invoices.InvoiceLineItemJobCostCodeSchema
alias Dozer.Invoices.InvoiceLineItemSchema
alias Dozer.Invoices.InvoiceSchema
alias Dozer.Invoices.NewInvoice
alias Dozer.Invoices.UpdateInvoice
alias Dozer.Origination
alias Dozer.Origination.Project
alias Dozer.Repo
alias Dozer.VendorLineItems
@type uuid :: Ecto.UUID.t()
@type invoice :: Ecto.Schema.t()
@vendor_invoicing_endpoint "/webservices/ItfVendorInvoicing.asmx"
@api_rate_limit :timer.seconds(1)
@server_tz "America/New_York"
@doc """
Send (create) invoices through the Yardi api.
"""
@spec send_invoices(project_id :: uuid, document_ids :: [uuid]) ::
{:error, any} | {:ok, [invoice]}
def send_invoices(project_id, document_ids) do
project = get_project(project_id)
invoices = get_invoices(document_ids)
auth_params = auth_params(project.organization)
url = project.organization.yardi_url <> @vendor_invoicing_endpoint
payables = Enum.map(invoices, &map_payable(project, &1, project.organization))
uuid = Ecto.UUID.generate()
Logger.info(%{
message: "[YARDI] Yardi.send_invoices",
project_id: project_id,
document_ids: document_ids,
batch_description_id: uuid
})
with {:ok, batch_id} <-
Api.open_payable_batch(url, auth_params, "Rabbet API #{uuid}", project.custom_id),
{:ok, _response} <- Api.add_payables_to_batch(url, auth_params, batch_id, payables),
{:ok, _response} <- Api.post_payable_batch(url, auth_params, batch_id) do
{:ok, invoices}
else
{:error, errors} -> {:error, {invoices, errors}}
end
end
def invoice_sync(organization_id) when is_binary(organization_id) do
query =
from project in Project,
where: project.organization_id == ^organization_id,
where: project.setup_complete,
preload: [:organization]
# Yardi strongly discourages simultaneous API calls to the same endpoint
# so we need to execute sequentially
query
|> Repo.all()
|> Enum.each(&pull_data_from_yardi/1)
{:ok, Repo.get!(Organization, organization_id)}
end
def invoice_sync(%Organization{id: organization_id}) do
invoice_sync(organization_id)
end
def vendor_sync(%Organization{} = organization) do
auth_params = auth_params(organization)
url = organization.yardi_url <> @vendor_invoicing_endpoint
VendorSync.publish_subscription(organization, "Retrieving vendors...")
case Api.get_vendors(url, auth_params) do
{:ok, vendors} ->
vendors = Enum.reject(vendors, &Map.get(&1, "is_inactive", false))
VendorSync.publish_subscription(organization, "#{length(vendors)} vendors found")
vendors
|> Enum.map(fn vendor ->
YardiVendorWorker.new(%{organization_id: organization.id, vendor: vendor})
end)
|> Oban.insert_all()
{:error, error_message} ->
VendorSync.publish_subscription(organization, "Error: #{error_message}")
if error_message !== organization.yardi_vendors_sync_error_message do
update_organization_yardi_error_details(organization, error_message)
end
end
{:ok, organization}
end
def vendor_sync(organization_id) do
organization = Repo.get!(Organization, organization_id)
vendor_sync(organization)
end
@spec generate_vendor_from_yardi(String.t(), map()) ::
{:ok, Organization.t()} | {:error, Ecto.Changeset.t()}
def generate_vendor_from_yardi(organization_id, unformatted_vendor) do
customer = Repo.get!(Organization, organization_id)
vendor = atomize_keys(unformatted_vendor)
attrs = %{
city: vendor.city,
email_addresses: [vendor.email],
is_1099: vendor.it_gets_1099,
name: get_vendor_name(vendor),
phone_numbers: get_vendor_phone_numbers(vendor),
state: if(vendor.state == "", do: nil, else: String.downcase(vendor.state)),
street_address: vendor.address_1,
vendor_cost_code: vendor.vendor_id,
zip: vendor.zip_code,
vendor_source: "yardi"
}
query =
from vendor in Vendor,
join: vendor_relationship in VendorRelationship,
on: vendor_relationship.vendor_id == vendor.id,
where: vendor_relationship.customer_id == ^customer.id,
where: vendor.vendor_cost_code == ^vendor.vendor_id,
where: not vendor.is_deleted,
order_by: [desc: vendor.inserted_at],
limit: 1
result =
query
|> Repo.one()
|> case do
nil ->
VendorSync.publish_subscription(
customer,
"Creating new vendor: #{attrs.name}"
)
Accounts.create_vendor(customer, attrs)
vendor_to_update ->
VendorSync.publish_subscription(
customer,
"Updating found vendor: #{attrs.name}"
)
Accounts.update_vendor(vendor_to_update, attrs)
end
set_organization_last_synced_at_date(customer)
result
end
def pull_data_from_yardi(project_id) do
with {:ok, payables} <- fetch_yardi_payables_for_project(project_id),
{:ok, journal_entries} <- fetch_yardi_journal_entries_for_project(project_id) do
enqueue_payables(project_id, payables)
enqueue_journal_entries(project_id, journal_entries)
{:ok, project_id}
else
{:error, error_message} -> {:error, error_message}
end
end
def fetch_yardi_journal_entries_for_project(project_id) do
project = get_project(project_id)
auth_params = auth_params(project.organization)
url = project.organization.yardi_url <> @vendor_invoicing_endpoint
Api.get_journal_entries_from_yardi(url, auth_params, project.custom_id)
end
def fetch_yardi_payables_for_project(project_id) do
project = get_project(project_id)
auth_params = auth_params(project.organization)
url = project.organization.yardi_url <> @vendor_invoicing_endpoint
{from_date, to_date} =
get_from_date_and_to_date(
project.accounts_payable_last_synced_at,
project.organization.accounts_payable_settings
)
result_one = Api.get_payables_from_yardi(url, auth_params, project, from_date, to_date)
result_two =
Api.get_payables_from_yardi_by_last_modified_date(
url,
auth_params,
project,
from_date,
to_date
)
# we have to make two payable calls because the two calls by themselves
# don't return all of the expected payables
with {:ok, payables_one} <- result_one,
{:ok, payables_two} <- result_two do
{:ok, payables_one ++ payables_two}
else
{:error, error_message} ->
if error_message !== project.accounts_payable_sync_error_message do
update_project_accounts_payable_error_details(project, error_message)
end
Logger.error("Failed to retrieve Yardi payables #{inspect(error_message)}")
{:error, error_message}
end
end
def generate_invoice_from_yardi_payable(project_id, unformatted_payable) do
# We hit Yardi and they give us `payables`.
# For each Payable, we format the data into a new Invoice schema record.
# We use that new Invoice record to call the Yardi API to get document images.
# If we don't get a document image for that Invoice, we generate it ourselves
# and create two Document schema records:
# One with type null
# One with type `invoice` and the correlation_id set to the previous Document record.
# we also associate this type `invoice` record to the Invoice record via the invoice.document_id
# foreign key.
project = get_project(project_id)
formatted_payable_details =
unformatted_payable
|> Map.get("details", [])
|> Enum.map(&atomize_keys/1)
payable =
unformatted_payable
|> atomize_keys()
|> Map.put(
:details,
formatted_payable_details
)
vendor =
case get_vendor_using_yardi_person_id(payable.person_id, project.organization_id) do
nil -> fetch_and_create_vendor_by_yardi_id(project.organization, payable.person_id)
known_vendor -> known_vendor
end
auth_params = auth_params(project.organization)
url = project.organization.yardi_url <> @vendor_invoicing_endpoint
corrected_payable = fix_payable_details(payable, project.custom_id)
case get_existing_invoice(payable, project_id) do
nil ->
new_invoice = payable_to_new_invoice(corrected_payable, project, vendor)
{:ok, %{storage_path: file_token, content_type: content_type, page_count: page_count}} =
get_file_token_for_payable(
new_invoice,
project,
project.organization,
corrected_payable,
url,
auth_params
)
# Order of schema creation MUST be UploadDocument, InvoiceDocument, Invoice.
Repo.transaction(fn ->
{:ok, upload_document} =
add_upload_document(
project,
vendor,
new_invoice,
corrected_payable,
"payable",
file_token,
content_type,
page_count
)
{:ok, invoice_document} = add_invoice_document(new_invoice, upload_document)
{:ok, invoice} = insert_new_invoice(new_invoice, invoice_document)
attach_invoice_documentation(invoice, invoice_document)
set_project_last_synced_at_date(project)
invoice
end)
document_to_update ->
if is_nil(document_to_update.draw) or document_to_update.draw.state != "funded" do
update_existing_payable(document_to_update, corrected_payable, vendor, project)
else
:ok
end
end
end
@doc """
For each Journal Entry, an invoice is created.
If the organization enables the :yardi_pull_individualize_journal_entries setting, each
journal entry is split based on the line item category_id.
The new Invoice record is used to call the Yardi API to get document images.
If a document image is not returned for that Invoice, a document is generated from a default template.
Additionally:
Two %Document{} records are created:
- One with type null
- One with type `invoice` and the correlation_id set to the previous Document record.
We also associate this type `invoice` record to the Invoice record via the invoice.document_id foreign key.
"""
def generate_invoice_from_yardi_journal_entry(project_id, journal_entry) do
project = get_project(project_id)
details =
journal_entry
|> Map.get("details", [])
|> Enum.map(&atomize_keys/1)
atomized_journal_entry =
journal_entry
|> atomize_keys()
|> Map.put(:details, details)
individualize_journal_entries? =
Permissions.is_permitted?(project.organization, :yardi_pull_individualize_journal_entries)
if individualize_journal_entries? do
generate_split_invoices(project, atomized_journal_entry)
else
generate_single_invoices(project, atomized_journal_entry)
end
end
def update_project_accounts_payable_error_details(project, error) do
attrs = %{
accounts_payable_sync_failing_since: Timex.now(),
accounts_payable_sync_error_message: error
}
project
|> Project.changeset(attrs)
|> Repo.update()
end
def update_organization_yardi_error_details(organization, error) do
attrs = %{
yardi_vendors_sync_failing_since: Timex.now(),
yardi_vendors_sync_error_message: error
}
organization
|> Organization.update_changeset(attrs)
|> Repo.update()
end
def get_vendor_using_yardi_person_id(person_id, customer_id) do
query =
from vendor in Vendor,
join: vendor_relationship in VendorRelationship,
on: vendor_relationship.vendor_id == vendor.id,
where: vendor_relationship.customer_id == ^customer_id,
where: vendor.vendor_cost_code == ^person_id,
limit: 1
Repo.one(query)
end
defp generate_split_invoices(project, journal_entry) do
filtered_details = filter_details(journal_entry.details, project.custom_id)
split_journal_entries = split_journal_entry_by_details(journal_entry, filtered_details)
Enum.each(split_journal_entries, fn split_journal_entry ->
create_invoice_from_journal_entry(split_journal_entry, project)
end)
end
defp generate_single_invoices(project, journal_entry) do
filtered_details = filter_details(journal_entry.details, project.custom_id)
total_amount = aggregate_journal_entry_amounts(filtered_details)
corrected_journal_entry =
journal_entry
|> Map.put(:details, filtered_details)
|> Map.put(:total_amount, total_amount)
entry_has_details? = not Enum.empty?(corrected_journal_entry.details)
if entry_has_details? do
create_invoice_from_journal_entry(corrected_journal_entry, project)
end
end
defp create_invoice_from_journal_entry(journal_entry, project) do
case get_existing_invoice(journal_entry, project.id) do
nil ->
new_invoice = journal_entry_to_new_invoice(journal_entry, project, project.organization)
{:ok, %{storage_path: file_token}} =
create_pdf(new_invoice, project, project.organization, "journal-entry-template")
# Order of schema creation MUST be UploadDocument, InvoiceDocument, Invoice.
Repo.transaction(fn ->
{:ok, upload_document} =
add_upload_document(
project,
project.organization,
new_invoice,
journal_entry,
"journal_entry",
file_token,
"application/pdf"
)
{:ok, invoice_document} = add_invoice_document(new_invoice, upload_document)
{:ok, invoice} = insert_new_invoice(new_invoice, invoice_document)
attach_invoice_documentation(invoice, invoice_document)
set_project_last_synced_at_date(project)
invoice
end)
document_to_update ->
update_existing_yardi_journal_entry(
document_to_update,
journal_entry,
project.organization,
project
)
end
end
defp update_existing_yardi_journal_entry(document_to_update, journal_entry, vendor, project) do
if is_nil(document_to_update.draw) or document_to_update.draw.state != "funded" do
query =
from invoice in InvoiceSchema,
where: invoice.document_id == ^document_to_update.id
invoice_to_update = Repo.one(query)
line_items = get_formatted_line_items(journal_entry.details, project)
invoice_attrs = %UpdateInvoice{
description: get_description(journal_entry),
draw_id: invoice_to_update.draw_id,
vendor_id: if(is_nil(vendor), do: nil, else: vendor.id),
net_amount: to_money(journal_entry.total_amount),
gross_amount: to_money(journal_entry.total_amount),
line_items: line_items,
date: journal_entry.journal_entry_date,
type: "invoice",
state:
if is_nil(vendor) or Enum.empty?(line_items) do
:failed
else
:applied
end,
auto_calculate_retainage: invoice_to_update.auto_calculate_retainage,
total_retainage: invoice_to_update.total_retainage,
current_payment_due: invoice_to_update.current_payment_due
}
import_source_metadata = %{
yardi_id: journal_entry.id,
yardi_document_type: "journal_entry",
yardi_status: "updated",
last_yardi_sync: DateTime.utc_now(),
vendor_id: if(journal_entry.person_id == "", do: nil, else: journal_entry.person_id),
invoice_date: parse_invoice_date(journal_entry.journal_entry_date),
gross_amount: to_money(journal_entry.total_amount),
line_items: line_items,
is_reversed: journal_entry_is_reversed?(journal_entry.reference),
amount_paid: nil,
date_paid: nil,
invoice_number: nil,
is_paid: false
}
document_attrs = %{
vendor_id: invoice_attrs.vendor_id,
import_source_metadata: import_source_metadata
}
document_to_update
|> invoice_has_changed?(document_attrs)
|> handle_update_invoice(
document_to_update,
document_attrs,
invoice_to_update,
invoice_attrs,
project
)
end
end
defp handle_update_invoice(false, _, _, invoice_to_update, _, _) do
{:ok, invoice_to_update}
end
defp handle_update_invoice(
true,
document_to_update,
document_attrs,
invoice_to_update,
invoice_attrs,
project
) do
Logger.warn("[WOOD] invoice has changed. invoice_to_update: #{inspect(invoice_to_update)}")
Logger.warn("[WOOD] invoice has changed. invoice_attrs: #{inspect(invoice_attrs)}")
Logger.warn("[WOOD] invoice has changed. document_to_update: #{inspect(document_to_update)}")
Logger.warn("[WOOD] invoice has changed. document_attrs: #{inspect(document_attrs)}")
Repo.transaction(fn ->
{:ok, updated_document} =
document_to_update
|> Document.changeset(document_attrs)
|> Repo.update()
{:ok, updated_invoice} =
invoice_to_update
|> InvoiceSchema.changeset_from_update_invoice(invoice_attrs)
|> Repo.update()
attach_invoice_documentation(updated_invoice, updated_document)
set_project_last_synced_at_date(project)
updated_invoice
end)
end
# for the payable case, the invoice_changeset and the document's amount_paid and is_paid
# fields will have all the potential changes coming from yardi
defp invoice_has_changed?(
%Document{
import_source_metadata: original_import_source_metadata
},
%{import_source_metadata: import_source_metadata}
) do
formatted_original_details =
Enum.map(original_import_source_metadata.line_items, fn detail ->
Map.new(detail, fn {key, value} -> {String.to_atom(key), value} end)
end)
# remove variable fields
cleaned_original_import_source_metadata =
original_import_source_metadata
|> Map.from_struct()
|> Map.put(:line_items, formatted_original_details)
|> Map.drop([:yardi_status, :last_yardi_sync, :id])
cleaned_import_source_metadata =
Map.drop(import_source_metadata, [:yardi_status, :last_yardi_sync])
# the yardi data has NOT changed from its original if the metadata's are the same
# NOTE: the Rabbet invoice may have changed but we don't check for that
# We only want to update a Rabbet invoice if data coming from Yardi has been updated
!Map.equal?(cleaned_import_source_metadata, cleaned_original_import_source_metadata)
end
defp get_existing_invoice(yardi_document, project_id) do
query =
from document in Document,
where: fragment("import_source_metadata->>'yardi_id'=?", ^yardi_document.id),
where: document.type == "invoice",
where: document.project_id == ^project_id,
order_by: [desc: document.inserted_at],
limit: 1,
preload: [:draw]
Repo.one(query)
end
defp payable_to_new_invoice(payable, project, vendor) do
attrs = %{
project: project.id,
vendor: vendor,
date: payable.invoice_date,
number: payable.invoice_number
}
new_invoice(payable, attrs)
end
defp journal_entry_to_new_invoice(journal_entry, project, vendor) do
attrs = %{
project: project.id,
vendor: vendor,
date: journal_entry.journal_entry_date,
number: nil
}
new_invoice(journal_entry, attrs)
end
defp new_invoice(document, attrs) do
%{
project: project,
vendor: vendor,
date: date,
number: number
} = attrs
line_items = get_formatted_line_items(document.details, project)
has_non_matching_line_items =
Enum.any?(line_items, fn line_item ->
Map.get(line_item, :line_item_id) == nil
end)
state =
if is_nil(vendor) or Enum.empty?(line_items) or has_non_matching_line_items,
do: :failed,
else: :applied
%NewInvoice{
id: UUID.generate(),
project_id: project.id,
draw_id: nil,
type: "invoice",
description: get_description(document),
vendor_id: if(is_nil(vendor), do: nil, else: vendor.id),
net_amount: to_money(document.total_amount),
gross_amount: to_money(document.total_amount),
line_items: line_items,
state: state,
date: date,
number: number
}
end
# if a payable has any details whose property_id
# does not match the project_custom_id,
# remove that detail from the payable
# and remove that detail's amount from the payable's amount
defp fix_payable_details(payable, project_custom_id) do
{filtered_non_matching_details, filtered_details} =
Enum.split_with(payable.details, fn detail ->
is_voided = Map.get(detail, :is_voided, "false")
detail.property_id != project_custom_id || is_voided == "true"
end)
if length(filtered_non_matching_details) != 0 do
filtered_non_matching_details_total_amount =
Enum.reduce(filtered_non_matching_details, 0, fn detail, total ->
{detail_amount, _} = Float.parse(detail.amount)
detail_amount + total
end)
filtered_matching_details_total_amount =
Enum.reduce(filtered_details, 0, fn detail, total ->
{detail_amount, _} = Float.parse(detail.amount)
detail_amount + total
end)
corrected_payable_amount_paid =
case Float.parse(payable.amount_paid) do
{0.0, _} ->
0
{payable_amount_paid, _} ->
payable_amount_paid - filtered_non_matching_details_total_amount
end
payable
|> Map.put(
:total_amount,
filtered_matching_details_total_amount
)
|> Map.put(
:amount_paid,
corrected_payable_amount_paid
)
|> Map.put(:details, filtered_details)
else
details_amount_total =
Enum.reduce(payable.details, 0, fn detail, total ->
{detail_amount, _} = Float.parse(detail.amount)
detail_amount + total
end)
{payable_total_amount, _} = Float.parse(payable.total_amount)
if details_amount_total != payable_total_amount do
payable
|> Map.put(:total_amount, details_amount_total)
else
payable
end
end
end
defp filter_details(details, project_custom_id) do
Enum.filter(details, fn detail ->
detail.property_id == project_custom_id && detail.category_id !== ""
end)
end
defp aggregate_journal_entry_amounts(details) do
Enum.reduce(details, 0, fn detail, total ->
{detail_amount, _} = Float.parse(detail.amount)
detail_amount + total
end)
end
defp split_journal_entry_by_details(journal_entry, details) do
Enum.map(details, fn detail ->
{detail_amount, _} = Float.parse(detail.amount)
journal_entry_id = "#{journal_entry.id}-#{detail.category_id}-#{detail.amount}"
journal_entry
|> Map.put(:id, journal_entry_id)
|> Map.put(:details, [detail])
|> Map.put(:total_amount, detail_amount)
end)
end
defp get_formatted_line_items(details, project) do
details
|> Enum.with_index(fn detail, index ->
budget_line_item = get_budget_line_item_from_detail(detail, project)
if is_nil(budget_line_item) do
%{
name: nil,
number: nil,
amount: detail.amount,
gross_amount: detail.amount,
line_item_index: index,
line_item_id: nil,
retainage_amount: 0,
retainage_to_date_amount: 0,
materials_stored_amount: 0
}
else
%{
name: budget_line_item.name,
number: budget_line_item.number,
amount: detail.amount,
gross_amount: detail.amount,
line_item_index: index,
line_item_id: budget_line_item.id,
retainage_amount: 0,
retainage_to_date_amount: 0,
materials_stored_amount: 0
}
end
end)
|> Enum.filter(fn line_item ->
!is_nil(line_item)
end)
end
defp get_budget_line_item_from_detail(detail, project) do
query =
from budget_line_item in BudgetLineItem,
where: budget_line_item.number == ^detail.category_id,
where: budget_line_item.project_id == ^project.id
query
|> Repo.all()
|> List.first()
end
defp get_description(document) do
detail_notes =
Enum.reduce(document.details, "Detail Notes: ", fn detail, text ->
text <> "#{detail.category_id} (#{to_money(detail.amount)}) - #{detail.notes}\r"
end)
"Note: #{document.notes}\r" <> detail_notes
end
defp get_file_token_for_payable(invoice, project, organization, payable, url, auth) do
case Api.get_invoice_image_from_yardi(
url,
auth,
project.custom_id,
payable.id,
invoice.number
) do
{:ok, %{page_count: page_count}} ->
{parsed_page_count, _} = Integer.parse(page_count)
invoice_images =
Stream.unfold(1, fn page ->
if page == parsed_page_count + 1 do
nil
else
{:ok, %{image: png}} =
Api.get_invoice_image_from_yardi(
url,
auth,
project.custom_id,
payable.id,
invoice.number,
Integer.to_string(page)
)
# other option is to turn into separate job
Process.sleep(@api_rate_limit)
{png, page + 1}
end
end)
|> Enum.to_list()
png_file_paths =
Enum.map(invoice_images, fn image ->
with {:ok, temp_file} <- Briefly.create(),
{:ok, data} <- Base.decode64(image),
:ok <- File.write(temp_file, data) do
{:ok, temp_file}
temp_file
end
end)
combine_list_of_pngs_to_single_pdf(png_file_paths, parsed_page_count)
_ ->
create_pdf(invoice, project, organization, "payable-template")
end
end
defp combine_list_of_pngs_to_single_pdf(png_file_paths, page_count) do
{:ok, pdf} =
png_file_paths
|> Enum.map(fn path ->
{:ok, doc} = Librarian.get_local(path)
doc
end)
|> Librarian.combine()
{:ok, pdf} = Librarian.upload(pdf)
{:ok,
%{storage_path: pdf.storage_path, content_type: pdf.content_type, page_count: page_count}}
end
defp create_pdf(invoice, project, organization, template_name) do
with {:ok, file} <- create_docx(organization, project, invoice, template_name),
{:ok, file} <- Librarian.transform_format(file, "pdf") do
Librarian.upload(file)
else
error -> {:error, error}
end
end
# How is this cyclomatically complex..?
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defp create_docx(organization, project, invoice, template_name) do
params = %{
context: %{
project: %{
name: project.name
},
organization: %{
street_address: organization.street_address || "",
city: organization.city || "",
state: organization.state || "",
zip: organization.zip || "",
logo_token: organization.alternate_logo_file_token
},
invoice: %{
number: invoice.number || "",
date:
if(is_nil(invoice.date),
do: "",
else: format_date(invoice.date)
),
amount: invoice.net_amount |> Money.to_decimal() |> Decimal.to_float()
},
stakeholders: %{
client: client_params(invoice.vendor_id)
},
line_items: line_item_params(invoice)
},
template_name: template_name
}
Draftsman.generate_invoice(params)
end
defp line_item_params(invoice) when length(invoice.line_items) != 0 do
Enum.map(invoice.line_items, fn line_item ->
{line_item_amount, _} = Float.parse(line_item.amount)
%{
number: line_item.number,
name: line_item.name,
amount: line_item_amount
}
end)
end
defp line_item_params(_invoice) do
[
%{
number: "",
name: "",
amount: ""
}
]
end
defp client_params(nil) do
%{
id: "",
name: "",
address: "",
city: "",
state: "",
zip: "",
phone_number: ""
}
end
defp client_params(vendor_id) do
vendor = Repo.get!(Organization, vendor_id)
%{
id: vendor.vendor_cost_code || "",
name: vendor.name || "",
address: vendor.street_address || "",
city: vendor.city || "",
state: vendor.state || "",
zip: vendor.zip || "",
phone_number: Enum.join(vendor.phone_numbers, " | ")
}
end
defp add_upload_document(
project,
vendor,
invoice,
yardi_document,
yardi_document_type,
file_token,
content_type,
page_count \\ 1
) do
import_source_metadata =
if yardi_document_type == "payable" do
%{
yardi_id: yardi_document.id,
yardi_document_type: "payable",
yardi_status: "added",
last_yardi_sync: DateTime.utc_now(),
vendor_id: yardi_document.person_id,
invoice_number: yardi_document.invoice_number,
invoice_date: yardi_document.invoice_date,
is_paid: !convert_to_boolean(yardi_document.is_unpaid),
amount_paid: to_money(yardi_document.amount_paid),
date_paid: get_date_paid_from_payable_details(yardi_document.details),
gross_amount: to_money(yardi_document.total_amount),
is_reversed: convert_to_boolean(yardi_document.is_reversed),
line_items: invoice.line_items
}
else
%{
yardi_id: yardi_document.id,
yardi_document_type: "journal_entry",
yardi_status: "added",
last_yardi_sync: DateTime.utc_now(),
vendor_id: yardi_document.person_id,
invoice_number: nil,
invoice_date: yardi_document.journal_entry_date,
is_paid: false,
amount_paid: nil,
date_paid: nil,
gross_amount: to_money(yardi_document.total_amount),
is_reversed: journal_entry_is_reversed?(yardi_document.reference),
line_items: invoice.line_items
}
end
vendor_name =
if is_nil(vendor) do
"imported_from_yardi"
else
vendor.name
end
file_name =
if content_type == "image/png" do
"#{vendor_name} #{format_money(yardi_document.total_amount)}_#{yardi_document_type}.png"
else
"#{vendor_name} #{format_money(yardi_document.total_amount)}_#{yardi_document_type}.pdf"
end
attrs = %{
id: Ecto.UUID.generate(),
file_content_type: content_type,
original_file_content_type: content_type,
file_token: file_token,
original_file_token: file_token,
file_name: file_name,
original_file_name: file_name,
state: Atom.to_string(invoice.state),
pages: Enum.to_list(1..page_count),
processing_started_at: DateTime.utc_now(),
processing_finished_at: DateTime.utc_now(),
organization_id: project.organization_id,
project_id: project.id,
uploader_id: nil,
target_id: nil,
vendor_id: invoice.vendor_id,
import_source: "yardi",
import_source_metadata: import_source_metadata
}
%Dozer.Documentation.Document{}
|> Ecto.Changeset.change(attrs)
|> Repo.insert()
end
defp get_date_paid_from_payable_details(payable_details) do
if Enum.empty?(payable_details) do
nil
else
payable_details
|> hd()
|> Map.get(:check_date)
|> case do
"" ->
nil
check_date ->
{:ok, date_paid} = Timex.parse(check_date, "{ISO:Extended}")
Timex.to_date(date_paid)
end
end
end
defp parse_invoice_date(invoice_date) do
if invoice_date == "" do
nil
else
{:ok, date} = Timex.parse(invoice_date, "{ISO:Extended}")
Timex.to_date(date)
end
end
def add_invoice_document(invoice, upload_document) do
attrs =
upload_document
|> inherit_upload_attrs()
|> Map.merge(%{
id: Ecto.UUID.generate(),
upload_id: upload_document.id,
correlation_id: upload_document.id,
type: "invoice",
state: if(invoice.state == :failed, do: "failed", else: "applied"),
is_paid: upload_document.import_source_metadata.is_paid,
amount_paid:
if upload_document.import_source_metadata.is_paid == false do
nil
else
upload_document.import_source_metadata.amount_paid
end,
date_paid:
if upload_document.import_source_metadata.is_paid == false do
nil
else
upload_document.import_source_metadata.date_paid
end,
import_source: upload_document.import_source,
import_source_metadata: upload_document.import_source_metadata
})
%Dozer.Documentation.Document{}
|> Ecto.Changeset.change(attrs)
|> Repo.insert()
end
defp insert_new_invoice(invoice, invoice_document) do
attrs = %{
invoice
| document_id: invoice_document.id
}
%InvoiceSchema{}
|> InvoiceSchema.changeset_from_new_invoice(attrs)
|> Repo.insert()
end
defp inherit_upload_attrs(upload) do
Map.take(upload, [
:file_content_type,
:original_file_content_type,
:file_token,
:original_file_token,
:file_name,
:original_file_name,
:pages,
:organization_id,
:project_id,
:uploader_id,
:target_id,
:vendor_id,
:processing_started_at,
:processing_finished_at
])
end
defp attach_invoice_documentation(invoice, invoice_document) do
{:ok, invoice} = Invoices.invoice_from_invoice_schema(invoice)
Logger.warn(
"[WOOD] In Yardi.attach_invoice_documentation: #{inspect(invoice)} #{inspect(invoice_document)}"
)
VendorLineItems.sync_documentation(invoice)
end
defp set_project_last_synced_at_date(project) do
project
|> Project.changeset(%{accounts_payable_last_synced_at: Timex.now()})
|> Repo.update()
end
@doc """
Import and upsert vendors.
TODO(mmiller): Finish with v2.
"""
def sync_vendors(organization_id) do
organization = Repo.get!(Organization, organization_id)
auth = auth_params(organization)
url = organization.yardi_url <> @vendor_invoicing_endpoint
with {:ok, vendors} <- Api.get_vendors(url, auth) do
Enum.map(vendors, fn vendor ->
attrs = %{
vendor_cost_code: vendor.vendor_id,
name: vendor_name(vendor),
city: vendor.city,
state: sanitize_address_state(vendor.state),
phone_numbers: vendor_phone_numbers(vendor),
email_addresses: [vendor.email],
street_address: vendor.address_1,
type: "other",
vendor_source: "yardi"
}
%Vendor{}
|> Vendor.changeset(attrs)
|> Ecto.Changeset.put_assoc(:customers, [organization])
end)
end
end
@spec fetch_and_create_vendor_by_yardi_id(organization :: Organization.t(), id: String.t()) ::
Vendor.t() | nil
def fetch_and_create_vendor_by_yardi_id(organization, yardi_id) do
auth_params = auth_params(organization)
url = organization.yardi_url <> @vendor_invoicing_endpoint
case Api.get_vendor_by_id(url, auth_params, yardi_id) do
{:ok, vendor_from_api} ->
{:ok, upserted_vendor} = generate_vendor_from_yardi(organization.id, vendor_from_api)
upserted_vendor
_ ->
nil
end
end
# Vendor data is not well formatted on their end.
# Placing these in preferred order.
defp vendor_name(vendor) do
[
vendor.government_name,
String.trim("#{vendor.vendor_first_name} #{vendor.vendor_last_name}"),
vendor.vendor_id
]
|> Enum.find(nil, fn name ->
not is_nil(name) and name != ""
end)
end
# Limitation of the xpath lib forces this.
defp vendor_phone_numbers(vendor) do
[
vendor.phone_number_1,
vendor.phone_number_2,
vendor.phone_number_3,
vendor.phone_number_4,
vendor.phone_number_5,
vendor.phone_number_6,
vendor.phone_number_7,
vendor.phone_number_8,
vendor.phone_number_9,
vendor.phone_number_10
]
|> Enum.reject(&is_nil/1)
|> Enum.map(&String.replace(&1, ~r/\D/, ""))
|> Enum.reject(&is_nil/1)
end
defp auth_params(organization) do
%AuthParams{
username: organization.yardi_user_name,
password: organization.yardi_password,
server_name: organization.yardi_server_name,
database: organization.yardi_database,
platform: platform(organization.yardi_platform),
interface_entity: "Rabbet",
interface_license: Application.get_env(:dozer, Dozer.Integrations.Yardi)[:license]
}
end
def map_payable(project, invoice, organization) do
%Payable{
person_id: invoice.vendor.vendor_cost_code,
invoice_number: invoice.number,
invoice_date: "#{invoice.date}T00:00:00",
total_amount: money_fmt(invoice.net_amount),
notes: payable_notes(invoice),
details: Enum.map(invoice.line_items, &map_payable_detail(project, &1, organization)),
custom: payable_custom(invoice)
}
end
defp map_payable_detail(project, invoice_line_item, organization) do
get_final_payable_detail(
%PayableDetail{
amount: money_fmt(invoice_line_item.amount),
account_id: gl_account(invoice_line_item, project),
property_id: project.custom_id,
notes: payable_detail_notes(invoice_line_item)
},
invoice_line_item,
Permissions.is_permitted?(organization, :yardi_job_cost_module)
)
end
defp get_final_payable_detail(preliminary_payable_detail, invoice_line_item, true) do
job_cost_module_fields = %{
job_id: line_item_number(invoice_line_item),
category_id: cost_code(invoice_line_item)
}
Map.merge(preliminary_payable_detail, job_cost_module_fields)
end
defp get_final_payable_detail(preliminary_payable_detail, _invoice_line_item, false) do
preliminary_payable_detail
end
## If there are no job cost codes, use the project's gl_code
defp gl_account(%InvoiceLineItemSchema{job_cost_codes: []}, %Project{gl_code: account}),
do: account
## If the first/only job cost code has a nil gl_code, use the project's gl_code
defp gl_account(
%InvoiceLineItemSchema{
job_cost_codes: [
%InvoiceLineItemJobCostCodeSchema{job_cost_code: %JobCostCode{gl_code: nil}}
]
},
%Project{gl_code: account}
),
do: account
## Otherwise, use the first/only job cost code's gl_code
defp gl_account(
%InvoiceLineItemSchema{
job_cost_codes: [
%InvoiceLineItemJobCostCodeSchema{job_cost_code: %JobCostCode{gl_code: account}}
]
},
_project
),
do: account
## If there is no Cost Code, return nil
defp cost_code(%InvoiceLineItemSchema{job_cost_codes: []}), do: nil
## If there is a Cost Code, return it
defp cost_code(%InvoiceLineItemSchema{
job_cost_codes: [
%InvoiceLineItemJobCostCodeSchema{job_cost_code: %JobCostCode{code: code}}
]
}),
do: code
defp payable_notes(%{document: document}) do
approval = DocumentReviews.get_approval_for_integration(document)
case approval do
nil -> nil
_approval -> "Approved in Rabbet by #{approval.name}"
end
end
# Returns either the line item number if it exists, or nil
defp line_item_number(line) do
case line.line_item do
%{number: nil} -> nil
# if you delete a line item number, instead of becoming nil,
# it shows up as an empty string, so accounting for that case
%{number: ""} -> nil
_ -> line.line_item.number
end
end
# Returns what we should put in the Notes field for the line item
defp payable_detail_notes(line) do
line_item_description =
if line_item_number(line) == nil do
line.name
else
"#{line_item_number(line)} - #{line.name}"
end
line_item_cost_code =
if cost_code(line) == nil do
"N/A"
else
cost_code(line)
end
"""
Description: #{line_item_description},
Cost Code: #{line_item_cost_code}
"""
end
defp journal_entry_is_reversed?(journal_entry_reference) do
if String.contains?(journal_entry_reference, ["Reversal of", "reversal of"]) do
true
else
false
end
end
# Adds the URL to grab the PDF from
defp payable_custom(invoice) do
# Yardi forces this to https anyways so we're not
# going to bother changing it for local dev.
base = "https://#{DozerWeb.Endpoint.config(:url)[:host]}"
url = "#{base}/download_document/#{invoice.document_id}/#{invoice.document.file_token}"
%Custom{
interface_vendor: "Rabbet",
custom_1: url
}
end
defp get_project(project_id) do
project_id
|> Origination.get_project!()
|> Repo.preload(:organization)
end
defp get_invoices(document_ids) do
query =
from i in InvoiceSchema,
where: i.document_id in ^document_ids,
preload: [
:vendor,
:document,
line_items: [:line_item, job_cost_codes: [:job_cost_code]]
]
Repo.all(query)
end
defp get_from_date_and_to_date(
nil = _accounts_payable_last_synced_at,
accounts_payable_settings
) do
now = Timex.now(@server_tz)
day_range_for_pulling_payables =
Map.get(accounts_payable_settings, :day_range_for_pulling_payables, 365)
{Timex.shift(now, days: -day_range_for_pulling_payables), now}
end
defp get_from_date_and_to_date(accounts_payable_last_synced_at, _accounts_payable_settings) do
from = Timex.Timezone.convert(accounts_payable_last_synced_at, @server_tz)
to = Timex.now(@server_tz)
{from, to}
end
defp platform("sql_server"), do: "SQL Server"
defp platform("oracle"), do: "Oracle"
defp platform(_), do: "SQL Server"
defp money_fmt(amount) do
amount
|> Money.to_decimal()
|> Decimal.to_float()
|> :erlang.float_to_binary(decimals: 2)
end
defp convert_to_boolean("true"), do: true
defp convert_to_boolean("false"), do: false
def config(key) do
Application.fetch_env!(:dozer, __MODULE__)[key]
end
defp set_organization_last_synced_at_date(organization) do
organization
|> Organization.update_changeset(%{yardi_vendors_last_synced_at: Timex.now()})
|> Repo.update()
end
defp get_vendor_phone_numbers(vendor) do
phone_numbers = [
vendor.phone_number_1,
vendor.phone_number_2,
vendor.phone_number_3,
vendor.phone_number_4,
vendor.phone_number_5,
vendor.phone_number_6,
vendor.phone_number_7,
vendor.phone_number_8,
vendor.phone_number_9,
vendor.phone_number_10
]
Enum.filter(phone_numbers, fn phone_number ->
phone_number != ""
end)
end
defp get_vendor_name(vendor) do
cond do
vendor.vendor_last_name != "" ->
vendor.vendor_last_name
vendor.government_name != "" ->
vendor.government_name
vendor.vendor_first_name != "" ->
vendor.vendor_fist_name
true ->
"Yardi Generated Vendor"
end
end
defp enqueue_payables(project_id, payables) do
workers =
Enum.map(payables, fn payable ->
YardiPayableWorker.new(%{
project_id: project_id,
payable: payable
})
end)
Oban.insert_all(workers)
end
defp enqueue_journal_entries(project_id, journal_entries) do
workers =
Enum.map(journal_entries, fn journal_entry ->
YardiJournalEntryWorker.new(%{
project_id: project_id,
journal_entry: journal_entry
})
end)
Oban.insert_all(workers)
end
defp update_existing_payable(document_to_update, payable, vendor, project) do
query = from invoice in InvoiceSchema, where: invoice.document_id == ^document_to_update.id
invoice_to_update = Repo.one(query)
line_items = get_formatted_line_items(payable.details, project)
invoice_attrs = %UpdateInvoice{
description: get_description(payable),
draw_id: invoice_to_update.draw_id,
vendor_id: if(is_nil(vendor), do: nil, else: vendor.id),
net_amount: to_money(payable.total_amount),
gross_amount: to_money(payable.total_amount),
line_items: line_items,
date: parse_invoice_date(payable.invoice_date),
number: payable.invoice_number,
type: "invoice",
state:
if is_nil(vendor) or Enum.empty?(line_items) do
:failed
else
:applied
end,
auto_calculate_retainage: invoice_to_update.auto_calculate_retainage,
total_retainage: invoice_to_update.total_retainage,
current_payment_due: invoice_to_update.current_payment_due
}
import_source_metadata = %{
yardi_id: payable.id,
yardi_document_type: "payable",
yardi_status: "updated",
last_yardi_sync: DateTime.utc_now(),
vendor_id: payable.person_id,
invoice_number: payable.invoice_number,
invoice_date: parse_invoice_date(payable.invoice_date),
is_paid: !convert_to_boolean(payable.is_unpaid),
amount_paid: to_money(payable.amount_paid),
date_paid: get_date_paid_from_payable_details(payable.details),
gross_amount: to_money(payable.total_amount),
is_reversed: convert_to_boolean(payable.is_reversed),
line_items: line_items
}
document_attrs = %{
is_paid: import_source_metadata.is_paid,
amount_paid:
if import_source_metadata.is_paid == false do
nil
else
import_source_metadata.amount_paid
end,
date_paid:
if import_source_metadata.is_paid == false do
nil
else
import_source_metadata.date_paid
end,
vendor_id: invoice_attrs.vendor_id,
import_source_metadata: import_source_metadata
}
document_to_update
|> invoice_has_changed?(document_attrs)
|> handle_update_invoice(
document_to_update,
document_attrs,
invoice_to_update,
invoice_attrs,
project
)
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment