Vereinfacht accounting software API closes #431 #432

Merged
moritz merged 31 commits from feature/vereinfacht_api into main 2026-02-23 21:18:46 +01:00
2 changed files with 172 additions and 32 deletions
Showing only changes of commit b60ab3f392 - Show all commits

View file

@ -256,7 +256,7 @@ defmodule MvWeb.MemberLive.Show do
id={"membership-fees-#{@member.id}"}
member={@member}
current_user={@current_user}
vereinfacht_debug_response={@vereinfacht_debug_response}
vereinfacht_receipts={@vereinfacht_receipts}
/>
<% end %>
</Layouts.app>
@ -268,7 +268,7 @@ defmodule MvWeb.MemberLive.Show do
{:ok,
socket
|> assign(:active_tab, :contact)
|> assign(:vereinfacht_debug_response, nil)}
|> assign(:vereinfacht_receipts, nil)}
end
@impl true
@ -320,14 +320,14 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)}
end
def handle_event("load_vereinfacht_debug", %{"contact_id" => contact_id}, socket) do
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
response =
case Mv.Vereinfacht.Client.get_contact(contact_id) do
{:ok, body} -> {:ok, body}
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
{:ok, receipts} -> {:ok, receipts}
{:error, reason} -> {:error, reason}
end
{:noreply, assign(socket, :vereinfacht_debug_response, response)}
{:noreply, assign(socket, :vereinfacht_receipts, response)}
end
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash

View file

@ -54,42 +54,67 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<%= if Mv.Config.vereinfacht_configured?() do %>
<%= if @member.vereinfacht_contact_id do %>
<div class="mb-4">
<label class="label">
<span class="label-text font-semibold">{gettext("Vereinfacht")}</span>
</label>
<div class="flex flex-col gap-1">
<span class="font-mono text-sm">
{gettext("Contact ID: %{id}", id: @member.vereinfacht_contact_id)}
</span>
<div class="flex flex-col gap-2">
<.link
:if={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
target="_blank"
rel="noopener noreferrer"
class="link link-accent underline inline-flex items-center gap-1"
class="link link-accent underline inline-flex items-center gap-1 w-fit"
>
{gettext("View contact in Vereinfacht")}
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
</.link>
<div class="mt-2">
<span class="text-base-content/70 text-sm">{gettext("Debug:")}</span>
<div>
<button
type="button"
phx-click="load_vereinfacht_debug"
phx-click="load_vereinfacht_receipts"
phx-value-contact_id={@member.vereinfacht_contact_id}
class="btn btn-sm btn-ghost ml-1"
class="btn btn-sm btn-ghost"
>
{gettext("Load API response")}
{gettext("Show bookings/receipts from Vereinfacht")}
</button>
</div>
<%= if @vereinfacht_debug_response do %>
<%= if @vereinfacht_receipts do %>
<div
class="mt-2 rounded border border-base-300 bg-base-200 p-3 overflow-x-auto max-h-96 overflow-y-auto"
tabindex="0"
role="region"
aria-label={gettext("Vereinfacht API response")}
aria-label={gettext("Vereinfacht receipts")}
>
<pre class="text-xs whitespace-pre-wrap font-mono"><%= format_vereinfacht_debug_response(@vereinfacht_debug_response) %></pre>
<%= if match?({:ok, _}, @vereinfacht_receipts) do %>
<% {_, receipts} = @vereinfacht_receipts %>
<%= if receipts == [] do %>
<p class="text-sm text-base-content/70">{gettext("No receipts")}</p>
<% else %>
<% cols = receipt_display_columns(receipts) %>
<table class="table table-xs table-pin-rows">
<thead>
<tr>
<%= for {_key, translated_label} <- cols do %>
<th>{translated_label}</th>
<% end %>
</tr>
</thead>
<tbody>
<%= for r <- receipts do %>
<tr>
<%= for {col_key, _header_key} <- cols do %>
<td>{format_receipt_cell(col_key, r[col_key])}</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
<% end %>
<% else %>
<% {:error, reason} = @vereinfacht_receipts %>
<p class="text-sm text-error">
{gettext("Error loading receipts: %{reason}",
reason: format_vereinfacht_error(reason)
)}
</p>
<% end %>
</div>
<% end %>
</div>
@ -499,7 +524,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign_new(:create_cycle_date, fn -> nil end)
|> assign_new(:create_cycle_error, fn -> nil end)
|> assign_new(:regenerating, fn -> false end)
|> assign_new(:vereinfacht_debug_response, fn -> nil end)}
|> assign_new(:vereinfacht_receipts, fn -> nil end)}
end
@impl true
@ -1057,23 +1082,138 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_create_cycle_period(_date, _interval), do: ""
defp format_vereinfacht_debug_response({:ok, body}) when is_map(body) do
Jason.encode!(body, pretty: true)
defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail),
do: "HTTP #{status} #{detail}"
defp format_vereinfacht_error({:http, status, _}), do: "HTTP #{status}"
defp format_vereinfacht_error(reason), do: inspect(reason)
# Ordered receipt columns: {api_key, gettext key for header}. Only columns present in data are shown.
@receipt_column_spec [
{:amount, "Amount"},
{:bookingDate, "Booking date"},
{:createdAt, "Created at"},
{:receiptType, "Receipt type"},
{:referenceNumber, "Reference number"},
{:status, "Status"},
{:updatedAt, "Updated at"}
]
defp receipt_display_columns(receipts) when is_list(receipts) do
keys_in_data = receipts |> Enum.flat_map(&Map.keys/1) |> MapSet.new()
Enum.filter(@receipt_column_spec, fn {key, _} -> MapSet.member?(keys_in_data, key) end)
|> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end)
end
defp format_vereinfacht_debug_response({:error, {:http, status, detail}})
when is_binary(detail) do
"Error: HTTP #{status} #{detail}"
defp format_receipt_cell(:amount, nil), do: ""
defp format_receipt_cell(:amount, val) when is_number(val) do
case Decimal.cast(val) do
{:ok, d} -> MembershipFeeHelpers.format_currency(d)
_ -> to_string(val)
end
end
defp format_vereinfacht_debug_response({:error, {:http, status, _}}) do
"Error: HTTP #{status}"
defp format_receipt_cell(:amount, val) when is_binary(val) do
case Decimal.parse(val) do
{d, _} -> MembershipFeeHelpers.format_currency(d)
:error -> val
end
end
defp format_vereinfacht_debug_response({:error, reason}) do
"Error: " <> inspect(reason)
defp format_receipt_cell(:amount, val), do: to_string(val)
defp format_receipt_cell(:status, nil), do: ""
defp format_receipt_cell(:status, val) when is_binary(val) do
translate_receipt_status(val)
end
defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val))
defp format_receipt_cell(:receiptType, nil), do: ""
defp format_receipt_cell(:receiptType, val) when is_binary(val) do
translate_receipt_type(val)
end
defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val))
defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt],
do: ""
defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do
format_receipt_date(val)
end
defp format_receipt_cell(_col_key, val) when is_binary(val), do: val
defp format_receipt_cell(_col_key, val) when is_number(val), do: to_string(val)
defp format_receipt_cell(_col_key, val) when is_boolean(val),
do: if(val, do: gettext("Yes"), else: gettext("No"))
defp format_receipt_cell(_col_key, %Date{} = d), do: format_receipt_date_short(d)
defp format_receipt_cell(_col_key, val) when is_map(val) or is_list(val), do: Jason.encode!(val)
defp format_receipt_cell(_col_key, val), do: to_string(val)
defp format_receipt_date(%Date{} = d), do: format_receipt_date_short(d)
defp format_receipt_date(val) when is_binary(val) do
case parse_receipt_date(val) do
{:ok, d} -> format_receipt_date_short(d)
_ -> val
end
end
defp format_receipt_date(val), do: to_string(val)
# Parses ISO date or datetime string to Date (uses first 10 chars for datetime strings)
defp parse_receipt_date(val) when is_binary(val) do
date_str = if String.length(val) >= 10, do: String.slice(val, 0, 10), else: val
Date.from_iso8601(date_str)
end
# Format as "12. Dez. 2025" (day. abbreviated month. year) with translated month
defp format_receipt_date_short(%Date{day: day, month: month, year: year}) do
"#{day}. #{receipt_month_abbr(month)} #{year}"
end
defp receipt_month_abbr(1), do: gettext("Jan.")
defp receipt_month_abbr(2), do: gettext("Feb.")
defp receipt_month_abbr(3), do: gettext("Mar.")
defp receipt_month_abbr(4), do: gettext("Apr.")
defp receipt_month_abbr(5), do: gettext("May")
defp receipt_month_abbr(6), do: gettext("Jun.")
defp receipt_month_abbr(7), do: gettext("Jul.")
defp receipt_month_abbr(8), do: gettext("Aug.")
defp receipt_month_abbr(9), do: gettext("Sep.")
defp receipt_month_abbr(10), do: gettext("Oct.")
defp receipt_month_abbr(11), do: gettext("Nov.")
defp receipt_month_abbr(12), do: gettext("Dec.")
defp receipt_month_abbr(_), do: ""
# Translate API status values for display (extend as API returns more values)
defp translate_receipt_status("paid"), do: gettext("Paid")
defp translate_receipt_status("unpaid"), do: gettext("Unpaid")
defp translate_receipt_status("suspended"), do: gettext("Suspended")
defp translate_receipt_status("open"), do: gettext("Open")
defp translate_receipt_status("cancelled"), do: gettext("Cancelled")
defp translate_receipt_status("draft"), do: gettext("Draft")
defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
defp translate_receipt_status("completed"), do: gettext("Completed")
defp translate_receipt_status("empty"), do: ""
defp translate_receipt_status(other), do: other
# Translate API receipt type values (extend as API returns more values)
defp translate_receipt_type("invoice"), do: gettext("Invoice")
defp translate_receipt_type("receipt"), do: gettext("Receipt")
defp translate_receipt_type("credit_note"), do: gettext("Credit note")
defp translate_receipt_type("credit"), do: gettext("Credit")
defp translate_receipt_type("expense"), do: gettext("Expense")
defp translate_receipt_type("income"), do: gettext("Income")
defp translate_receipt_type(other), do: other
# Helper component for section box
attr :title, :string, required: true
slot :inner_block, required: true