defmodule ArcadiaCloud.Sync.BillingHistoryWorker do @moduledoc """ Daily discovery of new invoices via `/v2/customers/my/billing_history`. Upserts an invoice header per row and enqueues an InvoiceIngestWorker job for any invoice not yet ingested (CSV parse + line-item write + resource match). Idempotent — re-running re-enqueues nothing new. """ use Oban.Worker, queue: :cloud_billing, max_attempts: 3 alias ArcadiaCloud.Billing alias ArcadiaCloud.DigitalOcean.Client alias ArcadiaCloud.Sync.InvoiceIngestWorker @impl Oban.Worker def perform(_job) do with {:ok, history} <- Client.list_billing_history() do Enum.each(history, fn item -> case extract_invoice_attrs(item) do {:ok, attrs} -> {:ok, invoice} = Billing.upsert_invoice(attrs) if is_nil(invoice.lines_ingested_at) do %{invoice_id: invoice.id} |> InvoiceIngestWorker.new() |> Oban.insert!() end :skip -> :ok end end) :ok end end # DO billing_history items have shape: # {"type" => "Invoice", "description" => "Invoice for September 2024", # "amount" => "-100.00", "date" => "2024-10-01T00:00:00Z", # "invoice_id" => "uuid-here", "invoice_uuid" => "uuid-here"} # We only care about Invoice rows (skip Payment, Credit, etc). defp extract_invoice_attrs(%{"type" => "Invoice"} = item) do case item["invoice_uuid"] || item["invoice_id"] do nil -> :skip uuid -> {:ok, %{ provider: "digitalocean", provider_invoice_id: uuid, invoice_period: derive_period(item["date"]), amount_cents: dollars_to_cents_abs(item["amount"]), status: "issued", issued_at: parse_iso(item["date"]), raw: item }} end end defp extract_invoice_attrs(_), do: :skip # Invoice dated YYYY-MM-01 covers the previous month. Derive period as # first-of-the-previous-month. defp derive_period(nil), do: Date.utc_today() |> Date.add(-30) |> first_of_month() defp derive_period(iso) do case DateTime.from_iso8601(iso) do {:ok, dt, _} -> dt |> DateTime.to_date() |> Date.add(-1) |> first_of_month() _ -> Date.utc_today() |> first_of_month() end end defp first_of_month(%Date{year: y, month: m}), do: Date.new!(y, m, 1) defp dollars_to_cents_abs(nil), do: nil defp dollars_to_cents_abs(value) when is_binary(value) do case Float.parse(value) do {float, _} -> abs(round(float * 100)) :error -> nil end end defp dollars_to_cents_abs(value) when is_number(value), do: abs(round(value * 100)) defp parse_iso(nil), do: nil defp parse_iso(str) when is_binary(str) do case DateTime.from_iso8601(str) do {:ok, dt, _} -> DateTime.truncate(dt, :second) _ -> nil end end end