Vereinfacht API: filter-based contact lookup, no extra required fields, country sync, and docs #459

Merged
moritz merged 5 commits from feat/vereinfacht_api into main 2026-03-04 21:15:07 +01:00
16 changed files with 208 additions and 154 deletions

View file

@ -62,6 +62,7 @@ We are building a membership management system (Mila) using the following techno
**Related documents:**
- **UI / UX:** [`DESIGN_DUIDELINES.md`](../DESIGN_DUIDELINES.md) defines visual and interaction consistency: use of CoreComponents (no raw DaisyUI in views), page skeleton (`<.header>`, `mt-6 space-y-6`), **Back button left in header for edit/new forms** (§2.2), typography, buttons, forms, tables, flash/toast, and microcopy (e.g. German "du" and glossary). Follow "components first" and semantic variants instead of hard-coded colors.
- **Vereinfacht API:** [`docs/vereinfacht-api.md`](docs/vereinfacht-api.md) describes the finance-contact sync (find by email filter, minimal create payload, no extra required member fields).
---
@ -115,8 +116,13 @@ lib/
│ ├── membership_fees/ # Membership fee business logic
│ │ ├── cycle_generator.ex # Cycle generation algorithm
│ │ └── calendar_cycles.ex # Calendar cycle calculations
│ ├── vereinfacht/ # Vereinfacht accounting API integration
│ │ ├── client.ex # HTTP client (finance-contacts: create, update, find by email)
│ │ ├── vereinfacht.ex # Business logic (sync_member, sync_members_without_contact)
│ │ ├── sync_flash.ex # Flash message helpers for sync results
│ │ └── changes/ # Ash changes (SyncContact, sync linked member)
│ ├── helpers.ex # Shared helper functions (ash_actor_opts)
│ ├── constants.ex # Application constants (member_fields, custom_field_prefix)
│ ├── constants.ex # Application constants (member_fields, custom_field_prefix, vereinfacht_required_member_fields)
│ ├── application.ex # OTP application
│ ├── mailer.ex # Email mailer
│ ├── release.ex # Release tasks
@ -2874,7 +2880,7 @@ Building accessible applications ensures that all users, including those with di
**Required Fields:**
Which member fields are required (asterisk, tooltip, validation) is configured in **Settings** (Memberdata section: edit a member field and set "Required"). The member create/edit form and Member resource validation both read `settings.member_field_required`. Email is always required; other fields default to optional.
Which member fields are required (asterisk, tooltip, validation) is configured in **Settings** (Memberdata section: edit a member field and set "Required"). The member create/edit form and Member resource validation both read `settings.member_field_required`. Email is always required; other fields default to optional. The Vereinfacht integration does not add extra required member fields (the external API accepts a minimal payload when creating contacts and supports filter-by-email for lookup).
```heex
<!-- Mark required fields (value from settings or always true for email) -->

View file

@ -247,7 +247,7 @@
- ❌ Payment records/transactions (external payment tracking)
- ❌ Payment reminders
- ❌ Invoice generation
- ❌ vereinfacht.digital API integration
- ✅ Memberfinance-contact sync with vereinfacht.digital API (see `docs/vereinfacht-api.md`); ❌ transaction import / full API integration
- ❌ SEPA direct debit support
- ❌ Payment reports

47
docs/vereinfacht-api.md Normal file
View file

@ -0,0 +1,47 @@
# Vereinfacht API Integration
This document describes the current integration with the Vereinfacht (verein.visuel.dev) accounting API for syncing members as finance contacts.
## Overview
- **Purpose:** Create and update external finance contacts in Vereinfacht when members are created or updated; support bulk sync for members without a contact ID.
- **Configuration:** ENV or Settings: `VEREINFACHT_API_URL`, `VEREINFACHT_API_KEY`, `VEREINFACHT_CLUB_ID`, optional `VEREINFACHT_APP_URL` for contact view links.
- **Modules:** `Mv.Vereinfacht` (business logic), `Mv.Vereinfacht.Client` (HTTP client), `Mv.Vereinfacht.Changes.SyncContact` (Ash after_transaction change).
## API Usage
### Finding an existing contact by email
The API supports filtered list requests. Use a single GET instead of paginating:
- **Endpoint:** `GET /api/v1/finance-contacts?filter[isExternal]=true&filter[email]=<email>`
- **Client:** `Mv.Vereinfacht.Client.find_contact_by_email/1` builds this URL (with encoded email) and returns `{:ok, contact_id}` if the first match exists, `{:error, :not_found}` otherwise.
- No member fields are required in the app solely for this lookup.
### Creating a contact
When creating an external finance contact, the API only requires:
- **Attributes:** `contactType` (e.g. `"person"`), `isExternal: true`
- **Relationship:** `club` (club ID from config)
Additional attributes (firstName, lastName, email, address, zipCode, city, country) are optional and are sent when present on the member so the contact is filled in. The app does **not** enforce extra required member fields for Vereinfacht; only Settings-based required fields and email apply.
- **Client:** `Mv.Vereinfacht.Client.create_contact/1` builds the JSON:API body from the member; `Mv.Constants.vereinfacht_required_member_fields/0` is an empty list.
### Updating a contact
- **Endpoint:** `PATCH /api/v1/finance-contacts/:id`
- **Client:** `Mv.Vereinfacht.Client.update_contact/2` sends current member attributes. The API may still validate presence/format of fields on update.
## Flow
1. **Member create/update:** `SyncContact` runs after the transaction. If the member has no `vereinfacht_contact_id`, the client tries `find_contact_by_email(email)`; if found, it updates that contact and stores the ID on the member; otherwise it creates a contact and stores the new ID. If the member already has a contact ID, the client updates the contact.
2. **Bulk sync:** “Sync all members without Vereinfacht contact” calls `Vereinfacht.sync_members_without_contact/0`, which loads members with nil/blank `vereinfacht_contact_id` and runs the same create/update flow per member.
## References
- **Config:** `Mv.Config` (`vereinfacht_api_url`, `vereinfacht_api_key`, `vereinfacht_club_id`, `vereinfacht_app_url`, `vereinfacht_configured?/0`).
- **Constants:** `Mv.Constants.vereinfacht_required_member_fields/0` (empty), `vereinfacht_required_field?/1` (legacy; currently unused in UI or validation).
- **Tests:** `test/mv/vereinfacht/`, `test/mv/config_vereinfacht_test.exs`; see `test/mv/vereinfacht/vereinfacht_test_README.md` for scope.
- **Roadmap:** Payment/transaction import and deeper integration are tracked in `docs/feature-roadmap.md` and `docs/membership-fee-architecture.md`.

View file

@ -550,11 +550,9 @@ defmodule Mv.Membership.Member do
end,
where: [action_is([:create_member, :update_member])]
# Validate member fields that are marked as required in settings or by Vereinfacht.
# When settings cannot be loaded, we still enforce email + Vereinfacht-required fields.
# Validate member fields that are marked as required in settings.
# When settings cannot be loaded, enforce only email.
validate fn changeset, _context ->
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
required_fields =
case Mv.Membership.get_settings() do
{:ok, settings} ->
@ -562,20 +560,17 @@ defmodule Mv.Membership.Member do
normalized = VisibilityConfig.normalize(required_config)
Enum.filter(Mv.Constants.member_fields(), fn field ->
field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
Map.get(normalized, field, false)
field == :email || Map.get(normalized, field, false)
end)
{:error, reason} ->
Logger.warning(
"Member required-fields validation: could not load settings (#{inspect(reason)}). " <>
"Enforcing only email and Vereinfacht-required fields."
"Enforcing only email."
)
Enum.filter(Mv.Constants.member_fields(), fn field ->
field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field))
field == :email
end)
end

View file

@ -28,15 +28,17 @@ defmodule Mv.Constants do
@email_validator_checks [:html_input, :pow]
# Member fields that are required when Vereinfacht integration is active (contact sync)
@vereinfacht_required_member_fields [:first_name, :last_name, :street, :postal_code, :city]
# No member fields are required solely for Vereinfacht; API accepts minimal payload
# (contactType + isExternal) when creating external contacts and supports filter by email for lookup.
@vereinfacht_required_member_fields []
def member_fields, do: @member_fields
@doc """
Returns member fields that are always required when Vereinfacht integration is configured.
Used for validation, member form required indicators, and settings UI (checkbox disabled).
Currently empty: the Vereinfacht API only requires contactType (e.g. "person") when creating
external contacts; lookup uses filter[email] so no extra required fields in the app.
"""
def vereinfacht_required_member_fields, do: @vereinfacht_required_member_fields

View file

@ -9,7 +9,7 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
(Mv.Config.vereinfacht_configured?/0).
Only runs when relevant data changed: on create always; on update only when
first_name, last_name, email, street, house_number, postal_code, or city changed,
first_name, last_name, email, street, house_number, postal_code, city, or country changed,
or when the member has no vereinfacht_contact_id yet (to avoid unnecessary API calls).
"""
use Ash.Resource.Change
@ -26,7 +26,8 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
:street,
:house_number,
:postal_code,
:city
:city,
:country
]
@impl true

View file

@ -166,12 +166,12 @@ defmodule Mv.Vereinfacht.Client do
end
@doc """
Finds a finance contact by email (GET /finance-contacts, then match in response).
Finds a finance contact by email using the API filter.
The Vereinfacht API does not allow filter by email on this endpoint, so we
fetch the first page and find the contact client-side. Returns {:ok, contact_id}
if a contact with that email exists, {:error, :not_found} if none, or
{:error, reason} on API/network failure. Used before create for idempotency.
Uses GET /finance-contacts?filter[isExternal]=true&filter[email]=... so the API
returns only matching external contacts. Returns {:ok, contact_id} if a contact
exists, {:error, :not_found} if none, or {:error, reason} on API/network failure.
Used before create for idempotency.
"""
@spec find_contact_by_email(String.t()) :: {:ok, String.t()} | {:error, :not_found | term()}
def find_contact_by_email(email) when is_binary(email) do
@ -182,25 +182,18 @@ defmodule Mv.Vereinfacht.Client do
end
end
@find_contact_page_size 100
@find_contact_max_pages 100
defp do_find_contact_by_email(email) do
normalized = String.trim(email) |> String.downcase()
do_find_contact_by_email_page(1, normalized)
end
defp do_find_contact_by_email_page(page, _normalized) when page > @find_contact_max_pages do
{:error, :not_found}
end
defp do_find_contact_by_email_page(page, normalized) do
normalized_email = email |> String.trim() |> String.downcase()
base = base_url() |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts")
url = base <> "?page[size]=#{@find_contact_page_size}&page[number]=#{page}"
encoded_email = URI.encode_www_form(normalized_email)
url = "#{base}?filter[isExternal]=true&filter[email]=#{encoded_email}"
case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do
{:ok, %{status: 200, body: body}} when is_map(body) ->
handle_find_contact_page_response(body, page, normalized)
case get_first_contact_id_from_list(body) do
nil -> {:error, :not_found}
id -> {:ok, id}
end
{:ok, %{status: status, body: body}} ->
{:error, {:http, status, extract_error_message(body)}}
@ -210,48 +203,20 @@ defmodule Mv.Vereinfacht.Client do
end
end
defp handle_find_contact_page_response(body, page, normalized) do
case find_contact_id_by_email_in_list(body, normalized) do
id when is_binary(id) -> {:ok, id}
nil -> maybe_find_contact_next_page(body, page, normalized)
defp get_first_contact_id_from_list(%{"data" => data} = _body) when is_list(data) do
if length(data) > 1 do
Logger.warning(
"Vereinfacht find_contact_by_email: API returned multiple contacts for same email (count: #{length(data)}), using first. Check for duplicate or inconsistent data."
)
end
case data do
[%{"id" => id} | _] -> normalize_contact_id(id)
[] -> nil
end
end
defp maybe_find_contact_next_page(body, page, normalized) do
data = Map.get(body, "data") || []
if length(data) < @find_contact_page_size,
do: {:error, :not_found},
else: do_find_contact_by_email_page(page + 1, normalized)
end
defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do
Enum.find_value(list, fn
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => true}}
when is_binary(att_email) ->
if att_email |> String.trim() |> String.downcase() == normalized do
normalize_contact_id(id)
else
nil
end
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => "true"}}
when is_binary(att_email) ->
if att_email |> String.trim() |> String.downcase() == normalized do
normalize_contact_id(id)
else
nil
end
%{"id" => _id, "attributes" => _} ->
nil
_ ->
nil
end)
end
defp find_contact_id_by_email_in_list(_, _), do: nil
defp get_first_contact_id_from_list(_), do: nil
defp normalize_contact_id(id) when is_binary(id), do: id
defp normalize_contact_id(id) when is_integer(id), do: to_string(id)
@ -389,6 +354,7 @@ defmodule Mv.Vereinfacht.Client do
|> put_attr("address", address)
|> put_attr("zipCode", member |> Map.get(:postal_code))
|> put_attr("city", member |> Map.get(:city))
|> put_attr("country", member |> Map.get(:country))
|> Map.put("contactType", "person")
|> Map.put("isExternal", true)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)

View file

@ -33,7 +33,6 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
assigns
|> assign(:field_attributes, get_field_attributes(assigns.member_field))
|> assign(:is_email_field?, assigns.member_field == :email)
|> assign(:vereinfacht_required_field?, vereinfacht_required_field?(assigns))
|> assign(:field_label, MemberFields.label(assigns.member_field))
~H"""
@ -120,22 +119,12 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
<%!-- Line break before Required / Show in overview block --%>
<div class="mt-4">
<%!-- Required: disabled for email (always required) or Vereinfacht-required fields when integration is active --%>
<%!-- Required: disabled for email (always required); else configurable in settings --%>
<div
:if={@is_email_field? or @vereinfacht_required_field?}
:if={@is_email_field?}
class="tooltip tooltip-right"
data-tip={
if(@is_email_field?,
do: gettext("This is a technical field and cannot be changed"),
else: gettext("Required for Vereinfacht integration and cannot be disabled.")
)
}
aria-label={
if(@is_email_field?,
do: gettext("This is a technical field and cannot be changed"),
else: gettext("Required for Vereinfacht integration and cannot be disabled.")
)
}
data-tip={gettext("This is a technical field and cannot be changed")}
aria-label={gettext("This is a technical field and cannot be changed")}
>
<fieldset class="mb-2 fieldset">
<label>
@ -164,7 +153,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
</fieldset>
</div>
<.input
:if={not @is_email_field? and not @vereinfacht_required_field?}
:if={not @is_email_field?}
field={@form[:required]}
type="checkbox"
label={gettext("Required")}
@ -211,12 +200,11 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
end
required =
socket.assigns.vereinfacht_required_field? ||
if Map.has_key?(member_field_params, "required") do
TypeParsers.parse_boolean(member_field_params["required"])
else
form.source["required"]
end
if Map.has_key?(member_field_params, "required") do
TypeParsers.parse_boolean(member_field_params["required"])
else
form.source["required"]
end
# Merge so we keep name/value_type and have current checkbox state; use as new form source
merged_source =
@ -247,12 +235,11 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
end
required =
socket.assigns.vereinfacht_required_field? ||
if Map.has_key?(member_field_params, "required") do
TypeParsers.parse_boolean(member_field_params["required"])
else
form.source["required"]
end
if Map.has_key?(member_field_params, "required") do
TypeParsers.parse_boolean(member_field_params["required"])
else
form.source["required"]
end
field_string = Atom.to_string(socket.assigns.member_field)
@ -292,20 +279,10 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
normalized_visibility = VisibilityConfig.normalize(visibility_config)
normalized_required = VisibilityConfig.normalize(required_config)
show_in_overview = Map.get(normalized_visibility, member_field, true)
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
# Persist in socket so validate/save can enforce server-side without relying on render assigns
socket =
assign(
socket,
:vereinfacht_required_field?,
vereinfacht_required_field?(%{member_field: member_field})
)
# Email always required; Vereinfacht-required fields when integration active; else from settings
# Email always required; else from settings
required =
member_field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(member_field)) ||
Map.get(normalized_required, member_field, false)
member_field == :email || Map.get(normalized_required, member_field, false)
form_data = %{
"name" => MemberFields.label(member_field),
@ -338,9 +315,4 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
defp format_error(error) do
inspect(error)
end
defp vereinfacht_required_field?(assigns) do
Mv.Config.vereinfacht_configured?() &&
Mv.Constants.vereinfacht_required_field?(assigns.member_field)
end
end

View file

@ -172,19 +172,15 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
member_fields = Mv.Constants.member_fields()
visibility_config = settings.member_field_visibility || %{}
required_config = settings.member_field_required || %{}
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
normalized_visibility = VisibilityConfig.normalize(visibility_config)
normalized_required = VisibilityConfig.normalize(required_config)
Enum.map(member_fields, fn field ->
show_in_overview = Map.get(normalized_visibility, field, true)
# Email always required; Vereinfacht-required fields when integration active; else from settings
# Email always required; else from settings
required =
field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
Map.get(normalized_required, field, false)
field == :email || Map.get(normalized_required, field, false)
attribute = Info.attribute(Mv.Membership.Member, field)

View file

@ -398,8 +398,6 @@ defmodule MvWeb.MemberLive.Form do
end
defp get_member_field_required_map do
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
case Membership.get_settings() do
{:ok, settings} ->
required_config = settings.member_field_required || %{}
@ -407,20 +405,15 @@ defmodule MvWeb.MemberLive.Form do
Mv.Constants.member_fields()
|> Enum.map(fn field ->
required =
field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
Map.get(normalized, field, false)
required = field == :email || Map.get(normalized, field, false)
{field, required}
end)
|> Map.new()
{:error, _} ->
# Email always required; Vereinfacht fields when integration active
# When settings cannot be loaded, only email is required
Map.new(Mv.Constants.member_fields(), fn f ->
{f,
f == :email || (vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(f))}
{f, f == :email}
end)
end
end

View file

@ -75,6 +75,7 @@ defmodule Mv.MixProject do
{:bandit, "~> 1.5"},
{:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false},
{:sobelow, "~> 0.14", only: [:dev, :test], runtime: false},
{:bypass, "~> 2.1", only: [:dev, :test]},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:picosat_elixir, "~> 0.1"},
{:ecto_commons, "~> 0.3"},

View file

@ -10,11 +10,15 @@
"bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
"credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"},
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
@ -65,8 +69,10 @@
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_cowboy": {:hex, :plug_cowboy, "2.8.0", "07789e9c03539ee51bb14a07839cc95aa96999fd8846ebfd28c97f0b50c7b612", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9cbfaaf17463334ca31aed38ea7e08a68ee37cabc077b1e9be6d2fb68e0171d0"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"},
"reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},

View file

@ -2820,11 +2820,6 @@ msgstr "Okt."
msgid "Sep."
msgstr "Sep."
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert werden."
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format

View file

@ -2820,11 +2820,6 @@ msgstr ""
msgid "Sep."
msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format

View file

@ -2820,11 +2820,6 @@ msgstr ""
msgid "Sep."
msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr "Required for Vereinfacht integration and cannot be disabled."
#: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format

View file

@ -2,8 +2,8 @@ defmodule Mv.Vereinfacht.ClientTest do
@moduledoc """
Tests for Mv.Vereinfacht.Client.
Only tests the "not configured" path; no real HTTP calls. Config reads from
ENV first, then from Settings (DB), so we use DataCase so get_settings() is available.
"Not configured" path: no HTTP. When configured we use Bypass to stub the API
and assert on request (query params) and response parsing.
"""
use Mv.DataCase, async: false
@ -30,6 +30,84 @@ defmodule Mv.Vereinfacht.ClientTest do
end
end
describe "find_contact_by_email/1" do
test "returns {:error, :not_configured} when Vereinfacht is not configured" do
assert Client.find_contact_by_email("kayley.becker@example.com") ==
{:error, :not_configured}
end
@tag :bypass
test "sends filter[isExternal]=true and filter[email]=<encoded> and returns :not_found when data is empty" do
bypass = Bypass.open()
base = "http://127.0.0.1:#{bypass.port}"
set_vereinfacht_env(base)
Bypass.expect_once(bypass, "GET", "/finance-contacts", fn conn ->
qs = conn.query_string || ""
assert qs =~ "filter[isExternal]=true",
"expected query to contain filter[isExternal]=true, got: #{inspect(qs)}"
assert qs =~ "filter[email]=",
"expected query to contain filter[email]=..., got: #{inspect(qs)}"
# Email should be encoded (e.g. @ as %40)
assert qs =~ "filter[email]=test%40example.com",
"expected filter[email] to be URL-encoded (downcased), got: #{inspect(qs)}"
body = Jason.encode!(%{"jsonapi" => %{"version" => "1.0"}, "data" => []})
conn
|> Plug.Conn.put_resp_content_type("application/vnd.api+json")
|> Plug.Conn.send_resp(200, body)
end)
assert Client.find_contact_by_email(" Test@Example.com ") == {:error, :not_found}
end
@tag :bypass
test "returns {:ok, id} when API returns one contact (string id)" do
bypass = Bypass.open()
base = "http://127.0.0.1:#{bypass.port}"
set_vereinfacht_env(base)
Bypass.expect_once(bypass, "GET", "/finance-contacts", fn conn ->
body =
Jason.encode!(%{
"jsonapi" => %{"version" => "1.0"},
"data" => [%{"type" => "finance-contacts", "id" => "123", "attributes" => %{}}]
})
conn
|> Plug.Conn.put_resp_content_type("application/vnd.api+json")
|> Plug.Conn.send_resp(200, body)
end)
assert Client.find_contact_by_email("user@example.com") == {:ok, "123"}
end
@tag :bypass
test "returns {:ok, id} when API returns one contact (integer id)" do
bypass = Bypass.open()
base = "http://127.0.0.1:#{bypass.port}"
set_vereinfacht_env(base)
Bypass.expect_once(bypass, "GET", "/finance-contacts", fn conn ->
body =
Jason.encode!(%{
"jsonapi" => %{"version" => "1.0"},
"data" => [%{"type" => "finance-contacts", "id" => 456, "attributes" => %{}}]
})
conn
|> Plug.Conn.put_resp_content_type("application/vnd.api+json")
|> Plug.Conn.send_resp(200, body)
end)
assert Client.find_contact_by_email("other@example.com") == {:ok, "456"}
end
end
defp build_member_struct do
%{
first_name: "Test",
@ -42,6 +120,12 @@ defmodule Mv.Vereinfacht.ClientTest do
}
end
defp set_vereinfacht_env(base_url) do
System.put_env("VEREINFACHT_API_URL", base_url)
System.put_env("VEREINFACHT_API_KEY", "test-key")
System.put_env("VEREINFACHT_CLUB_ID", "2")
end
defp clear_vereinfacht_env do
System.delete_env("VEREINFACHT_API_URL")
System.delete_env("VEREINFACHT_API_KEY")