diff --git a/config/dev.exs b/config/dev.exs index 40ca218..a7ce5cd 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -27,6 +27,19 @@ config :arcadia_cloud, ArcadiaCloudWeb.Endpoint, config :arcadia_cloud, ArcadiaCloud.Guardian, secret_key: "DuMkIRN3Qcxk8VqOu8nHj5i7a7a7YgBHF4oXqKwDI4A=" +# skyai-finance push — service-to-service identity for cloud invoice push. +# tenant_id="platform-admin" lands invoices in the platform's own books; +# role "admin" satisfies finance's RequireWriteRole plug. +config :arcadia_cloud, :skyai_finance, + base_url: System.get_env("SKYAI_FINANCE_URL") || "http://localhost:4010", + identity_claims: %{ + "sub" => "platform-system:platform-admin", + "tenant_id" => "platform-admin", + "tenant_slug" => "platform-admin", + "email" => "platform-system@sky-ai.com", + "roles" => ["admin"] + } + # ## SSL Support # # In order to use HTTPS in development, a self-signed diff --git a/lib/arcadia_cloud/billing.ex b/lib/arcadia_cloud/billing.ex index 46bc8e7..c64b0f0 100644 --- a/lib/arcadia_cloud/billing.ex +++ b/lib/arcadia_cloud/billing.ex @@ -70,6 +70,17 @@ defmodule ArcadiaCloud.Billing do |> Repo.update() end + def mark_invoice_pushed(invoice, finance_invoice_id) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + invoice + |> CloudInvoice.changeset(%{ + pushed_to_finance_at: now, + finance_invoice_id: finance_invoice_id + }) + |> Repo.update() + end + # ---- cost lines ----------------------------------------------------------- @doc """ diff --git a/lib/arcadia_cloud/billing/cloud_invoice.ex b/lib/arcadia_cloud/billing/cloud_invoice.ex index 058c275..1da1893 100644 --- a/lib/arcadia_cloud/billing/cloud_invoice.ex +++ b/lib/arcadia_cloud/billing/cloud_invoice.ex @@ -14,13 +14,16 @@ defmodule ArcadiaCloud.Billing.CloudInvoice do field :issued_at, :utc_datetime field :csv_fetched_at, :utc_datetime field :lines_ingested_at, :utc_datetime + field :pushed_to_finance_at, :utc_datetime + field :finance_invoice_id, :string field :raw, :map timestamps(type: :utc_datetime) end @required ~w(provider provider_invoice_id invoice_period)a - @optional ~w(amount_cents status issued_at csv_fetched_at lines_ingested_at raw)a + @optional ~w(amount_cents status issued_at csv_fetched_at lines_ingested_at + pushed_to_finance_at finance_invoice_id raw)a def changeset(invoice, attrs) do invoice diff --git a/lib/arcadia_cloud/integrations/skyai_finance.ex b/lib/arcadia_cloud/integrations/skyai_finance.ex new file mode 100644 index 0000000..734180e --- /dev/null +++ b/lib/arcadia_cloud/integrations/skyai_finance.ex @@ -0,0 +1,57 @@ +defmodule ArcadiaCloud.Integrations.SkyaiFinance do + @moduledoc """ + HTTP client for pushing cloud invoices to skyai-finance-svc. + + Auth: mints a short-lived Guardian JWT (shared secret per + project_arcadia_guardian_secret memory) with the platform-admin identity + configured under `:arcadia_cloud, :skyai_finance`. JWT carries role + `admin` because skyai-finance's RequireWriteRole plug accepts `admin` + or `user`, not `platform_admin`. + """ + + alias ArcadiaCloud.Guardian + + @doc """ + Push a single cloud invoice (and ensure-vendor) to skyai-finance. + + Idempotent — finance dedups on (tenant_id, source, external_id) and + updates existing rows on re-push. + + Returns {:ok, %{vendor_id, invoice_id, action}} | {:error, reason}. + """ + def push_invoice(payload) when is_map(payload) do + with {:ok, base_url} <- fetch(:base_url), + {:ok, identity_claims} <- fetch(:identity_claims), + {:ok, token, _claims} <- + Guardian.encode_and_sign(identity_claims, identity_claims, token_type: :access) do + url = base_url <> "/api/v1/integrations/cloud/import-invoice" + + case Req.post(url, + headers: [ + {"authorization", "Bearer " <> token}, + {"content-type", "application/json"} + ], + json: payload, + retry: :transient, + max_retries: 2, + receive_timeout: 15_000 + ) do + {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> + {:ok, body} + + {:ok, %Req.Response{status: status, body: body}} -> + {:error, {:http, status, body}} + + {:error, e} -> + {:error, {:transport, e}} + end + end + end + + defp fetch(key) do + case Application.get_env(:arcadia_cloud, :skyai_finance, [])[key] do + nil -> {:error, {:skyai_finance_not_configured, key}} + value -> {:ok, value} + end + end +end diff --git a/lib/arcadia_cloud/sync/invoice_ingest_worker.ex b/lib/arcadia_cloud/sync/invoice_ingest_worker.ex index 75cd2f2..add4cdc 100644 --- a/lib/arcadia_cloud/sync/invoice_ingest_worker.ex +++ b/lib/arcadia_cloud/sync/invoice_ingest_worker.ex @@ -29,12 +29,84 @@ defmodule ArcadiaCloud.Sync.InvoiceIngestWorker do {:ok, _} = Billing.replace_cost_lines(invoice, lines) matched = Billing.match_cost_lines_to_resources(invoice) - {:ok, _} = Billing.mark_invoice_ingested(invoice) + {:ok, invoice} = Billing.mark_invoice_ingested(invoice) - {:ok, %{lines: length(lines), matched: matched}} + push_result = push_to_finance(invoice, lines) + + {:ok, %{lines: length(lines), matched: matched, finance_push: push_result}} end end + # ---- push to skyai-finance ------------------------------------------------ + + defp push_to_finance(%CloudInvoice{} = invoice, lines) do + payload = build_finance_payload(invoice, lines) + + case ArcadiaCloud.Integrations.SkyaiFinance.push_invoice(payload) do + {:ok, %{"invoice_id" => fid} = body} -> + Billing.mark_invoice_pushed(invoice, fid) + {:ok, body["action"] || "ok"} + + {:error, {:skyai_finance_not_configured, _}} -> + # Push is optional — skipped in environments without finance configured. + :skipped + + {:error, reason} -> + # Log and let Oban retry the whole worker; nothing destructive happened. + require Logger + Logger.warning("skyai-finance push failed for invoice #{invoice.id}: #{inspect(reason)}") + {:error, reason} + end + end + + defp build_finance_payload(%CloudInvoice{} = invoice, lines) do + gst_minor = total_tax_minor(lines) + gross_minor = invoice.amount_cents || total_amount_minor(lines) + net_minor = max(gross_minor - gst_minor, 0) + + %{ + "invoice" => %{ + "source" => invoice.provider, + "external_id" => invoice.provider_invoice_id, + "date" => Date.to_iso8601(invoice.invoice_period), + "period_start" => Date.to_iso8601(invoice.invoice_period), + "period_end" => Date.to_iso8601(end_of_month(invoice.invoice_period)), + "currency" => "USD", + "amount_gross_minor" => gross_minor, + "amount_tax_minor" => gst_minor, + "amount_net_minor" => net_minor, + "gst_inclusive" => true, + "notes" => + "Auto-imported from arcadia-cloud. #{length(lines)} line items; #{Enum.count(lines, & &1[:kind])} kind-classified." + }, + "vendor" => %{ + "name" => "DigitalOcean", + "category" => "cloud", + "default_currency" => "USD" + } + } + end + + defp total_amount_minor(lines) do + Enum.reduce(lines, 0, fn l, acc -> acc + (l[:amount_cents] || 0) end) + end + + defp total_tax_minor(lines) do + Enum.reduce(lines, 0, fn l, acc -> + desc = (l[:description] || "") |> String.downcase() + + if String.contains?(desc, "gst") or String.contains?(desc, "tax") do + acc + (l[:amount_cents] || 0) + else + acc + end + end) + end + + defp end_of_month(%Date{} = d) do + d |> Date.end_of_month() + end + # ---- CSV parsing ---------------------------------------------------------- # DO invoice CSV columns (as of v2 API): diff --git a/priv/repo/migrations/20260519140000_invoice_pushed_to_finance.exs b/priv/repo/migrations/20260519140000_invoice_pushed_to_finance.exs new file mode 100644 index 0000000..edb8c25 --- /dev/null +++ b/priv/repo/migrations/20260519140000_invoice_pushed_to_finance.exs @@ -0,0 +1,12 @@ +defmodule ArcadiaCloud.Repo.Migrations.InvoicePushedToFinance do + use Ecto.Migration + + def change do + alter table(:cloud_invoices) do + add :pushed_to_finance_at, :utc_datetime + add :finance_invoice_id, :string + end + + create index(:cloud_invoices, [:pushed_to_finance_at]) + end +end