diff --git a/.formatter.exs b/.formatter.exs
new file mode 100644
index 0000000..fab2411
--- /dev/null
+++ b/.formatter.exs
@@ -0,0 +1,10 @@
+[
+ import_deps: [:ash, :ash_postgres, :ash_phoenix, :ash_json_api, :ecto, :ecto_sql, :phoenix],
+ subdirectories: ["priv/*/migrations"],
+ plugins: [Spark.Formatter],
+ inputs: [
+ "*.{heex,ex,exs}",
+ "{config,lib,test}/**/*.{heex,ex,exs}",
+ "priv/*/seeds.exs"
+ ]
+]
diff --git a/README.md b/README.md
index 45d2490..0c7d759 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,210 @@
-# PAEx
-Plateforme Agrée elixir implementation
+# PAEx — Plateforme Agréée Elixir
+
+**PAEx** is an open-source reference implementation of a French accredited partner
+dematerialization platform (**Plateforme Agréée** / PDP) for mandatory
+e-invoicing (_facturation électronique_) and e-reporting, built with Elixir,
+Phoenix, Ecto and the [Ash Framework](https://ash-hq.org/).
+
+---
+
+## Background
+
+France is rolling out mandatory B2B e-invoicing for all VAT-registered companies
+(large companies from 2026, SMEs from 2027). Every invoice must transit through
+either the public portal (PPF – _Portail Public de Facturation_) or an accredited
+private PDP. E-reporting also covers B2C and international B2B transactions not
+subject to e-invoicing.
+
+PAEx demonstrates how such a platform can be built on the Elixir/Phoenix/Ash
+stack, providing:
+
+- **Structured invoice lifecycle management** aligned with the 11 official DGFiP
+ invoice statuses
+- **Multi-format support**: Factur-X (PDF/A-3 + XML), UBL, and CII
+- **E-reporting** for B2C, cross-border B2B, and payment flows
+- **Full transmission audit trail** for every exchange with the PPF and other PDPs
+- **Company directory** with SIREN/SIRET identifiers and PPF routing IDs
+- **Role-based access control** via Ash Policies (admin / operator / api_client)
+- **JSON REST API** for programmatic integration
+
+---
+
+## Technology Stack
+
+| Layer | Technology |
+|----------------|-------------------------------------------|
+| Language | Elixir ≥ 1.16 |
+| Web framework | Phoenix 1.7 + LiveView |
+| Domain logic | Ash Framework 3.x |
+| Database | PostgreSQL 14+ via AshPostgres / Ecto SQL |
+| API | JSON REST (AshJsonApi) |
+| Auth | Ash Policies + Bcrypt |
+
+---
+
+## Domain Model
+
+```
+PAEx.Accounts → User, Token
+PAEx.Companies → Company (SIREN / SIRET)
+PAEx.Invoices → Invoice, InvoiceLine, StatusEvent
+PAEx.EReporting → EReport, EReportLine
+PAEx.Transmission → TransmissionEvent
+```
+
+### Invoice lifecycle (`PAEx.Invoices.Invoice`)
+
+```
+deposee
+ ├─[valid]────► en_cours_de_traitement
+ │ └─[routed]──► en_cours_d_emission
+ │ └─[delivered]──► emise
+ │ ├─[refused]──► refusee
+ │ └─[accepted]─► acceptee
+ │ └─► mise_en_paiement
+ │ └─► comptabilisee
+ ├─[invalid]──► rejetee
+ └─(any stage before comptabilisee) ──► annulee / en_litige
+```
+
+### E-reporting (`PAEx.EReporting.EReport`)
+
+Report types: `b2c` · `b2b_international` · `payment`
+Statuses: `draft` → `submitted` → `accepted` / `rejected`
+
+### Transmission audit (`PAEx.Transmission.TransmissionEvent`)
+
+Records every outbound (to PPF/PDP/ERP) and inbound (webhooks, ACKs) message.
+
+---
+
+## Getting Started
+
+### Prerequisites
+
+- Elixir ≥ 1.16 / OTP ≥ 26
+- PostgreSQL ≥ 14
+- Node.js ≥ 18 (for asset pipeline)
+
+### Setup
+
+```bash
+# Install dependencies
+mix deps.get
+
+# Create and migrate the database
+mix ecto.setup
+
+# Start the server
+mix phx.server
+```
+
+The application is now available at **http://localhost:4000**.
+
+### Running Tests
+
+```bash
+mix test
+```
+
+---
+
+## REST API Overview
+
+All API endpoints are prefixed with `/api/v1`.
+
+### Companies
+
+| Method | Path | Description |
+|--------|------------------------|---------------------------|
+| GET | `/companies` | List all companies |
+| POST | `/companies` | Register a new company |
+| GET | `/companies/:id` | Get a specific company |
+| PUT | `/companies/:id` | Update company details |
+
+### Invoices
+
+| Method | Path | Description |
+|--------|-------------------------------------|-------------------------------|
+| GET | `/invoices` | List invoices |
+| POST | `/invoices` | Submit a new invoice |
+| GET | `/invoices/:id` | Get invoice details |
+| POST | `/invoices/:id/start_processing` | Start processing |
+| POST | `/invoices/:id/reject` | Reject invoice |
+| POST | `/invoices/:id/start_emission` | Start routing to recipient |
+| POST | `/invoices/:id/mark_emitted` | Confirm delivery |
+| POST | `/invoices/:id/refuse` | Recipient refuses invoice |
+| POST | `/invoices/:id/accept` | Recipient accepts invoice |
+| POST | `/invoices/:id/initiate_payment` | Initiate payment |
+| POST | `/invoices/:id/mark_accounted` | Mark invoice as accounted |
+| POST | `/invoices/:id/raise_dispute` | Raise a dispute |
+| POST | `/invoices/:id/cancel` | Cancel the invoice |
+
+### E-Reporting
+
+| Method | Path | Description |
+|--------|------------------------------|--------------------------------|
+| GET | `/e_reports` | List e-reports |
+| POST | `/e_reports` | Create a draft e-report |
+| GET | `/e_reports/:id` | Get report details |
+| POST | `/e_reports/:id/submit` | Submit report to PPF |
+| POST | `/e_reports/:id/acknowledge` | Record PPF acceptance |
+| POST | `/e_reports/:id/reject` | Record PPF rejection |
+
+### Transmission Events
+
+| Method | Path | Description |
+|--------|-------------------------------|-------------------------------|
+| GET | `/transmission_events` | List all transmission events |
+| GET | `/transmission_events/:id` | Get a specific event |
+
+---
+
+## Project Structure
+
+```
+lib/
+├── pa_ex/
+│ ├── application.ex
+│ ├── repo.ex
+│ ├── accounts/
+│ │ ├── accounts.ex # Ash domain
+│ │ └── resources/
+│ │ ├── user.ex
+│ │ └── token.ex
+│ ├── companies/
+│ │ ├── companies.ex
+│ │ └── resources/
+│ │ └── company.ex
+│ ├── invoices/
+│ │ ├── invoices.ex
+│ │ └── resources/
+│ │ ├── invoice.ex
+│ │ ├── invoice_line.ex
+│ │ └── status_event.ex
+│ ├── e_reporting/
+│ │ ├── e_reporting.ex
+│ │ └── resources/
+│ │ ├── e_report.ex
+│ │ └── e_report_line.ex
+│ └── transmission/
+│ ├── transmission.ex
+│ └── resources/
+│ └── transmission_event.ex
+└── pa_ex_web/
+ ├── endpoint.ex
+ ├── router.ex
+ ├── telemetry.ex
+ └── controllers/
+ ├── company_controller.ex
+ ├── invoice_controller.ex
+ ├── e_report_controller.ex
+ ├── transmission_event_controller.ex
+ └── fallback_controller.ex
+```
+
+---
+
+## License
+
+MIT — see [LICENSE](LICENSE).
diff --git a/config/config.exs b/config/config.exs
new file mode 100644
index 0000000..7d02c0d
--- /dev/null
+++ b/config/config.exs
@@ -0,0 +1,44 @@
+import Config
+
+# Configure Ash domains
+config :pa_ex,
+ ash_domains: [
+ PAEx.Accounts,
+ PAEx.Companies,
+ PAEx.Invoices,
+ PAEx.EReporting,
+ PAEx.Transmission
+ ]
+
+# Ecto repos
+config :pa_ex,
+ ecto_repos: [PAEx.Repo]
+
+config :pa_ex, PAEx.Repo,
+ migration_primary_key: [type: :uuid],
+ migration_timestamps: [type: :utc_datetime_usec]
+
+# Phoenix endpoint
+config :pa_ex, PAExWeb.Endpoint,
+ url: [host: "localhost"],
+ adapter: Bandit.PhoenixAdapter,
+ render_errors: [
+ formats: [html: PAExWeb.ErrorHTML, json: PAExWeb.ErrorJSON],
+ layout: false
+ ],
+ pubsub_server: PAEx.PubSub,
+ live_view: [signing_salt: "pa_ex_lv_salt"]
+
+# Logger
+config :logger, :console,
+ format: "$time $metadata[$level] $message\n",
+ metadata: [:request_id, :domain]
+
+# Phoenix
+config :phoenix, :json_library, Jason
+
+# AshJsonApi
+config :ash_json_api,
+ include_nil_values?: false
+
+import_config "#{config_env()}.exs"
diff --git a/config/dev.exs b/config/dev.exs
new file mode 100644
index 0000000..3ceeccb
--- /dev/null
+++ b/config/dev.exs
@@ -0,0 +1,23 @@
+import Config
+
+config :pa_ex, PAEx.Repo,
+ username: "postgres",
+ password: "postgres",
+ hostname: "localhost",
+ database: "pa_ex_dev",
+ stacktrace: true,
+ show_sensitive_data_on_connection_error: true,
+ pool_size: 10
+
+config :pa_ex, PAExWeb.Endpoint,
+ http: [ip: {127, 0, 0, 1}, port: 4000],
+ check_origin: false,
+ code_reloader: true,
+ debug_errors: true,
+ secret_key_base: "dev_secret_key_base_at_least_64_bytes_long_replace_in_production_000",
+ watchers: []
+
+config :logger, :console, format: "[$level] $message\n"
+
+config :phoenix, :stacktrace_depth, 20
+config :phoenix, :plug_init_mode, :runtime
diff --git a/config/prod.exs b/config/prod.exs
new file mode 100644
index 0000000..9cea256
--- /dev/null
+++ b/config/prod.exs
@@ -0,0 +1,5 @@
+import Config
+
+config :logger, level: :info
+
+config :phoenix, :serve_endpoints, true
diff --git a/config/runtime.exs b/config/runtime.exs
new file mode 100644
index 0000000..078a4e4
--- /dev/null
+++ b/config/runtime.exs
@@ -0,0 +1,37 @@
+import Config
+
+if config_env() == :prod do
+ database_url =
+ System.get_env("DATABASE_URL") ||
+ raise """
+ environment variable DATABASE_URL is missing.
+ For example: ecto://USER:PASS@HOST/DATABASE
+ """
+
+ maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
+
+ config :pa_ex, PAEx.Repo,
+ url: database_url,
+ pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
+ socket_options: maybe_ipv6
+
+ secret_key_base =
+ System.get_env("SECRET_KEY_BASE") ||
+ raise """
+ environment variable SECRET_KEY_BASE is missing.
+ You can generate one by calling: mix phx.gen.secret
+ """
+
+ host = System.get_env("PHX_HOST") || "example.com"
+ port = String.to_integer(System.get_env("PORT") || "4000")
+
+ config :pa_ex, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
+
+ config :pa_ex, PAExWeb.Endpoint,
+ url: [host: host, port: 443, scheme: "https"],
+ http: [
+ ip: {0, 0, 0, 0, 0, 0, 0, 0},
+ port: port
+ ],
+ secret_key_base: secret_key_base
+end
diff --git a/config/test.exs b/config/test.exs
new file mode 100644
index 0000000..8122569
--- /dev/null
+++ b/config/test.exs
@@ -0,0 +1,18 @@
+import Config
+
+config :pa_ex, PAEx.Repo,
+ username: "postgres",
+ password: "postgres",
+ hostname: "localhost",
+ database: "pa_ex_test#{System.get_env("MIX_TEST_PARTITION")}",
+ pool: Ecto.Adapters.SQL.Sandbox,
+ pool_size: System.schedulers_online() * 2
+
+config :pa_ex, PAExWeb.Endpoint,
+ http: [ip: {127, 0, 0, 1}, port: 4002],
+ secret_key_base: "test_secret_key_base_at_least_64_bytes_long_replace_in_production_00",
+ server: false
+
+config :logger, level: :warning
+
+config :phoenix, :plug_init_mode, :runtime
diff --git a/lib/pa_ex/accounts/accounts.ex b/lib/pa_ex/accounts/accounts.ex
new file mode 100644
index 0000000..8c38ea1
--- /dev/null
+++ b/lib/pa_ex/accounts/accounts.ex
@@ -0,0 +1,17 @@
+defmodule PAEx.Accounts do
+ @moduledoc """
+ The Accounts domain manages users and authentication tokens for the PAEx platform.
+
+ Users can hold different roles within the system:
+
+ * `:admin` — platform administrators with full access
+ * `:operator` — staff who manage invoices and e-reporting on behalf of companies
+ * `:api_client` — machine-to-machine clients (PDPs, ERPs, etc.)
+ """
+ use Ash.Domain, otp_app: :pa_ex
+
+ resources do
+ resource PAEx.Accounts.User
+ resource PAEx.Accounts.Token
+ end
+end
diff --git a/lib/pa_ex/accounts/resources/token.ex b/lib/pa_ex/accounts/resources/token.ex
new file mode 100644
index 0000000..1fafbce
--- /dev/null
+++ b/lib/pa_ex/accounts/resources/token.ex
@@ -0,0 +1,83 @@
+defmodule PAEx.Accounts.Token do
+ @moduledoc """
+ Short-lived authentication / confirmation tokens linked to a user.
+
+ Token types:
+ * `:session` – API bearer token issued after login
+ * `:confirmation` – used to confirm a user's email address
+ * `:reset` – used to reset a forgotten password
+ """
+ use Ash.Resource,
+ otp_app: :pa_ex,
+ domain: PAEx.Accounts,
+ data_layer: AshPostgres.DataLayer
+
+ postgres do
+ table "tokens"
+ repo PAEx.Repo
+ end
+
+ attributes do
+ uuid_primary_key :id
+
+ attribute :token, :string do
+ allow_nil? false
+ sensitive? true
+ public? true
+ end
+
+ attribute :context, :atom do
+ constraints one_of: [:session, :confirmation, :reset]
+ allow_nil? false
+ public? true
+ end
+
+ attribute :sent_to, :string do
+ public? true
+ end
+
+ attribute :expires_at, :utc_datetime_usec do
+ allow_nil? false
+ public? true
+ end
+
+ create_timestamp :inserted_at
+ end
+
+ relationships do
+ belongs_to :user, PAEx.Accounts.User do
+ allow_nil? false
+ public? true
+ end
+ end
+
+ validations do
+ validate compare(:expires_at, greater_than: &DateTime.utc_now/0) do
+ on [:create]
+ message "must be in the future"
+ end
+ end
+
+ actions do
+ defaults [:read, :destroy]
+
+ create :generate do
+ description "Generate a new token for the given user and context."
+ accept [:context, :sent_to, :expires_at, :user_id]
+
+ change fn changeset, _ ->
+ raw = :crypto.strong_rand_bytes(32)
+ token = Base.url_encode64(raw, padding: false)
+ Ash.Changeset.change_attribute(changeset, :token, token)
+ end
+ end
+
+ read :by_token do
+ description "Look up a valid (non-expired) token by its value."
+ argument :token, :string, allow_nil?: false
+ argument :context, :atom, allow_nil?: false
+
+ filter expr(token == ^arg(:token) and context == ^arg(:context) and expires_at > now())
+ end
+ end
+end
diff --git a/lib/pa_ex/accounts/resources/user.ex b/lib/pa_ex/accounts/resources/user.ex
new file mode 100644
index 0000000..0dc3174
--- /dev/null
+++ b/lib/pa_ex/accounts/resources/user.ex
@@ -0,0 +1,154 @@
+defmodule PAEx.Accounts.User do
+ @moduledoc """
+ Represents a platform user.
+
+ Users authenticate with email + password and hold a role that controls what
+ actions they can perform within the platform.
+ """
+ use Ash.Resource,
+ otp_app: :pa_ex,
+ domain: PAEx.Accounts,
+ data_layer: AshPostgres.DataLayer,
+ authorizers: [Ash.Policy.Authorizer]
+
+ postgres do
+ table "users"
+ repo PAEx.Repo
+ end
+
+ attributes do
+ uuid_primary_key :id
+
+ attribute :email, :ci_string do
+ allow_nil? false
+ public? true
+ end
+
+ attribute :hashed_password, :string do
+ allow_nil? false
+ sensitive? true
+ end
+
+ attribute :role, :atom do
+ constraints one_of: [:admin, :operator, :api_client]
+ default :operator
+ allow_nil? false
+ public? true
+ end
+
+ attribute :confirmed_at, :utc_datetime_usec do
+ public? true
+ end
+
+ create_timestamp :inserted_at
+ update_timestamp :updated_at
+ end
+
+ identities do
+ identity :unique_email, [:email]
+ end
+
+ validations do
+ validate match(:email, ~r/^[^\s]+@[^\s]+$/) do
+ message "must be a valid email address"
+ end
+ end
+
+ actions do
+ defaults [:read, :destroy]
+
+ create :register do
+ description "Register a new user with email and password."
+ accept [:email, :role]
+
+ argument :password, :string do
+ allow_nil? false
+ sensitive? true
+ constraints min_length: 12
+ end
+
+ argument :password_confirmation, :string do
+ allow_nil? false
+ sensitive? true
+ end
+
+ validate confirm(:password, :password_confirmation)
+
+ change fn changeset, _ ->
+ password = Ash.Changeset.get_argument(changeset, :password)
+
+ Ash.Changeset.change_attribute(
+ changeset,
+ :hashed_password,
+ Bcrypt.hash_pwd_salt(password)
+ )
+ end
+ end
+
+ update :confirm_email do
+ description "Mark the user's email address as confirmed."
+ accept []
+ change set_attribute(:confirmed_at, &DateTime.utc_now/0)
+ end
+
+ update :change_password do
+ description "Allow a user to change their password."
+ accept []
+
+ argument :current_password, :string do
+ allow_nil? false
+ sensitive? true
+ end
+
+ argument :new_password, :string do
+ allow_nil? false
+ sensitive? true
+ constraints min_length: 12
+ end
+
+ argument :new_password_confirmation, :string do
+ allow_nil? false
+ sensitive? true
+ end
+
+ validate confirm(:new_password, :new_password_confirmation)
+
+ change fn changeset, _ ->
+ new_password = Ash.Changeset.get_argument(changeset, :new_password)
+
+ Ash.Changeset.change_attribute(
+ changeset,
+ :hashed_password,
+ Bcrypt.hash_pwd_salt(new_password)
+ )
+ end
+ end
+ end
+
+ calculations do
+ calculate :confirmed?, :boolean, expr(not is_nil(confirmed_at))
+ end
+
+ policies do
+ policy action_type(:read) do
+ authorize_if expr(id == ^actor(:id))
+ authorize_if actor_attribute_equals(:role, :admin)
+ end
+
+ policy action(:register) do
+ authorize_if always()
+ end
+
+ policy action(:confirm_email) do
+ authorize_if expr(id == ^actor(:id))
+ end
+
+ policy action(:change_password) do
+ authorize_if expr(id == ^actor(:id))
+ end
+
+ policy action_type(:destroy) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ end
+ end
+end
diff --git a/lib/pa_ex/application.ex b/lib/pa_ex/application.ex
new file mode 100644
index 0000000..65a2492
--- /dev/null
+++ b/lib/pa_ex/application.ex
@@ -0,0 +1,29 @@
+defmodule PAEx.Application do
+ @moduledoc """
+ OTP Application entry point for PAEx – Plateforme Agréée Elixir.
+
+ Starts the supervision tree including the database repo, Phoenix endpoint,
+ and any background workers required for e-invoicing and e-reporting.
+ """
+ use Application
+
+ @impl true
+ def start(_type, _args) do
+ children = [
+ PAExWeb.Telemetry,
+ PAEx.Repo,
+ {DNSCluster, query: Application.get_env(:pa_ex, :dns_cluster_query, :ignore)},
+ {Phoenix.PubSub, name: PAEx.PubSub},
+ PAExWeb.Endpoint
+ ]
+
+ opts = [strategy: :one_for_one, name: PAEx.Supervisor]
+ Supervisor.start_link(children, opts)
+ end
+
+ @impl true
+ def config_change(changed, _new, removed) do
+ PAExWeb.Endpoint.config_change(changed, removed)
+ :ok
+ end
+end
diff --git a/lib/pa_ex/companies/companies.ex b/lib/pa_ex/companies/companies.ex
new file mode 100644
index 0000000..a03b634
--- /dev/null
+++ b/lib/pa_ex/companies/companies.ex
@@ -0,0 +1,17 @@
+defmodule PAEx.Companies do
+ @moduledoc """
+ The Companies domain manages the legal entities (entreprises) registered on
+ the PAEx platform.
+
+ A company is identified by its SIREN number (9 digits, unique per legal entity)
+ and may have one or more establishments identified by their SIRET number (14
+ digits: SIREN + 5-digit establishment suffix). The `ppf_routing_id` field
+ stores the identifier in the national PPF directory, which is required to
+ route incoming invoices.
+ """
+ use Ash.Domain, otp_app: :pa_ex
+
+ resources do
+ resource PAEx.Companies.Company
+ end
+end
diff --git a/lib/pa_ex/companies/resources/company.ex b/lib/pa_ex/companies/resources/company.ex
new file mode 100644
index 0000000..7b3e400
--- /dev/null
+++ b/lib/pa_ex/companies/resources/company.ex
@@ -0,0 +1,248 @@
+defmodule PAEx.Companies.Company do
+ @moduledoc """
+ A legal entity (entreprise / établissement) registered on the PAEx platform.
+
+ ## Identifiers
+ * `siren` – 9-digit French company identifier (registre du commerce)
+ * `siret` – 14-digit establishment identifier (SIREN + NIC)
+ * `vat_number` – intra-EU VAT number (e.g. `FR12345678901`)
+
+ ## Status
+ * `:active` – the company can emit and receive invoices
+ * `:suspended` – operations are temporarily blocked
+ * `:closed` – the company has been deregistered
+
+ ## Role on the platform
+ * `:emitter` – the company only emits invoices (vendeur / fournisseur)
+ * `:receiver` – the company only receives invoices (acheteur / client)
+ * `:both` – the company both emits and receives invoices
+ """
+ use Ash.Resource,
+ otp_app: :pa_ex,
+ domain: PAEx.Companies,
+ data_layer: AshPostgres.DataLayer,
+ authorizers: [Ash.Policy.Authorizer]
+
+ postgres do
+ table "companies"
+ repo PAEx.Repo
+ end
+
+ attributes do
+ uuid_primary_key :id
+
+ attribute :name, :string do
+ allow_nil? false
+ public? true
+ constraints max_length: 255
+ end
+
+ # SIREN: 9 digits
+ attribute :siren, :string do
+ allow_nil? false
+ public? true
+ constraints min_length: 9, max_length: 9, match: ~r/^\d{9}$/
+ end
+
+ # SIRET: SIREN (9) + NIC (5) = 14 digits
+ attribute :siret, :string do
+ allow_nil? true
+ public? true
+ constraints min_length: 14, max_length: 14, match: ~r/^\d{14}$/
+ end
+
+ attribute :vat_number, :string do
+ allow_nil? true
+ public? true
+ constraints max_length: 20
+ end
+
+ # Address fields (required by French e-invoicing specs)
+ attribute :address_street, :string do
+ allow_nil? true
+ public? true
+ end
+
+ attribute :address_city, :string do
+ allow_nil? true
+ public? true
+ constraints max_length: 100
+ end
+
+ attribute :address_postal_code, :string do
+ allow_nil? true
+ public? true
+ constraints max_length: 10
+ end
+
+ attribute :address_country, :string do
+ allow_nil? true
+ public? true
+ constraints min_length: 2, max_length: 2
+ default "FR"
+ end
+
+ attribute :status, :atom do
+ constraints one_of: [:active, :suspended, :closed]
+ default :active
+ allow_nil? false
+ public? true
+ end
+
+ attribute :role, :atom do
+ constraints one_of: [:emitter, :receiver, :both]
+ default :both
+ allow_nil? false
+ public? true
+ end
+
+ # Identifier in the PPF national directory for routing incoming invoices.
+ # For PDPs, this is the SIRET or a specific routing code assigned by the PPF.
+ attribute :ppf_routing_id, :string do
+ allow_nil? true
+ public? true
+ constraints max_length: 50
+ end
+
+ create_timestamp :inserted_at
+ update_timestamp :updated_at
+ end
+
+ identities do
+ identity :unique_siren, [:siren]
+ end
+
+ relationships do
+ has_many :emitted_invoices, PAEx.Invoices.Invoice do
+ source_attribute :id
+ destination_attribute :emitter_id
+ end
+
+ has_many :received_invoices, PAEx.Invoices.Invoice do
+ source_attribute :id
+ destination_attribute :receiver_id
+ end
+ end
+
+ validations do
+ # Luhn-like SIREN check is handled at the service layer; here we validate format only.
+ validate match(:siren, ~r/^\d{9}$/) do
+ message "must be a 9-digit number"
+ end
+
+ validate match(:siret, ~r/^\d{14}$/) do
+ where present(:siret)
+ message "must be a 14-digit number"
+ end
+
+ validate match(:vat_number, ~r/^[A-Z]{2}[0-9A-Z]{2,13}$/) do
+ where present(:vat_number)
+ message "must be a valid EU VAT number"
+ end
+ end
+
+ actions do
+ defaults [:read, :destroy]
+
+ create :register do
+ description "Register a new company on the platform."
+ accept [
+ :name,
+ :siren,
+ :siret,
+ :vat_number,
+ :address_street,
+ :address_city,
+ :address_postal_code,
+ :address_country,
+ :role,
+ :ppf_routing_id
+ ]
+ end
+
+ update :update_details do
+ description "Update company contact and address information."
+ accept [
+ :name,
+ :address_street,
+ :address_city,
+ :address_postal_code,
+ :address_country,
+ :ppf_routing_id
+ ]
+ end
+
+ update :suspend do
+ description "Suspend a company's operations on the platform."
+ accept []
+ change set_attribute(:status, :suspended)
+ end
+
+ update :close do
+ description "Mark a company as permanently closed / deregistered."
+ accept []
+ change set_attribute(:status, :closed)
+ end
+
+ update :reactivate do
+ description "Reactivate a previously suspended company."
+ accept []
+ change set_attribute(:status, :active)
+ end
+
+ read :active do
+ description "List all active companies."
+ filter expr(status == :active)
+ end
+
+ read :by_siren do
+ description "Fetch a company by its SIREN."
+ argument :siren, :string, allow_nil?: false
+ filter expr(siren == ^arg(:siren))
+ end
+ end
+
+ calculations do
+ calculate :full_address, :string, expr(
+ fragment(
+ "concat_ws(', ', ?, concat_ws(' ', ?, ?), ?)",
+ address_street,
+ address_postal_code,
+ address_city,
+ address_country
+ )
+ )
+ end
+
+ policies do
+ policy action_type(:read) do
+ authorize_if always()
+ end
+
+ policy action(:register) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ authorize_if actor_attribute_equals(:role, :operator)
+ end
+
+ policy action(:update_details) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ authorize_if actor_attribute_equals(:role, :operator)
+ end
+
+ policy action(:suspend) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ end
+
+ policy action(:close) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ end
+
+ policy action(:reactivate) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ end
+
+ policy action_type(:destroy) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ end
+ end
+end
diff --git a/lib/pa_ex/e_reporting/e_reporting.ex b/lib/pa_ex/e_reporting/e_reporting.ex
new file mode 100644
index 0000000..ea3154d
--- /dev/null
+++ b/lib/pa_ex/e_reporting/e_reporting.ex
@@ -0,0 +1,20 @@
+defmodule PAEx.EReporting do
+ @moduledoc """
+ The EReporting domain manages periodic e-reporting obligations as defined by
+ the French DGFiP for the mandatory e-invoicing reform.
+
+ E-reporting covers transaction flows that are **not** subject to e-invoicing:
+ * `b2c` – B2C (business-to-consumer) transactions
+ * `b2b_international`– cross-border B2B transactions with non-French counterparts
+ * `payment` – payment data transmission
+
+ Reports are grouped into **periods** (a calendar month or week depending on
+ the company's reporting frequency) and transmitted to the PPF.
+ """
+ use Ash.Domain, otp_app: :pa_ex
+
+ resources do
+ resource PAEx.EReporting.EReport
+ resource PAEx.EReporting.EReportLine
+ end
+end
diff --git a/lib/pa_ex/e_reporting/resources/e_report.ex b/lib/pa_ex/e_reporting/resources/e_report.ex
new file mode 100644
index 0000000..84fa1cb
--- /dev/null
+++ b/lib/pa_ex/e_reporting/resources/e_report.ex
@@ -0,0 +1,247 @@
+defmodule PAEx.EReporting.EReport do
+ @moduledoc """
+ A periodic e-reporting submission sent to the PPF by a company.
+
+ ## Report types
+ * `:b2c` – aggregated B2C (consumer) sales data
+ * `:b2b_international` – B2B cross-border invoice data
+ * `:payment` – payment information for previously reported invoices
+
+ ## Statuses
+ * `:draft` – being compiled, not yet sent
+ * `:submitted` – transmitted to the PPF, awaiting acknowledgement
+ * `:accepted` – acknowledged and accepted by the PPF
+ * `:rejected` – rejected by the PPF (must be corrected and resubmitted)
+
+ ## Reporting frequency
+ The DGFiP allows weekly or monthly reporting depending on the company's VAT
+ filing regime. The `period_start` / `period_end` attributes capture the
+ exact period covered.
+ """
+ use Ash.Resource,
+ otp_app: :pa_ex,
+ domain: PAEx.EReporting,
+ data_layer: AshPostgres.DataLayer,
+ authorizers: [Ash.Policy.Authorizer]
+
+ @report_types [:b2c, :b2b_international, :payment]
+ @statuses [:draft, :submitted, :accepted, :rejected]
+
+ postgres do
+ table "e_reports"
+ repo PAEx.Repo
+ end
+
+ attributes do
+ uuid_primary_key :id
+
+ attribute :report_type, :atom do
+ constraints one_of: @report_types
+ allow_nil? false
+ public? true
+ end
+
+ attribute :status, :atom do
+ constraints one_of: @statuses
+ default :draft
+ allow_nil? false
+ public? true
+ end
+
+ # The calendar period this report covers
+ attribute :period_start, :date do
+ allow_nil? false
+ public? true
+ end
+
+ attribute :period_end, :date do
+ allow_nil? false
+ public? true
+ end
+
+ # Aggregated monetary totals (in euro cents)
+ attribute :total_amount_excl_tax, :integer do
+ allow_nil? false
+ default 0
+ public? true
+ description "Total amount excluding taxes for the period, in euro cents."
+ end
+
+ attribute :total_vat_amount, :integer do
+ allow_nil? false
+ default 0
+ public? true
+ description "Total VAT amount for the period, in euro cents."
+ end
+
+ attribute :total_amount_incl_tax, :integer do
+ allow_nil? false
+ default 0
+ public? true
+ description "Total amount including taxes for the period, in euro cents."
+ end
+
+ attribute :transaction_count, :integer do
+ allow_nil? false
+ default 0
+ public? true
+ end
+
+ # PPF submission tracking
+ attribute :ppf_submission_id, :string do
+ allow_nil? true
+ public? true
+ end
+
+ attribute :submitted_at, :utc_datetime_usec do
+ allow_nil? true
+ public? true
+ end
+
+ attribute :ppf_acknowledgement_id, :string do
+ allow_nil? true
+ public? true
+ end
+
+ attribute :rejection_reason, :string do
+ allow_nil? true
+ public? true
+ constraints max_length: 2000
+ end
+
+ create_timestamp :inserted_at
+ update_timestamp :updated_at
+ end
+
+ relationships do
+ belongs_to :company, PAEx.Companies.Company do
+ allow_nil? false
+ public? true
+ end
+
+ has_many :lines, PAEx.EReporting.EReportLine do
+ destination_attribute :e_report_id
+ end
+ end
+
+ validations do
+ validate compare(:period_end, greater_than_or_equal_to: :period_start) do
+ message "period_end must be on or after period_start"
+ end
+
+ validate compare(:total_amount_excl_tax, greater_than_or_equal_to: 0)
+ validate compare(:total_vat_amount, greater_than_or_equal_to: 0)
+ validate compare(:total_amount_incl_tax, greater_than_or_equal_to: 0)
+ validate compare(:transaction_count, greater_than_or_equal_to: 0)
+ end
+
+ actions do
+ defaults [:read, :destroy]
+
+ create :create_draft do
+ description "Create a new draft e-report for a given period."
+ accept [:report_type, :period_start, :period_end, :company_id]
+ end
+
+ update :update_totals do
+ description "Update aggregated totals after lines have been added."
+ accept [
+ :total_amount_excl_tax,
+ :total_vat_amount,
+ :total_amount_incl_tax,
+ :transaction_count
+ ]
+
+ validate attribute_equals(:status, :draft) do
+ message "Only draft reports can have their totals updated"
+ end
+ end
+
+ update :submit do
+ description "Transmit the e-report to the PPF."
+ accept [:ppf_submission_id]
+
+ validate attribute_equals(:status, :draft) do
+ message "Only draft reports can be submitted"
+ end
+
+ change set_attribute(:status, :submitted)
+ change set_attribute(:submitted_at, &DateTime.utc_now/0)
+ end
+
+ update :acknowledge_acceptance do
+ description "Record PPF acceptance acknowledgement."
+ accept [:ppf_acknowledgement_id]
+
+ validate attribute_equals(:status, :submitted) do
+ message "Only submitted reports can be acknowledged"
+ end
+
+ change set_attribute(:status, :accepted)
+ end
+
+ update :reject do
+ description "Record PPF rejection with reason."
+ accept [:rejection_reason]
+
+ validate attribute_equals(:status, :submitted) do
+ message "Only submitted reports can be rejected"
+ end
+
+ change set_attribute(:status, :rejected)
+ end
+
+ update :reopen do
+ description "Reopen a rejected report for correction."
+ accept []
+
+ validate attribute_equals(:status, :rejected) do
+ message "Only rejected reports can be reopened"
+ end
+
+ change set_attribute(:status, :draft)
+ change set_attribute(:rejection_reason, nil)
+ change set_attribute(:ppf_submission_id, nil)
+ change set_attribute(:submitted_at, nil)
+ end
+
+ read :by_company_and_period do
+ description "Fetch reports for a specific company and period."
+ argument :company_id, :uuid, allow_nil?: false
+ argument :period_start, :date, allow_nil?: false
+ argument :period_end, :date, allow_nil?: false
+
+ filter expr(
+ company_id == ^arg(:company_id) and
+ period_start >= ^arg(:period_start) and
+ period_end <= ^arg(:period_end)
+ )
+ end
+ end
+
+ calculations do
+ calculate :total_excl_tax_eur, :decimal, expr(total_amount_excl_tax / 100.0)
+ calculate :total_vat_eur, :decimal, expr(total_vat_amount / 100.0)
+ calculate :total_incl_tax_eur, :decimal, expr(total_amount_incl_tax / 100.0)
+ end
+
+ policies do
+ policy action_type(:read) do
+ authorize_if always()
+ end
+
+ policy action(:create_draft) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ authorize_if actor_attribute_equals(:role, :operator)
+ end
+
+ policy action_type(:update) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ authorize_if actor_attribute_equals(:role, :operator)
+ end
+
+ policy action_type(:destroy) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ end
+ end
+end
diff --git a/lib/pa_ex/e_reporting/resources/e_report_line.ex b/lib/pa_ex/e_reporting/resources/e_report_line.ex
new file mode 100644
index 0000000..95094eb
--- /dev/null
+++ b/lib/pa_ex/e_reporting/resources/e_report_line.ex
@@ -0,0 +1,112 @@
+defmodule PAEx.EReporting.EReportLine do
+ @moduledoc """
+ A single transaction line within an e-reporting submission.
+
+ For B2C and international B2B reports, each line represents one transaction.
+ For payment reports, each line references a previously reported invoice.
+ """
+ use Ash.Resource,
+ otp_app: :pa_ex,
+ domain: PAEx.EReporting,
+ data_layer: AshPostgres.DataLayer
+
+ postgres do
+ table "e_report_lines"
+ repo PAEx.Repo
+ end
+
+ attributes do
+ uuid_primary_key :id
+
+ attribute :transaction_date, :date do
+ allow_nil? false
+ public? true
+ end
+
+ # Free-text reference assigned by the seller (e.g., POS receipt number)
+ attribute :transaction_ref, :string do
+ allow_nil? true
+ public? true
+ constraints max_length: 100
+ end
+
+ attribute :customer_country, :string do
+ allow_nil? true
+ public? true
+ description "ISO 3166-1 alpha-2 country code of the customer (for international reports)."
+ constraints min_length: 2, max_length: 2
+ end
+
+ attribute :customer_vat_number, :string do
+ allow_nil? true
+ public? true
+ description "VAT number of the customer (for international B2B reports)."
+ constraints max_length: 20
+ end
+
+ # Monetary amounts (euro cents)
+ attribute :amount_excl_tax, :integer do
+ allow_nil? false
+ public? true
+ end
+
+ attribute :vat_rate, :decimal do
+ allow_nil? false
+ public? true
+ description "Applicable VAT rate as a percentage."
+ constraints greater_than_or_equal_to: 0, less_than_or_equal_to: 100
+ end
+
+ attribute :vat_amount, :integer do
+ allow_nil? false
+ public? true
+ end
+
+ attribute :amount_incl_tax, :integer do
+ allow_nil? false
+ public? true
+ end
+
+ attribute :currency, :string do
+ allow_nil? false
+ public? true
+ default "EUR"
+ constraints min_length: 3, max_length: 3
+ end
+
+ create_timestamp :inserted_at
+ end
+
+ relationships do
+ belongs_to :e_report, PAEx.EReporting.EReport do
+ allow_nil? false
+ public? true
+ end
+ end
+
+ validations do
+ validate compare(:amount_excl_tax, greater_than_or_equal_to: 0)
+ validate compare(:vat_amount, greater_than_or_equal_to: 0)
+ validate compare(:amount_incl_tax, greater_than_or_equal_to: 0)
+ end
+
+ actions do
+ defaults [:read, :destroy]
+
+ create :add_line do
+ description "Add a transaction line to a draft e-report."
+ accept [
+ :transaction_date,
+ :transaction_ref,
+ :customer_country,
+ :customer_vat_number,
+ :amount_excl_tax,
+ :vat_rate,
+ :vat_amount,
+ :amount_incl_tax,
+ :currency,
+ :e_report_id
+ ]
+ end
+ end
+end
diff --git a/lib/pa_ex/invoices/invoices.ex b/lib/pa_ex/invoices/invoices.ex
new file mode 100644
index 0000000..8c4ab9d
--- /dev/null
+++ b/lib/pa_ex/invoices/invoices.ex
@@ -0,0 +1,41 @@
+defmodule PAEx.Invoices do
+ @moduledoc """
+ The Invoices domain handles the full lifecycle of electronic invoices
+ (factures électroniques) as required by the French e-invoicing reform.
+
+ ## Invoice lifecycle (cycle de vie)
+
+ ```
+ deposee
+ │
+ ├──[validation OK]──► en_cours_de_traitement
+ │ │
+ │ [routing OK]──► en_cours_d_emission
+ │ │
+ │ [delivered]──► emise
+ │ │
+ │ ┌────────────┤
+ │ │ │
+ │ [refused] [accepted]──► mise_en_paiement
+ │ │ │
+ │ refusee comptabilisee
+ │
+ ├──[validation KO]──► rejetee
+ │
+ └──(at any stage before comptabilisee)──► annulee
+ en_litige
+ ```
+
+ ## Supported invoice formats
+ * `factur_x` – Factur-X EN 16931 (PDF/A-3 + embedded XML)
+ * `ubl` – Universal Business Language (ISO/IEC 19845)
+ * `cii` – UN/CEFACT Cross Industry Invoice (ISO 19005)
+ """
+ use Ash.Domain, otp_app: :pa_ex
+
+ resources do
+ resource PAEx.Invoices.Invoice
+ resource PAEx.Invoices.InvoiceLine
+ resource PAEx.Invoices.StatusEvent
+ end
+end
diff --git a/lib/pa_ex/invoices/resources/invoice.ex b/lib/pa_ex/invoices/resources/invoice.ex
new file mode 100644
index 0000000..76e4813
--- /dev/null
+++ b/lib/pa_ex/invoices/resources/invoice.ex
@@ -0,0 +1,393 @@
+defmodule PAEx.Invoices.Invoice do
+ @moduledoc """
+ An electronic invoice (facture électronique) exchanged between two companies
+ through the PAEx platform.
+
+ ## Statuses
+ * `deposee` – submitted to the platform, pending validation
+ * `en_cours_de_traitement`– passed format validation, being processed
+ * `rejetee` – rejected by the platform (format/content error)
+ * `en_cours_d_emission` – routed to recipient's PDP, pending delivery ACK
+ * `emise` – delivered to recipient's platform
+ * `refusee` – refused by the recipient
+ * `acceptee` – accepted by the recipient
+ * `mise_en_paiement` – payment initiated
+ * `comptabilisee` – accounted in recipient's system
+ * `en_litige` – under dispute
+ * `annulee` – cancelled
+
+ ## Formats
+ * `factur_x` – Factur-X (PDF/A-3 + embedded EN 16931 XML)
+ * `ubl` – Universal Business Language
+ * `cii` – Cross Industry Invoice
+ """
+ use Ash.Resource,
+ otp_app: :pa_ex,
+ domain: PAEx.Invoices,
+ data_layer: AshPostgres.DataLayer,
+ authorizers: [Ash.Policy.Authorizer]
+
+ @statuses [
+ :deposee,
+ :en_cours_de_traitement,
+ :rejetee,
+ :en_cours_d_emission,
+ :emise,
+ :refusee,
+ :acceptee,
+ :mise_en_paiement,
+ :comptabilisee,
+ :en_litige,
+ :annulee
+ ]
+
+ @formats [:factur_x, :ubl, :cii]
+
+ @invoice_types [:facture, :avoir, :facture_rectificative]
+
+ postgres do
+ table "invoices"
+ repo PAEx.Repo
+ end
+
+ attributes do
+ uuid_primary_key :id
+
+ # Human-readable invoice number assigned by the emitter
+ attribute :number, :string do
+ allow_nil? false
+ public? true
+ constraints max_length: 50
+ end
+
+ attribute :invoice_type, :atom do
+ constraints one_of: @invoice_types
+ default :facture
+ allow_nil? false
+ public? true
+ end
+
+ attribute :format, :atom do
+ constraints one_of: @formats
+ default :factur_x
+ allow_nil? false
+ public? true
+ end
+
+ attribute :status, :atom do
+ constraints one_of: @statuses
+ default :deposee
+ allow_nil? false
+ public? true
+ end
+
+ attribute :issue_date, :date do
+ allow_nil? false
+ public? true
+ end
+
+ attribute :due_date, :date do
+ allow_nil? true
+ public? true
+ end
+
+ attribute :delivery_date, :date do
+ allow_nil? true
+ public? true
+ end
+
+ # Monetary amounts (in EUR cents to avoid floating-point issues)
+ attribute :amount_excl_tax, :integer do
+ allow_nil? false
+ public? true
+ description "Total amount excluding taxes, in euro cents."
+ end
+
+ attribute :amount_vat, :integer do
+ allow_nil? false
+ public? true
+ description "Total VAT amount, in euro cents."
+ end
+
+ attribute :amount_incl_tax, :integer do
+ allow_nil? false
+ public? true
+ description "Total amount including taxes, in euro cents."
+ end
+
+ attribute :currency, :string do
+ allow_nil? false
+ public? true
+ default "EUR"
+ constraints min_length: 3, max_length: 3
+ end
+
+ # Purchase order reference (if any)
+ attribute :purchase_order_ref, :string do
+ allow_nil? true
+ public? true
+ constraints max_length: 50
+ end
+
+ # Contract reference (if any)
+ attribute :contract_ref, :string do
+ allow_nil? true
+ public? true
+ constraints max_length: 50
+ end
+
+ # Raw invoice payload (XML content for UBL/CII or base64-encoded PDF for Factur-X)
+ attribute :payload, :string do
+ allow_nil? true
+ sensitive? false
+ public? false
+ end
+
+ # Routing metadata
+ attribute :ppf_submission_id, :string do
+ allow_nil? true
+ public? true
+ description "Identifier assigned by the PPF upon successful submission."
+ end
+
+ attribute :rejection_reason, :string do
+ allow_nil? true
+ public? true
+ constraints max_length: 1000
+ end
+
+ create_timestamp :inserted_at
+ update_timestamp :updated_at
+ end
+
+ relationships do
+ belongs_to :emitter, PAEx.Companies.Company do
+ allow_nil? false
+ public? true
+ end
+
+ belongs_to :receiver, PAEx.Companies.Company do
+ allow_nil? false
+ public? true
+ end
+
+ has_many :lines, PAEx.Invoices.InvoiceLine do
+ destination_attribute :invoice_id
+ end
+
+ has_many :status_events, PAEx.Invoices.StatusEvent do
+ destination_attribute :invoice_id
+ end
+ end
+
+ identities do
+ identity :unique_number_per_emitter, [:number, :emitter_id]
+ end
+
+ validations do
+ validate compare(:amount_excl_tax, greater_than_or_equal_to: 0) do
+ message "cannot be negative"
+ end
+
+ validate compare(:amount_vat, greater_than_or_equal_to: 0) do
+ message "cannot be negative"
+ end
+
+ validate compare(:amount_incl_tax, greater_than_or_equal_to: 0) do
+ message "cannot be negative"
+ end
+
+ validate compare(:due_date, greater_than_or_equal_to: :issue_date) do
+ where present(:due_date)
+ message "must be on or after the issue date"
+ end
+ end
+
+ actions do
+ defaults [:read, :destroy]
+
+ create :submit do
+ description "Submit a new invoice to the platform (initial deposit)."
+ accept [
+ :number,
+ :invoice_type,
+ :format,
+ :issue_date,
+ :due_date,
+ :delivery_date,
+ :amount_excl_tax,
+ :amount_vat,
+ :amount_incl_tax,
+ :currency,
+ :purchase_order_ref,
+ :contract_ref,
+ :payload,
+ :emitter_id,
+ :receiver_id
+ ]
+ end
+
+ # --- Lifecycle transitions ---
+
+ update :start_processing do
+ description "Mark the invoice as being processed after format validation."
+ accept []
+
+ validate attribute_equals(:status, :deposee) do
+ message "Invoice must be in 'deposee' status to start processing"
+ end
+
+ change set_attribute(:status, :en_cours_de_traitement)
+ end
+
+ update :reject do
+ description "Reject the invoice due to a validation or format error."
+ accept [:rejection_reason]
+
+ validate attribute_in(:status, [:deposee, :en_cours_de_traitement]) do
+ message "Invoice must be in 'deposee' or 'en_cours_de_traitement' status to be rejected"
+ end
+
+ change set_attribute(:status, :rejetee)
+ end
+
+ update :start_emission do
+ description "Begin routing the invoice to the recipient's platform."
+ accept []
+
+ validate attribute_equals(:status, :en_cours_de_traitement) do
+ message "Invoice must be 'en_cours_de_traitement' to start emission"
+ end
+
+ change set_attribute(:status, :en_cours_d_emission)
+ end
+
+ update :mark_emitted do
+ description "Confirm that the invoice was delivered to the recipient's platform."
+ accept [:ppf_submission_id]
+
+ validate attribute_equals(:status, :en_cours_d_emission) do
+ message "Invoice must be 'en_cours_d_emission' to be marked as emitted"
+ end
+
+ change set_attribute(:status, :emise)
+ end
+
+ update :refuse do
+ description "Record that the recipient refused the invoice."
+ accept [:rejection_reason]
+
+ validate attribute_equals(:status, :emise) do
+ message "Invoice must be 'emise' to be refused"
+ end
+
+ change set_attribute(:status, :refusee)
+ end
+
+ update :accept do
+ description "Record that the recipient accepted the invoice."
+ accept []
+
+ validate attribute_equals(:status, :emise) do
+ message "Invoice must be 'emise' to be accepted"
+ end
+
+ change set_attribute(:status, :acceptee)
+ end
+
+ update :initiate_payment do
+ description "Record that payment has been initiated by the recipient."
+ accept []
+
+ validate attribute_equals(:status, :acceptee) do
+ message "Invoice must be 'acceptee' before payment can be initiated"
+ end
+
+ change set_attribute(:status, :mise_en_paiement)
+ end
+
+ update :mark_accounted do
+ description "Mark the invoice as accounted in the recipient's system."
+ accept []
+
+ validate attribute_equals(:status, :mise_en_paiement) do
+ message "Invoice must be 'mise_en_paiement' before it can be accounted"
+ end
+
+ change set_attribute(:status, :comptabilisee)
+ end
+
+ update :raise_dispute do
+ description "Mark the invoice as under dispute."
+ accept []
+
+ validate attribute_in(:status, [:emise, :acceptee, :mise_en_paiement]) do
+ message "Invoice must be emitted, accepted, or in payment to be disputed"
+ end
+
+ change set_attribute(:status, :en_litige)
+ end
+
+ update :cancel do
+ description "Cancel the invoice. Only allowed before it has been accounted."
+ accept []
+
+ validate attribute_not_in(:status, [:comptabilisee, :annulee]) do
+ message "Invoice cannot be cancelled once it has been accounted"
+ end
+
+ change set_attribute(:status, :annulee)
+ end
+
+ read :by_status do
+ description "List invoices with a given status."
+ argument :status, :atom, allow_nil?: false
+ filter expr(status == ^arg(:status))
+ end
+
+ read :for_emitter do
+ description "List invoices emitted by a given company."
+ argument :emitter_id, :uuid, allow_nil?: false
+ filter expr(emitter_id == ^arg(:emitter_id))
+ end
+
+ read :for_receiver do
+ description "List invoices addressed to a given company."
+ argument :receiver_id, :uuid, allow_nil?: false
+ filter expr(receiver_id == ^arg(:receiver_id))
+ end
+ end
+
+ calculations do
+ calculate :amount_excl_tax_eur, :decimal, expr(amount_excl_tax / 100.0)
+ calculate :amount_vat_eur, :decimal, expr(amount_vat / 100.0)
+ calculate :amount_incl_tax_eur, :decimal, expr(amount_incl_tax / 100.0)
+
+ calculate :overdue?, :boolean,
+ expr(
+ not is_nil(due_date) and due_date < fragment("current_date") and
+ status not in [:comptabilisee, :annulee, :refusee]
+ )
+ end
+
+ policies do
+ policy action_type(:read) do
+ authorize_if always()
+ end
+
+ policy action(:submit) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ authorize_if actor_attribute_equals(:role, :operator)
+ authorize_if actor_attribute_equals(:role, :api_client)
+ end
+
+ policy action_type(:update) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ authorize_if actor_attribute_equals(:role, :operator)
+ end
+
+ policy action_type(:destroy) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ end
+ end
+end
diff --git a/lib/pa_ex/invoices/resources/invoice_line.ex b/lib/pa_ex/invoices/resources/invoice_line.ex
new file mode 100644
index 0000000..8833f2a
--- /dev/null
+++ b/lib/pa_ex/invoices/resources/invoice_line.ex
@@ -0,0 +1,122 @@
+defmodule PAEx.Invoices.InvoiceLine do
+ @moduledoc """
+ A single line item on an electronic invoice.
+
+ Each line captures:
+ * the product or service reference
+ * quantity and unit of measure
+ * unit price (excluding VAT)
+ * applicable VAT rate
+ * computed totals (excl. VAT, VAT amount, incl. VAT)
+
+ All monetary amounts are stored in **euro cents** (integer) to avoid
+ floating-point rounding issues.
+ """
+ use Ash.Resource,
+ otp_app: :pa_ex,
+ domain: PAEx.Invoices,
+ data_layer: AshPostgres.DataLayer
+
+ postgres do
+ table "invoice_lines"
+ repo PAEx.Repo
+ end
+
+ attributes do
+ uuid_primary_key :id
+
+ attribute :line_number, :integer do
+ allow_nil? false
+ public? true
+ description "Position of this line on the invoice (1-based)."
+ end
+
+ attribute :description, :string do
+ allow_nil? false
+ public? true
+ constraints max_length: 500
+ end
+
+ attribute :product_ref, :string do
+ allow_nil? true
+ public? true
+ constraints max_length: 100
+ end
+
+ attribute :quantity, :decimal do
+ allow_nil? false
+ public? true
+ constraints greater_than: 0
+ end
+
+ attribute :unit_of_measure, :string do
+ allow_nil? false
+ public? true
+ default "EA"
+ description "UN/ECE Recommendation 20 unit code (e.g. EA = each, KGM = kilogram)."
+ constraints max_length: 10
+ end
+
+ # Stored as integer (euro cents)
+ attribute :unit_price, :integer do
+ allow_nil? false
+ public? true
+ description "Net unit price excluding VAT, in euro cents."
+ end
+
+ # VAT rate as a percentage stored as decimal (e.g. 20.0, 10.0, 5.5, 0.0)
+ attribute :vat_rate, :decimal do
+ allow_nil? false
+ public? true
+ description "VAT rate as a percentage (e.g. 20.0 for 20%)."
+ constraints greater_than_or_equal_to: 0, less_than_or_equal_to: 100
+ end
+
+ # Computed totals (euro cents)
+ attribute :line_amount_excl_tax, :integer do
+ allow_nil? false
+ public? true
+ description "quantity × unit_price, in euro cents."
+ end
+
+ attribute :line_vat_amount, :integer do
+ allow_nil? false
+ public? true
+ description "VAT amount for this line, in euro cents."
+ end
+
+ attribute :line_amount_incl_tax, :integer do
+ allow_nil? false
+ public? true
+ description "line_amount_excl_tax + line_vat_amount, in euro cents."
+ end
+
+ create_timestamp :inserted_at
+ update_timestamp :updated_at
+ end
+
+ relationships do
+ belongs_to :invoice, PAEx.Invoices.Invoice do
+ allow_nil? false
+ public? true
+ end
+ end
+
+ validations do
+ validate compare(:unit_price, greater_than_or_equal_to: 0) do
+ message "cannot be negative"
+ end
+
+ validate compare(:line_amount_excl_tax, greater_than_or_equal_to: 0) do
+ message "cannot be negative"
+ end
+
+ validate compare(:line_vat_amount, greater_than_or_equal_to: 0) do
+ message "cannot be negative"
+ end
+ end
+
+ actions do
+ defaults [:read, :create, :update, :destroy]
+ end
+end
diff --git a/lib/pa_ex/invoices/resources/status_event.ex b/lib/pa_ex/invoices/resources/status_event.ex
new file mode 100644
index 0000000..0557361
--- /dev/null
+++ b/lib/pa_ex/invoices/resources/status_event.ex
@@ -0,0 +1,77 @@
+defmodule PAEx.Invoices.StatusEvent do
+ @moduledoc """
+ An immutable audit-trail entry recording every status change in an invoice's
+ lifecycle.
+
+ Each event captures:
+ * the previous and new status
+ * when the transition occurred
+ * who or what triggered it (actor, PPF callback, etc.)
+ * an optional human-readable note
+ """
+ use Ash.Resource,
+ otp_app: :pa_ex,
+ domain: PAEx.Invoices,
+ data_layer: AshPostgres.DataLayer
+
+ postgres do
+ table "invoice_status_events"
+ repo PAEx.Repo
+ end
+
+ attributes do
+ uuid_primary_key :id
+
+ attribute :from_status, :atom do
+ allow_nil? true
+ public? true
+ end
+
+ attribute :to_status, :atom do
+ allow_nil? false
+ public? true
+ end
+
+ attribute :triggered_by, :string do
+ allow_nil? true
+ public? true
+ description "Actor or system that triggered this transition (user id, 'ppf_callback', etc.)."
+ end
+
+ attribute :note, :string do
+ allow_nil? true
+ public? true
+ constraints max_length: 1000
+ end
+
+ create_timestamp :occurred_at
+ end
+
+ relationships do
+ belongs_to :invoice, PAEx.Invoices.Invoice do
+ allow_nil? false
+ public? true
+ end
+ end
+
+ actions do
+ defaults [:read]
+
+ create :record do
+ description "Record a new status transition event."
+ accept [:from_status, :to_status, :triggered_by, :note, :invoice_id]
+ end
+ end
+
+ policies do
+ policy action_type(:read) do
+ authorize_if always()
+ end
+
+ policy action(:record) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ authorize_if actor_attribute_equals(:role, :operator)
+ authorize_if actor_attribute_equals(:role, :api_client)
+ end
+ end
+end
diff --git a/lib/pa_ex/repo.ex b/lib/pa_ex/repo.ex
new file mode 100644
index 0000000..7a2373d
--- /dev/null
+++ b/lib/pa_ex/repo.ex
@@ -0,0 +1,11 @@
+defmodule PAEx.Repo do
+ use AshPostgres.Repo, otp_app: :pa_ex
+
+ def installed_extensions do
+ ["uuid-ossp", "citext"]
+ end
+
+ def min_pg_version do
+ %Version{major: 14, minor: 0, patch: 0}
+ end
+end
diff --git a/lib/pa_ex/transmission/resources/transmission_event.ex b/lib/pa_ex/transmission/resources/transmission_event.ex
new file mode 100644
index 0000000..398d150
--- /dev/null
+++ b/lib/pa_ex/transmission/resources/transmission_event.ex
@@ -0,0 +1,262 @@
+defmodule PAEx.Transmission.TransmissionEvent do
+ @moduledoc """
+ An immutable record of a single message exchanged with an external system.
+
+ ## Directions
+ * `:outbound` – PAEx sending a message to an external party
+ * `:inbound` – PAEx receiving a message from an external party
+
+ ## Counterparty types
+ * `:ppf` – the French Public Invoicing Portal (PPF / Chorus Pro)
+ * `:pdp` – another accredited Partner Dematerialization Platform
+ * `:erp` – a customer / supplier ERP system
+
+ ## Event types
+ * `:invoice_submission` – submitting an invoice to the PPF or PDP
+ * `:invoice_status_update` – sending or receiving a lifecycle status update
+ * `:e_report_submission` – submitting a periodic e-reporting payload
+ * `:e_report_acknowledgement` – receiving an acknowledgement from the PPF
+ * `:directory_lookup` – querying the PPF directory for a company's PDP
+ * `:ppf_webhook` – inbound webhook notification from the PPF
+
+ ## Statuses
+ * `:pending` – queued but not yet sent
+ * `:sent` – sent, awaiting acknowledgement
+ * `:acked` – acknowledged (success)
+ * `:failed` – send error (will be retried)
+ * `:received` – successfully received and parsed (inbound messages)
+ """
+ use Ash.Resource,
+ otp_app: :pa_ex,
+ domain: PAEx.Transmission,
+ data_layer: AshPostgres.DataLayer,
+ authorizers: [Ash.Policy.Authorizer]
+
+ @directions [:outbound, :inbound]
+ @counterparty_types [:ppf, :pdp, :erp]
+ @event_types [
+ :invoice_submission,
+ :invoice_status_update,
+ :e_report_submission,
+ :e_report_acknowledgement,
+ :directory_lookup,
+ :ppf_webhook
+ ]
+ @statuses [:pending, :sent, :acked, :failed, :received]
+
+ postgres do
+ table "transmission_events"
+ repo PAEx.Repo
+ end
+
+ attributes do
+ uuid_primary_key :id
+
+ attribute :direction, :atom do
+ constraints one_of: @directions
+ allow_nil? false
+ public? true
+ end
+
+ attribute :counterparty_type, :atom do
+ constraints one_of: @counterparty_types
+ allow_nil? false
+ public? true
+ end
+
+ attribute :counterparty_id, :string do
+ allow_nil? true
+ public? true
+ description "SIREN, PDP identifier, or URL of the remote party."
+ constraints max_length: 200
+ end
+
+ attribute :event_type, :atom do
+ constraints one_of: @event_types
+ allow_nil? false
+ public? true
+ end
+
+ attribute :status, :atom do
+ constraints one_of: @statuses
+ default :pending
+ allow_nil? false
+ public? true
+ end
+
+ # Reference to the business object being transmitted
+ attribute :reference_id, :uuid do
+ allow_nil? true
+ public? true
+ description "UUID of the Invoice, EReport, or other entity being transmitted."
+ end
+
+ attribute :reference_type, :string do
+ allow_nil? true
+ public? true
+ description "Module name of the referenced entity (e.g. 'PAEx.Invoices.Invoice')."
+ constraints max_length: 100
+ end
+
+ attribute :payload_summary, :string do
+ allow_nil? true
+ public? true
+ description "Non-sensitive summary of the transmitted payload for debugging."
+ constraints max_length: 500
+ end
+
+ attribute :http_status_code, :integer do
+ allow_nil? true
+ public? true
+ end
+
+ attribute :error_message, :string do
+ allow_nil? true
+ public? true
+ constraints max_length: 2000
+ end
+
+ attribute :retry_count, :integer do
+ allow_nil? false
+ default 0
+ public? true
+ end
+
+ attribute :sent_at, :utc_datetime_usec do
+ allow_nil? true
+ public? true
+ end
+
+ attribute :acked_at, :utc_datetime_usec do
+ allow_nil? true
+ public? true
+ end
+
+ create_timestamp :inserted_at
+ update_timestamp :updated_at
+ end
+
+ validations do
+ validate compare(:retry_count, greater_than_or_equal_to: 0)
+ end
+
+ actions do
+ defaults [:read]
+
+ create :record_outbound do
+ description "Create a pending outbound transmission event."
+ accept [
+ :counterparty_type,
+ :counterparty_id,
+ :event_type,
+ :reference_id,
+ :reference_type,
+ :payload_summary
+ ]
+
+ change set_attribute(:direction, :outbound)
+ end
+
+ create :record_inbound do
+ description "Record an inbound transmission event as received."
+ accept [
+ :counterparty_type,
+ :counterparty_id,
+ :event_type,
+ :reference_id,
+ :reference_type,
+ :payload_summary,
+ :http_status_code
+ ]
+
+ change set_attribute(:direction, :inbound)
+ change set_attribute(:status, :received)
+ end
+
+ update :mark_sent do
+ description "Mark a pending outbound event as sent."
+ accept [:http_status_code]
+
+ validate attribute_equals(:status, :pending) do
+ message "Only pending events can be marked as sent"
+ end
+
+ change set_attribute(:status, :sent)
+ change set_attribute(:sent_at, &DateTime.utc_now/0)
+ end
+
+ update :acknowledge do
+ description "Record a successful acknowledgement from the remote party."
+ accept [:http_status_code, :payload_summary]
+
+ validate attribute_equals(:status, :sent) do
+ message "Only sent events can be acknowledged"
+ end
+
+ change set_attribute(:status, :acked)
+ change set_attribute(:acked_at, &DateTime.utc_now/0)
+ end
+
+ update :mark_failed do
+ description "Record a transmission failure; increments retry_count."
+ accept [:error_message, :http_status_code]
+
+ validate attribute_in(:status, [:pending, :sent]) do
+ message "Only pending or sent events can be marked as failed"
+ end
+
+ change set_attribute(:status, :failed)
+ change increment(:retry_count)
+ end
+
+ update :retry do
+ description "Reset a failed event back to pending for retry."
+ accept []
+
+ validate attribute_equals(:status, :failed) do
+ message "Only failed events can be retried"
+ end
+
+ change set_attribute(:status, :pending)
+ end
+
+ read :pending_outbound do
+ description "List all outbound events that have not yet been sent."
+ filter expr(direction == :outbound and status == :pending)
+ end
+
+ read :failed_events do
+ description "List all events that have failed (for operational monitoring)."
+ filter expr(status == :failed)
+ end
+
+ read :for_reference do
+ description "List all transmission events for a given business object."
+ argument :reference_id, :uuid, allow_nil?: false
+ filter expr(reference_id == ^arg(:reference_id))
+ end
+ end
+
+ policies do
+ policy action_type(:read) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ authorize_if actor_attribute_equals(:role, :operator)
+ end
+
+ policy action(:record_outbound) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ authorize_if actor_attribute_equals(:role, :operator)
+ authorize_if actor_attribute_equals(:role, :api_client)
+ end
+
+ policy action(:record_inbound) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ authorize_if actor_attribute_equals(:role, :api_client)
+ end
+
+ policy action_type(:update) do
+ authorize_if actor_attribute_equals(:role, :admin)
+ authorize_if actor_attribute_equals(:role, :operator)
+ end
+ end
+end
diff --git a/lib/pa_ex/transmission/transmission.ex b/lib/pa_ex/transmission/transmission.ex
new file mode 100644
index 0000000..9331600
--- /dev/null
+++ b/lib/pa_ex/transmission/transmission.ex
@@ -0,0 +1,15 @@
+defmodule PAEx.Transmission do
+ @moduledoc """
+ The Transmission domain tracks every message exchange between PAEx and
+ external parties: the PPF (Portail Public de Facturation), other PDPs
+ (Plateformes de Dématérialisation Partenaires), and customer ERPs.
+
+ Each `TransmissionEvent` is an immutable record of a single message sent or
+ received, providing a full audit trail for compliance purposes.
+ """
+ use Ash.Domain, otp_app: :pa_ex
+
+ resources do
+ resource PAEx.Transmission.TransmissionEvent
+ end
+end
diff --git a/lib/pa_ex_web/components/layouts.ex b/lib/pa_ex_web/components/layouts.ex
new file mode 100644
index 0000000..cbc77a1
--- /dev/null
+++ b/lib/pa_ex_web/components/layouts.ex
@@ -0,0 +1,6 @@
+defmodule PAExWeb.Layouts do
+ @moduledoc false
+ use PAExWeb, :html
+
+ embed_templates "layouts/*"
+end
diff --git a/lib/pa_ex_web/components/layouts/app.html.heex b/lib/pa_ex_web/components/layouts/app.html.heex
new file mode 100644
index 0000000..16e1bf8
--- /dev/null
+++ b/lib/pa_ex_web/components/layouts/app.html.heex
@@ -0,0 +1,11 @@
+
+
+ <%= @inner_content %>
+
diff --git a/lib/pa_ex_web/components/layouts/root.html.heex b/lib/pa_ex_web/components/layouts/root.html.heex
new file mode 100644
index 0000000..e10fa01
--- /dev/null
+++ b/lib/pa_ex_web/components/layouts/root.html.heex
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ PAEx – Plateforme Agréée Elixir
+
+
+ <%= @inner_content %>
+
+
diff --git a/lib/pa_ex_web/controllers/company_controller.ex b/lib/pa_ex_web/controllers/company_controller.ex
new file mode 100644
index 0000000..d6b48d5
--- /dev/null
+++ b/lib/pa_ex_web/controllers/company_controller.ex
@@ -0,0 +1,61 @@
+defmodule PAExWeb.CompanyController do
+ @moduledoc "REST controller for Company resources."
+ use PAExWeb, :controller
+
+ alias PAEx.Companies
+ alias PAEx.Companies.Company
+
+ action_fallback PAExWeb.FallbackController
+
+ def index(conn, _params) do
+ companies =
+ Company
+ |> Ash.Query.new()
+ |> Ash.read!(domain: Companies)
+
+ json(conn, %{data: Enum.map(companies, &company_json/1)})
+ end
+
+ def show(conn, %{"id" => id}) do
+ company =
+ Company
+ |> Ash.get!(id, domain: Companies)
+
+ json(conn, %{data: company_json(company)})
+ end
+
+ def create(conn, params) do
+ with {:ok, company} <-
+ Ash.create(Company, params, action: :register, domain: Companies) do
+ conn
+ |> put_status(:created)
+ |> json(%{data: company_json(company)})
+ end
+ end
+
+ def update(conn, %{"id" => id} = params) do
+ company = Ash.get!(Company, id, domain: Companies)
+
+ with {:ok, updated} <-
+ Ash.update(company, params, action: :update_details, domain: Companies) do
+ json(conn, %{data: company_json(updated)})
+ end
+ end
+
+ defp company_json(%Company{} = c) do
+ %{
+ id: c.id,
+ name: c.name,
+ siren: c.siren,
+ siret: c.siret,
+ vat_number: c.vat_number,
+ address_street: c.address_street,
+ address_city: c.address_city,
+ address_postal_code: c.address_postal_code,
+ address_country: c.address_country,
+ status: c.status,
+ role: c.role,
+ ppf_routing_id: c.ppf_routing_id
+ }
+ end
+end
diff --git a/lib/pa_ex_web/controllers/e_report_controller.ex b/lib/pa_ex_web/controllers/e_report_controller.ex
new file mode 100644
index 0000000..63c9184
--- /dev/null
+++ b/lib/pa_ex_web/controllers/e_report_controller.ex
@@ -0,0 +1,83 @@
+defmodule PAExWeb.EReportController do
+ @moduledoc "REST controller for EReport resources."
+ use PAExWeb, :controller
+
+ alias PAEx.EReporting
+ alias PAEx.EReporting.EReport
+
+ action_fallback PAExWeb.FallbackController
+
+ def index(conn, _params) do
+ reports = Ash.read!(EReport, domain: EReporting)
+ json(conn, %{data: Enum.map(reports, &report_json/1)})
+ end
+
+ def show(conn, %{"id" => id}) do
+ report = Ash.get!(EReport, id, domain: EReporting)
+ json(conn, %{data: report_json(report)})
+ end
+
+ def create(conn, params) do
+ with {:ok, report} <-
+ Ash.create(EReport, params, action: :create_draft, domain: EReporting) do
+ conn
+ |> put_status(:created)
+ |> json(%{data: report_json(report)})
+ end
+ end
+
+ def submit(conn, %{"id" => id} = params) do
+ report = Ash.get!(EReport, id, domain: EReporting)
+
+ with {:ok, updated} <-
+ Ash.update(report, Map.take(params, ["ppf_submission_id"]),
+ action: :submit,
+ domain: EReporting
+ ) do
+ json(conn, %{data: report_json(updated)})
+ end
+ end
+
+ def acknowledge(conn, %{"id" => id} = params) do
+ report = Ash.get!(EReport, id, domain: EReporting)
+
+ with {:ok, updated} <-
+ Ash.update(report, Map.take(params, ["ppf_acknowledgement_id"]),
+ action: :acknowledge_acceptance,
+ domain: EReporting
+ ) do
+ json(conn, %{data: report_json(updated)})
+ end
+ end
+
+ def reject(conn, %{"id" => id} = params) do
+ report = Ash.get!(EReport, id, domain: EReporting)
+
+ with {:ok, updated} <-
+ Ash.update(report, Map.take(params, ["rejection_reason"]),
+ action: :reject,
+ domain: EReporting
+ ) do
+ json(conn, %{data: report_json(updated)})
+ end
+ end
+
+ defp report_json(%EReport{} = r) do
+ %{
+ id: r.id,
+ company_id: r.company_id,
+ report_type: r.report_type,
+ status: r.status,
+ period_start: r.period_start,
+ period_end: r.period_end,
+ total_amount_excl_tax: r.total_amount_excl_tax,
+ total_vat_amount: r.total_vat_amount,
+ total_amount_incl_tax: r.total_amount_incl_tax,
+ transaction_count: r.transaction_count,
+ ppf_submission_id: r.ppf_submission_id,
+ submitted_at: r.submitted_at,
+ ppf_acknowledgement_id: r.ppf_acknowledgement_id,
+ rejection_reason: r.rejection_reason
+ }
+ end
+end
diff --git a/lib/pa_ex_web/controllers/error_html.ex b/lib/pa_ex_web/controllers/error_html.ex
new file mode 100644
index 0000000..bf306b4
--- /dev/null
+++ b/lib/pa_ex_web/controllers/error_html.ex
@@ -0,0 +1,8 @@
+defmodule PAExWeb.ErrorHTML do
+ @moduledoc false
+ use PAExWeb, :html
+
+ def render(template, _assigns) do
+ Phoenix.Controller.status_message_from_template(template)
+ end
+end
diff --git a/lib/pa_ex_web/controllers/error_json.ex b/lib/pa_ex_web/controllers/error_json.ex
new file mode 100644
index 0000000..d56d8e5
--- /dev/null
+++ b/lib/pa_ex_web/controllers/error_json.ex
@@ -0,0 +1,11 @@
+defmodule PAExWeb.ErrorJSON do
+ @moduledoc false
+
+ def render("404.json", _assigns) do
+ %{errors: [%{status: "404", title: "Not Found"}]}
+ end
+
+ def render("500.json", _assigns) do
+ %{errors: [%{status: "500", title: "Internal Server Error"}]}
+ end
+end
diff --git a/lib/pa_ex_web/controllers/fallback_controller.ex b/lib/pa_ex_web/controllers/fallback_controller.ex
new file mode 100644
index 0000000..dc95993
--- /dev/null
+++ b/lib/pa_ex_web/controllers/fallback_controller.ex
@@ -0,0 +1,47 @@
+defmodule PAExWeb.FallbackController do
+ @moduledoc """
+ Translates Ash errors and Ecto changesets into HTTP error responses.
+ """
+ use PAExWeb, :controller
+
+ def call(conn, {:error, %Ash.Error.Query.NotFound{}}) do
+ conn
+ |> put_status(:not_found)
+ |> json(%{errors: [%{status: "404", title: "Not Found"}]})
+ end
+
+ def call(conn, {:error, %Ash.Error.Invalid{} = error}) do
+ errors =
+ error.errors
+ |> List.wrap()
+ |> Enum.map(&format_error/1)
+
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{errors: errors})
+ end
+
+ def call(conn, {:error, %Ash.Error.Forbidden{}}) do
+ conn
+ |> put_status(:forbidden)
+ |> json(%{errors: [%{status: "403", title: "Forbidden"}]})
+ end
+
+ def call(conn, {:error, reason}) do
+ conn
+ |> put_status(:internal_server_error)
+ |> json(%{errors: [%{status: "500", title: "Internal Server Error", detail: inspect(reason)}]})
+ end
+
+ defp format_error(%{message: message, field: field}) when not is_nil(field) do
+ %{status: "422", title: "Validation Error", source: %{pointer: "/data/attributes/#{field}"}, detail: message}
+ end
+
+ defp format_error(%{message: message}) do
+ %{status: "422", title: "Validation Error", detail: message}
+ end
+
+ defp format_error(error) do
+ %{status: "422", title: "Validation Error", detail: inspect(error)}
+ end
+end
diff --git a/lib/pa_ex_web/controllers/invoice_controller.ex b/lib/pa_ex_web/controllers/invoice_controller.ex
new file mode 100644
index 0000000..c305786
--- /dev/null
+++ b/lib/pa_ex_web/controllers/invoice_controller.ex
@@ -0,0 +1,114 @@
+defmodule PAExWeb.InvoiceController do
+ @moduledoc "REST controller for Invoice resources and lifecycle transitions."
+ use PAExWeb, :controller
+
+ alias PAEx.Invoices
+ alias PAEx.Invoices.Invoice
+
+ action_fallback PAExWeb.FallbackController
+
+ def index(conn, params) do
+ query =
+ Invoice
+ |> Ash.Query.new()
+ |> maybe_filter_by_status(params)
+
+ invoices = Ash.read!(query, domain: Invoices)
+ json(conn, %{data: Enum.map(invoices, &invoice_json/1)})
+ end
+
+ def show(conn, %{"id" => id}) do
+ invoice = Ash.get!(Invoice, id, domain: Invoices)
+ json(conn, %{data: invoice_json(invoice)})
+ end
+
+ def create(conn, params) do
+ with {:ok, invoice} <-
+ Ash.create(Invoice, params, action: :submit, domain: Invoices) do
+ conn
+ |> put_status(:created)
+ |> json(%{data: invoice_json(invoice)})
+ end
+ end
+
+ # Lifecycle transition helpers
+
+ def start_processing(conn, %{"id" => id}) do
+ lifecycle_action(conn, id, :start_processing, %{})
+ end
+
+ def reject(conn, %{"id" => id} = params) do
+ lifecycle_action(conn, id, :reject, Map.take(params, ["rejection_reason"]))
+ end
+
+ def start_emission(conn, %{"id" => id}) do
+ lifecycle_action(conn, id, :start_emission, %{})
+ end
+
+ def mark_emitted(conn, %{"id" => id} = params) do
+ lifecycle_action(conn, id, :mark_emitted, Map.take(params, ["ppf_submission_id"]))
+ end
+
+ def refuse(conn, %{"id" => id} = params) do
+ lifecycle_action(conn, id, :refuse, Map.take(params, ["rejection_reason"]))
+ end
+
+ def accept(conn, %{"id" => id}) do
+ lifecycle_action(conn, id, :accept, %{})
+ end
+
+ def initiate_payment(conn, %{"id" => id}) do
+ lifecycle_action(conn, id, :initiate_payment, %{})
+ end
+
+ def mark_accounted(conn, %{"id" => id}) do
+ lifecycle_action(conn, id, :mark_accounted, %{})
+ end
+
+ def raise_dispute(conn, %{"id" => id}) do
+ lifecycle_action(conn, id, :raise_dispute, %{})
+ end
+
+ def cancel(conn, %{"id" => id}) do
+ lifecycle_action(conn, id, :cancel, %{})
+ end
+
+ # ── Private helpers ──────────────────────────────────────────────────────────
+
+ defp lifecycle_action(conn, id, action, params) do
+ invoice = Ash.get!(Invoice, id, domain: Invoices)
+
+ with {:ok, updated} <- Ash.update(invoice, params, action: action, domain: Invoices) do
+ json(conn, %{data: invoice_json(updated)})
+ end
+ end
+
+ defp maybe_filter_by_status(query, %{"status" => status}) do
+ Ash.Query.filter(query, status == ^String.to_existing_atom(status))
+ end
+
+ defp maybe_filter_by_status(query, _params), do: query
+
+ defp invoice_json(%Invoice{} = inv) do
+ %{
+ id: inv.id,
+ number: inv.number,
+ invoice_type: inv.invoice_type,
+ format: inv.format,
+ status: inv.status,
+ emitter_id: inv.emitter_id,
+ receiver_id: inv.receiver_id,
+ issue_date: inv.issue_date,
+ due_date: inv.due_date,
+ delivery_date: inv.delivery_date,
+ amount_excl_tax: inv.amount_excl_tax,
+ amount_vat: inv.amount_vat,
+ amount_incl_tax: inv.amount_incl_tax,
+ currency: inv.currency,
+ purchase_order_ref: inv.purchase_order_ref,
+ contract_ref: inv.contract_ref,
+ ppf_submission_id: inv.ppf_submission_id,
+ rejection_reason: inv.rejection_reason
+ }
+ end
+end
diff --git a/lib/pa_ex_web/controllers/transmission_event_controller.ex b/lib/pa_ex_web/controllers/transmission_event_controller.ex
new file mode 100644
index 0000000..56168b4
--- /dev/null
+++ b/lib/pa_ex_web/controllers/transmission_event_controller.ex
@@ -0,0 +1,39 @@
+defmodule PAExWeb.TransmissionEventController do
+ @moduledoc "Read-only controller for TransmissionEvent audit records."
+ use PAExWeb, :controller
+
+ alias PAEx.Transmission
+ alias PAEx.Transmission.TransmissionEvent
+
+ action_fallback PAExWeb.FallbackController
+
+ def index(conn, _params) do
+ events = Ash.read!(TransmissionEvent, domain: Transmission)
+ json(conn, %{data: Enum.map(events, &event_json/1)})
+ end
+
+ def show(conn, %{"id" => id}) do
+ event = Ash.get!(TransmissionEvent, id, domain: Transmission)
+ json(conn, %{data: event_json(event)})
+ end
+
+ defp event_json(%TransmissionEvent{} = e) do
+ %{
+ id: e.id,
+ direction: e.direction,
+ counterparty_type: e.counterparty_type,
+ counterparty_id: e.counterparty_id,
+ event_type: e.event_type,
+ status: e.status,
+ reference_id: e.reference_id,
+ reference_type: e.reference_type,
+ payload_summary: e.payload_summary,
+ http_status_code: e.http_status_code,
+ error_message: e.error_message,
+ retry_count: e.retry_count,
+ sent_at: e.sent_at,
+ acked_at: e.acked_at,
+ inserted_at: e.inserted_at
+ }
+ end
+end
diff --git a/lib/pa_ex_web/core_components.ex b/lib/pa_ex_web/core_components.ex
new file mode 100644
index 0000000..fec3b14
--- /dev/null
+++ b/lib/pa_ex_web/core_components.ex
@@ -0,0 +1,6 @@
+defmodule PAExWeb.CoreComponents do
+ @moduledoc """
+ Provides core UI components used throughout PAExWeb views and templates.
+ """
+ use Phoenix.Component
+end
diff --git a/lib/pa_ex_web/endpoint.ex b/lib/pa_ex_web/endpoint.ex
new file mode 100644
index 0000000..5115ab3
--- /dev/null
+++ b/lib/pa_ex_web/endpoint.ex
@@ -0,0 +1,40 @@
+defmodule PAExWeb.Endpoint do
+ use Phoenix.Endpoint, otp_app: :pa_ex
+
+ socket "/live", Phoenix.LiveView.Socket,
+ websocket: [connect_info: [session: {__MODULE__, :session_options, []}]],
+ longpoll: false
+
+ plug Plug.Static,
+ at: "/",
+ from: :pa_ex,
+ gzip: false,
+ only: PAExWeb.static_paths()
+
+ if code_reloading? do
+ plug Phoenix.CodeReloader
+ plug Phoenix.Ecto.CheckRepoStatus, otp_app: :pa_ex
+ end
+
+ plug Plug.RequestId
+ plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
+
+ plug Plug.Parsers,
+ parsers: [:urlencoded, :multipart, :json],
+ pass: ["*/*"],
+ json_decoder: Phoenix.json_library()
+
+ plug Plug.MethodOverride
+ plug Plug.Head
+ plug Plug.Session, session_options()
+ plug PAExWeb.Router
+
+ def session_options do
+ [
+ store: :cookie,
+ key: "_pa_ex_key",
+ signing_salt: "pa_ex_signing_salt",
+ same_site: "Lax"
+ ]
+ end
+end
diff --git a/lib/pa_ex_web/live/company_live.ex b/lib/pa_ex_web/live/company_live.ex
new file mode 100644
index 0000000..26cacef
--- /dev/null
+++ b/lib/pa_ex_web/live/company_live.ex
@@ -0,0 +1,44 @@
+defmodule PAExWeb.CompanyLive.Index do
+ @moduledoc "LiveView for listing registered companies."
+ use PAExWeb, :live_view
+
+ alias PAEx.Companies
+ alias PAEx.Companies.Company
+
+ @impl true
+ def mount(_params, _session, socket) do
+ companies = Ash.read!(Company, domain: Companies)
+ {:ok, assign(socket, companies: companies)}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
Entreprises
+
+
+
+ | Nom |
+ SIREN |
+ SIRET |
+ Statut |
+ Rôle |
+
+
+
+ <%= for c <- @companies do %>
+
+ | {c.name} |
+ {c.siren} |
+ {c.siret} |
+ {c.status} |
+ {c.role} |
+
+ <% end %>
+
+
+
+ """
+ end
+end
diff --git a/lib/pa_ex_web/live/dashboard_live.ex b/lib/pa_ex_web/live/dashboard_live.ex
new file mode 100644
index 0000000..5ed48b1
--- /dev/null
+++ b/lib/pa_ex_web/live/dashboard_live.ex
@@ -0,0 +1,24 @@
+defmodule PAExWeb.DashboardLive do
+ @moduledoc "Platform dashboard showing key metrics."
+ use PAExWeb, :live_view
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok, socket}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
PAEx — Plateforme Agréée Elixir
+
Tableau de bord de la plateforme de facturation électronique.
+
+
+ """
+ end
+end
diff --git a/lib/pa_ex_web/live/e_report_live.ex b/lib/pa_ex_web/live/e_report_live.ex
new file mode 100644
index 0000000..471dcda
--- /dev/null
+++ b/lib/pa_ex_web/live/e_report_live.ex
@@ -0,0 +1,46 @@
+defmodule PAExWeb.EReportLive.Index do
+ @moduledoc "LiveView for listing e-reports."
+ use PAExWeb, :live_view
+
+ alias PAEx.EReporting
+ alias PAEx.EReporting.EReport
+
+ @impl true
+ def mount(_params, _session, socket) do
+ reports = Ash.read!(EReport, domain: EReporting)
+ {:ok, assign(socket, reports: reports)}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
E-Reporting
+
+
+
+ | Type |
+ Statut |
+ Période début |
+ Période fin |
+ Transactions |
+ Montant TTC |
+
+
+
+ <%= for r <- @reports do %>
+
+ | {r.report_type} |
+ {r.status} |
+ {r.period_start} |
+ {r.period_end} |
+ {r.transaction_count} |
+ {r.total_amount_incl_tax} cts |
+
+ <% end %>
+
+
+
+ """
+ end
+end
diff --git a/lib/pa_ex_web/live/invoice_live.ex b/lib/pa_ex_web/live/invoice_live.ex
new file mode 100644
index 0000000..c2adb31
--- /dev/null
+++ b/lib/pa_ex_web/live/invoice_live.ex
@@ -0,0 +1,78 @@
+defmodule PAExWeb.InvoiceLive.Index do
+ @moduledoc "LiveView for listing and filtering invoices."
+ use PAExWeb, :live_view
+
+ alias PAEx.Invoices
+ alias PAEx.Invoices.Invoice
+
+ @impl true
+ def mount(_params, _session, socket) do
+ invoices = Ash.read!(Invoice, domain: Invoices)
+ {:ok, assign(socket, invoices: invoices)}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
Factures électroniques
+
+
+
+ | Numéro |
+ Type |
+ Format |
+ Statut |
+ Date d'émission |
+ Montant TTC |
+
+
+
+ <%= for inv <- @invoices do %>
+
+ | {inv.number} |
+ {inv.invoice_type} |
+ {inv.format} |
+ {inv.status} |
+ {inv.issue_date} |
+ {inv.amount_incl_tax} cts |
+
+ <% end %>
+
+
+
+ """
+ end
+end
+
+defmodule PAExWeb.InvoiceLive.Show do
+ @moduledoc "LiveView for showing a single invoice."
+ use PAExWeb, :live_view
+
+ alias PAEx.Invoices
+ alias PAEx.Invoices.Invoice
+
+ @impl true
+ def mount(%{"id" => id}, _session, socket) do
+ invoice = Ash.get!(Invoice, id, domain: Invoices)
+ {:ok, assign(socket, invoice: invoice)}
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
Facture {assigns.invoice.number}
+
+ - Statut
- {@invoice.status}
+ - Format
- {@invoice.format}
+ - Date d'émission
- {@invoice.issue_date}
+ - Montant HT
- {@invoice.amount_excl_tax} cts
+ - TVA
- {@invoice.amount_vat} cts
+ - Montant TTC
- {@invoice.amount_incl_tax} cts
+
+
← Retour
+
+ """
+ end
+end
diff --git a/lib/pa_ex_web/pa_ex_web.ex b/lib/pa_ex_web/pa_ex_web.ex
new file mode 100644
index 0000000..13eb5b5
--- /dev/null
+++ b/lib/pa_ex_web/pa_ex_web.ex
@@ -0,0 +1,77 @@
+defmodule PAExWeb do
+ @moduledoc """
+ Entry-point helpers for the PAExWeb namespace (controllers, views, etc.).
+ """
+
+ def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
+
+ def router do
+ quote do
+ use Phoenix.Router, helpers: false
+ import Plug.Conn
+ import Phoenix.Controller
+ import Phoenix.LiveView.Router
+ end
+ end
+
+ def channel do
+ quote do
+ use Phoenix.Channel
+ end
+ end
+
+ def controller do
+ quote do
+ use Phoenix.Controller,
+ formats: [:html, :json],
+ layouts: [html: PAExWeb.Layouts]
+
+ import Plug.Conn
+ unquote(verified_routes())
+ end
+ end
+
+ def live_view do
+ quote do
+ use Phoenix.LiveView, layout: {PAExWeb.Layouts, :app}
+ unquote(html_helpers())
+ end
+ end
+
+ def live_component do
+ quote do
+ use Phoenix.LiveComponent
+ unquote(html_helpers())
+ end
+ end
+
+ def html do
+ quote do
+ use Phoenix.Component
+ import Phoenix.Controller, only: [get_csrf_token: 0]
+ unquote(html_helpers())
+ end
+ end
+
+ defp html_helpers do
+ quote do
+ use Phoenix.HTML
+ import Phoenix.HTML.Form
+ import PAExWeb.CoreComponents
+ unquote(verified_routes())
+ end
+ end
+
+ def verified_routes do
+ quote do
+ use Phoenix.VerifiedRoutes,
+ endpoint: PAExWeb.Endpoint,
+ router: PAExWeb.Router,
+ statics: PAExWeb.static_paths()
+ end
+ end
+
+ defmacro __using__(which) when is_atom(which) do
+ apply(__MODULE__, which, [])
+ end
+end
diff --git a/lib/pa_ex_web/router.ex b/lib/pa_ex_web/router.ex
new file mode 100644
index 0000000..c04b7d1
--- /dev/null
+++ b/lib/pa_ex_web/router.ex
@@ -0,0 +1,69 @@
+defmodule PAExWeb.Router do
+ use PAExWeb, :router
+
+ pipeline :browser do
+ plug :accepts, ["html"]
+ plug :fetch_session
+ plug :fetch_live_flash
+ plug :put_root_layout, html: {PAExWeb.Layouts, :root}
+ plug :protect_from_forgery
+ plug :put_secure_browser_headers
+ end
+
+ pipeline :api do
+ plug :accepts, ["json"]
+ end
+
+ # ── JSON:API routes (Ash) ────────────────────────────────────────────────────
+
+ scope "/api/v1", PAExWeb do
+ pipe_through :api
+
+ # Companies
+ get "/companies", CompanyController, :index
+ post "/companies", CompanyController, :create
+ get "/companies/:id", CompanyController, :show
+ put "/companies/:id", CompanyController, :update
+
+ # Invoices
+ get "/invoices", InvoiceController, :index
+ post "/invoices", InvoiceController, :create
+ get "/invoices/:id", InvoiceController, :show
+
+ # Invoice lifecycle actions
+ post "/invoices/:id/start_processing", InvoiceController, :start_processing
+ post "/invoices/:id/reject", InvoiceController, :reject
+ post "/invoices/:id/start_emission", InvoiceController, :start_emission
+ post "/invoices/:id/mark_emitted", InvoiceController, :mark_emitted
+ post "/invoices/:id/refuse", InvoiceController, :refuse
+ post "/invoices/:id/accept", InvoiceController, :accept
+ post "/invoices/:id/initiate_payment", InvoiceController, :initiate_payment
+ post "/invoices/:id/mark_accounted", InvoiceController, :mark_accounted
+ post "/invoices/:id/raise_dispute", InvoiceController, :raise_dispute
+ post "/invoices/:id/cancel", InvoiceController, :cancel
+
+ # E-reporting
+ get "/e_reports", EReportController, :index
+ post "/e_reports", EReportController, :create
+ get "/e_reports/:id", EReportController, :show
+ post "/e_reports/:id/submit", EReportController, :submit
+ post "/e_reports/:id/acknowledge", EReportController, :acknowledge
+ post "/e_reports/:id/reject", EReportController, :reject
+
+ # Transmission events (read-only via API)
+ get "/transmission_events", TransmissionEventController, :index
+ get "/transmission_events/:id", TransmissionEventController, :show
+ end
+
+ # ── Browser / LiveView routes ─────────────────────────────────────────────────
+
+ scope "/", PAExWeb do
+ pipe_through :browser
+
+ live "/", DashboardLive, :index
+ live "/invoices", InvoiceLive.Index, :index
+ live "/invoices/:id", InvoiceLive.Show, :show
+ live "/e_reports", EReportLive.Index, :index
+ live "/companies", CompanyLive.Index, :index
+ end
+end
diff --git a/lib/pa_ex_web/telemetry.ex b/lib/pa_ex_web/telemetry.ex
new file mode 100644
index 0000000..3952892
--- /dev/null
+++ b/lib/pa_ex_web/telemetry.ex
@@ -0,0 +1,49 @@
+defmodule PAExWeb.Telemetry do
+ @moduledoc false
+ use Supervisor
+ import Telemetry.Metrics
+
+ def start_link(arg) do
+ Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
+ end
+
+ @impl true
+ def init(_arg) do
+ children = [
+ {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
+ ]
+
+ Supervisor.init(children, strategy: :one_for_one)
+ end
+
+ def metrics do
+ [
+ # Phoenix metrics
+ summary("phoenix.endpoint.start.system_time", unit: {:native, :millisecond}),
+ summary("phoenix.endpoint.stop.duration", unit: {:native, :millisecond}),
+ summary("phoenix.router_dispatch.start.system_time", unit: {:native, :millisecond}),
+ summary("phoenix.router_dispatch.exception.duration", unit: {:native, :millisecond}),
+ summary("phoenix.router_dispatch.stop.duration", unit: {:native, :millisecond}),
+ summary("phoenix.socket_connected.duration", unit: {:native, :millisecond}),
+ summary("phoenix.channel_join.duration", unit: {:native, :millisecond}),
+ summary("phoenix.channel_handled_in.duration", unit: {:native, :millisecond}),
+
+ # Database metrics
+ summary("pa_ex.repo.query.total_time", unit: {:native, :millisecond}),
+ summary("pa_ex.repo.query.decode_time", unit: {:native, :millisecond}),
+ summary("pa_ex.repo.query.query_time", unit: {:native, :millisecond}),
+ summary("pa_ex.repo.query.queue_time", unit: {:native, :millisecond}),
+ summary("pa_ex.repo.query.idle_time", unit: {:native, :millisecond}),
+
+ # VM metrics
+ summary("vm.memory.total", unit: {:byte, :kilobyte}),
+ summary("vm.total_run_queue_lengths.total"),
+ summary("vm.total_run_queue_lengths.cpu"),
+ summary("vm.total_run_queue_lengths.io")
+ ]
+ end
+
+ defp periodic_measurements do
+ []
+ end
+end
diff --git a/mix.exs b/mix.exs
new file mode 100644
index 0000000..d095bbe
--- /dev/null
+++ b/mix.exs
@@ -0,0 +1,108 @@
+defmodule PAEx.MixProject do
+ use Mix.Project
+
+ @version "0.1.0"
+ @source_url "https://github.com/LGuichet/PAEx"
+
+ def project do
+ [
+ app: :pa_ex,
+ version: @version,
+ elixir: "~> 1.16",
+ elixirc_paths: elixirc_paths(Mix.env()),
+ start_permanent: Mix.env() == :prod,
+ aliases: aliases(),
+ deps: deps(),
+ description:
+ "Plateforme Agréée Elixir — accredited partner dematerialization platform (PDP) " <>
+ "for French e-invoicing (facturation électronique) and e-reporting",
+ package: package(),
+ docs: docs()
+ ]
+ end
+
+ def application do
+ [
+ mod: {PAEx.Application, []},
+ extra_applications: [:logger, :runtime_tools]
+ ]
+ end
+
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
+ defp elixirc_paths(_), do: ["lib"]
+
+ defp deps do
+ [
+ # Ash Framework
+ {:ash, "~> 3.4"},
+ {:ash_postgres, "~> 2.4"},
+ {:ash_phoenix, "~> 2.1"},
+ {:ash_json_api, "~> 1.4"},
+
+ # Phoenix
+ {:phoenix, "~> 1.7"},
+ {:phoenix_ecto, "~> 4.6"},
+ {:phoenix_html, "~> 4.1"},
+ {:phoenix_live_reload, "~> 1.5", only: :dev},
+ {:phoenix_live_view, "~> 1.0"},
+
+ # Database
+ {:ecto_sql, "~> 3.12"},
+ {:postgrex, "~> 0.19"},
+
+ # Auth
+ {:bcrypt_elixir, "~> 3.1"},
+
+ # HTTP client (for PPF integration)
+ {:req, "~> 0.5"},
+
+ # XML generation (Factur-X / UBL / CII)
+ {:sweet_xml, "~> 0.7"},
+ {:xml_builder, "~> 2.3"},
+
+ # JSON
+ {:jason, "~> 1.4"},
+
+ # UUID
+ {:uniq, "~> 0.6"},
+
+ # Telemetry
+ {:bandit, "~> 1.5"},
+ {:dns_cluster, "~> 0.1"},
+ {:phoenix_live_dashboard, "~> 0.8"},
+ {:telemetry_metrics, "~> 1.0"},
+ {:telemetry_poller, "~> 1.1"},
+
+ # Dev / test
+ {:ex_doc, "~> 0.34", only: :dev, runtime: false},
+ {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
+ {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
+ {:mox, "~> 1.1", only: :test},
+ {:faker, "~> 0.18", only: :test}
+ ]
+ end
+
+ defp aliases do
+ [
+ setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
+ "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
+ "ecto.reset": ["ecto.drop", "ecto.setup"],
+ test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]
+ ]
+ end
+
+ defp package do
+ [
+ licenses: ["MIT"],
+ links: %{"GitHub" => @source_url}
+ ]
+ end
+
+ defp docs do
+ [
+ main: "readme",
+ source_url: @source_url,
+ extras: ["README.md"]
+ ]
+ end
+end
diff --git a/priv/repo/migrations/20240101000001_create_users_and_tokens.exs b/priv/repo/migrations/20240101000001_create_users_and_tokens.exs
new file mode 100644
index 0000000..ad1233a
--- /dev/null
+++ b/priv/repo/migrations/20240101000001_create_users_and_tokens.exs
@@ -0,0 +1,37 @@
+defmodule PAEx.Repo.Migrations.CreateUsersAndTokens do
+ @moduledoc """
+ Initial migration: users and authentication tokens.
+ """
+ use Ecto.Migration
+
+ def change do
+ execute "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"", "SELECT 1"
+ execute "CREATE EXTENSION IF NOT EXISTS \"citext\"", "SELECT 1"
+
+ create table(:users, primary_key: false) do
+ add :id, :uuid, primary_key: true, null: false, default: fragment("uuid_generate_v4()")
+ add :email, :citext, null: false
+ add :hashed_password, :string, null: false
+ add :role, :string, null: false, default: "operator"
+ add :confirmed_at, :utc_datetime_usec
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create unique_index(:users, [:email])
+
+ create table(:tokens, primary_key: false) do
+ add :id, :uuid, primary_key: true, null: false, default: fragment("uuid_generate_v4()")
+ add :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false
+ add :token, :string, null: false
+ add :context, :string, null: false
+ add :sent_to, :string
+ add :expires_at, :utc_datetime_usec, null: false
+
+ timestamps(type: :utc_datetime_usec, updated_at: false)
+ end
+
+ create index(:tokens, [:user_id])
+ create index(:tokens, [:context, :token])
+ end
+end
diff --git a/priv/repo/migrations/20240101000002_create_companies.exs b/priv/repo/migrations/20240101000002_create_companies.exs
new file mode 100644
index 0000000..12f0bf9
--- /dev/null
+++ b/priv/repo/migrations/20240101000002_create_companies.exs
@@ -0,0 +1,29 @@
+defmodule PAEx.Repo.Migrations.CreateCompanies do
+ @moduledoc """
+ Creates the companies table for legal entities registered on the platform.
+ """
+ use Ecto.Migration
+
+ def change do
+ create table(:companies, primary_key: false) do
+ add :id, :uuid, primary_key: true, null: false, default: fragment("uuid_generate_v4()")
+ add :name, :string, null: false, size: 255
+ add :siren, :string, null: false, size: 9
+ add :siret, :string, size: 14
+ add :vat_number, :string, size: 20
+ add :address_street, :text
+ add :address_city, :string, size: 100
+ add :address_postal_code, :string, size: 10
+ add :address_country, :string, size: 2, default: "FR"
+ add :status, :string, null: false, default: "active"
+ add :role, :string, null: false, default: "both"
+ add :ppf_routing_id, :string, size: 50
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create unique_index(:companies, [:siren])
+ create index(:companies, [:status])
+ create index(:companies, [:siret])
+ end
+end
diff --git a/priv/repo/migrations/20240101000003_create_invoices.exs b/priv/repo/migrations/20240101000003_create_invoices.exs
new file mode 100644
index 0000000..8def3c3
--- /dev/null
+++ b/priv/repo/migrations/20240101000003_create_invoices.exs
@@ -0,0 +1,81 @@
+defmodule PAEx.Repo.Migrations.CreateInvoices do
+ @moduledoc """
+ Creates tables for electronic invoices, their line items, and the status
+ audit-trail (status events).
+ """
+ use Ecto.Migration
+
+ def change do
+ create table(:invoices, primary_key: false) do
+ add :id, :uuid, primary_key: true, null: false, default: fragment("uuid_generate_v4()")
+ add :number, :string, null: false, size: 50
+ add :invoice_type, :string, null: false, default: "facture"
+ add :format, :string, null: false, default: "factur_x"
+ add :status, :string, null: false, default: "deposee"
+
+ add :emitter_id, references(:companies, type: :uuid, on_delete: :restrict), null: false
+ add :receiver_id, references(:companies, type: :uuid, on_delete: :restrict), null: false
+
+ add :issue_date, :date, null: false
+ add :due_date, :date
+ add :delivery_date, :date
+
+ # Amounts in euro cents
+ add :amount_excl_tax, :bigint, null: false
+ add :amount_vat, :bigint, null: false
+ add :amount_incl_tax, :bigint, null: false
+ add :currency, :string, null: false, size: 3, default: "EUR"
+
+ add :purchase_order_ref, :string, size: 50
+ add :contract_ref, :string, size: 50
+ add :payload, :text
+
+ add :ppf_submission_id, :string, size: 100
+ add :rejection_reason, :string, size: 1000
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create unique_index(:invoices, [:number, :emitter_id])
+ create index(:invoices, [:status])
+ create index(:invoices, [:emitter_id])
+ create index(:invoices, [:receiver_id])
+ create index(:invoices, [:issue_date])
+ create index(:invoices, [:due_date])
+
+ create table(:invoice_lines, primary_key: false) do
+ add :id, :uuid, primary_key: true, null: false, default: fragment("uuid_generate_v4()")
+ add :invoice_id, references(:invoices, type: :uuid, on_delete: :delete_all), null: false
+ add :line_number, :integer, null: false
+ add :description, :string, null: false, size: 500
+ add :product_ref, :string, size: 100
+ add :quantity, :decimal, null: false
+ add :unit_of_measure, :string, null: false, size: 10, default: "EA"
+
+ # Amounts in euro cents
+ add :unit_price, :bigint, null: false
+ add :vat_rate, :decimal, null: false
+ add :line_amount_excl_tax, :bigint, null: false
+ add :line_vat_amount, :bigint, null: false
+ add :line_amount_incl_tax, :bigint, null: false
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create index(:invoice_lines, [:invoice_id])
+ create unique_index(:invoice_lines, [:invoice_id, :line_number])
+
+ create table(:invoice_status_events, primary_key: false) do
+ add :id, :uuid, primary_key: true, null: false, default: fragment("uuid_generate_v4()")
+ add :invoice_id, references(:invoices, type: :uuid, on_delete: :delete_all), null: false
+ add :from_status, :string
+ add :to_status, :string, null: false
+ add :triggered_by, :string
+ add :note, :string, size: 1000
+ add :occurred_at, :utc_datetime_usec, null: false
+ end
+
+ create index(:invoice_status_events, [:invoice_id])
+ create index(:invoice_status_events, [:occurred_at])
+ end
+end
diff --git a/priv/repo/migrations/20240101000004_create_e_reporting.exs b/priv/repo/migrations/20240101000004_create_e_reporting.exs
new file mode 100644
index 0000000..172b8df
--- /dev/null
+++ b/priv/repo/migrations/20240101000004_create_e_reporting.exs
@@ -0,0 +1,56 @@
+defmodule PAEx.Repo.Migrations.CreateEReporting do
+ @moduledoc """
+ Creates tables for e-reporting submissions and their individual transaction lines.
+ """
+ use Ecto.Migration
+
+ def change do
+ create table(:e_reports, primary_key: false) do
+ add :id, :uuid, primary_key: true, null: false, default: fragment("uuid_generate_v4()")
+ add :company_id, references(:companies, type: :uuid, on_delete: :restrict), null: false
+ add :report_type, :string, null: false
+ add :status, :string, null: false, default: "draft"
+ add :period_start, :date, null: false
+ add :period_end, :date, null: false
+
+ # Aggregated totals in euro cents
+ add :total_amount_excl_tax, :bigint, null: false, default: 0
+ add :total_vat_amount, :bigint, null: false, default: 0
+ add :total_amount_incl_tax, :bigint, null: false, default: 0
+ add :transaction_count, :integer, null: false, default: 0
+
+ add :ppf_submission_id, :string, size: 100
+ add :submitted_at, :utc_datetime_usec
+ add :ppf_acknowledgement_id, :string, size: 100
+ add :rejection_reason, :text
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create index(:e_reports, [:company_id])
+ create index(:e_reports, [:status])
+ create index(:e_reports, [:period_start, :period_end])
+ create index(:e_reports, [:report_type])
+
+ create table(:e_report_lines, primary_key: false) do
+ add :id, :uuid, primary_key: true, null: false, default: fragment("uuid_generate_v4()")
+ add :e_report_id, references(:e_reports, type: :uuid, on_delete: :delete_all), null: false
+ add :transaction_date, :date, null: false
+ add :transaction_ref, :string, size: 100
+ add :customer_country, :string, size: 2
+ add :customer_vat_number, :string, size: 20
+
+ # Amounts in euro cents
+ add :amount_excl_tax, :bigint, null: false
+ add :vat_rate, :decimal, null: false
+ add :vat_amount, :bigint, null: false
+ add :amount_incl_tax, :bigint, null: false
+ add :currency, :string, null: false, size: 3, default: "EUR"
+
+ timestamps(type: :utc_datetime_usec, updated_at: false)
+ end
+
+ create index(:e_report_lines, [:e_report_id])
+ create index(:e_report_lines, [:transaction_date])
+ end
+end
diff --git a/priv/repo/migrations/20240101000005_create_transmission_events.exs b/priv/repo/migrations/20240101000005_create_transmission_events.exs
new file mode 100644
index 0000000..926f000
--- /dev/null
+++ b/priv/repo/migrations/20240101000005_create_transmission_events.exs
@@ -0,0 +1,34 @@
+defmodule PAEx.Repo.Migrations.CreateTransmissionEvents do
+ @moduledoc """
+ Creates the transmission_events table for auditing all messages exchanged
+ with the PPF and other external parties.
+ """
+ use Ecto.Migration
+
+ def change do
+ create table(:transmission_events, primary_key: false) do
+ add :id, :uuid, primary_key: true, null: false, default: fragment("uuid_generate_v4()")
+ add :direction, :string, null: false
+ add :counterparty_type, :string, null: false
+ add :counterparty_id, :string, size: 200
+ add :event_type, :string, null: false
+ add :status, :string, null: false, default: "pending"
+ add :reference_id, :uuid
+ add :reference_type, :string, size: 100
+ add :payload_summary, :string, size: 500
+ add :http_status_code, :integer
+ add :error_message, :text
+ add :retry_count, :integer, null: false, default: 0
+ add :sent_at, :utc_datetime_usec
+ add :acked_at, :utc_datetime_usec
+
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create index(:transmission_events, [:status])
+ create index(:transmission_events, [:direction, :status])
+ create index(:transmission_events, [:event_type])
+ create index(:transmission_events, [:reference_id])
+ create index(:transmission_events, [:inserted_at])
+ end
+end
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
new file mode 100644
index 0000000..9e3084e
--- /dev/null
+++ b/priv/repo/seeds.exs
@@ -0,0 +1,71 @@
+# Seeds — populate the development database with sample data
+# Run with: mix run priv/repo/seeds.exs
+
+alias PAEx.Accounts
+alias PAEx.Accounts.User
+alias PAEx.Companies
+alias PAEx.Companies.Company
+
+IO.puts("Seeding database…")
+
+# Create admin user
+{:ok, admin} =
+ Ash.create(
+ User,
+ %{
+ email: "admin@pa-ex.fr",
+ password: "AdminSecret123!",
+ password_confirmation: "AdminSecret123!",
+ role: :admin
+ },
+ action: :register,
+ domain: Accounts
+ )
+
+IO.puts(" ✓ Admin user: #{admin.email}")
+
+# Create a sample emitter company
+{:ok, emitter} =
+ Ash.create(
+ Company,
+ %{
+ name: "Fournitures Dupont SAS",
+ siren: "123456789",
+ siret: "12345678900012",
+ vat_number: "FR12123456789",
+ address_street: "10 Rue de la Paix",
+ address_city: "Paris",
+ address_postal_code: "75001",
+ address_country: "FR",
+ role: :emitter,
+ ppf_routing_id: "12345678900012"
+ },
+ action: :register,
+ domain: Companies
+ )
+
+IO.puts(" ✓ Emitter company: #{emitter.name} (SIREN: #{emitter.siren})")
+
+# Create a sample receiver company
+{:ok, receiver} =
+ Ash.create(
+ Company,
+ %{
+ name: "Acheteur Martin SARL",
+ siren: "987654321",
+ siret: "98765432100019",
+ vat_number: "FR98987654321",
+ address_street: "5 Avenue de Lyon",
+ address_city: "Lyon",
+ address_postal_code: "69001",
+ address_country: "FR",
+ role: :receiver,
+ ppf_routing_id: "98765432100019"
+ },
+ action: :register,
+ domain: Companies
+ )
+
+IO.puts(" ✓ Receiver company: #{receiver.name} (SIREN: #{receiver.siren})")
+
+IO.puts("Done.")
diff --git a/test/pa_ex/companies/company_test.exs b/test/pa_ex/companies/company_test.exs
new file mode 100644
index 0000000..1ad23a2
--- /dev/null
+++ b/test/pa_ex/companies/company_test.exs
@@ -0,0 +1,105 @@
+defmodule PAEx.Companies.CompanyTest do
+ @moduledoc "Unit tests for the Company resource."
+ use PAEx.DataCase, async: true
+
+ alias PAEx.Companies
+ alias PAEx.Companies.Company
+
+ @valid_attrs %{
+ name: "Test Corp SAS",
+ siren: "123456789",
+ siret: "12345678900012",
+ vat_number: "FR12123456789",
+ address_street: "1 Rue Test",
+ address_city: "Paris",
+ address_postal_code: "75001",
+ address_country: "FR",
+ role: :both
+ }
+
+ describe "register/1" do
+ test "creates a company with valid attributes" do
+ assert {:ok, company} =
+ Ash.create(Company, @valid_attrs, action: :register, domain: Companies)
+
+ assert company.name == "Test Corp SAS"
+ assert company.siren == "123456789"
+ assert company.siret == "12345678900012"
+ assert company.status == :active
+ assert company.address_country == "FR"
+ end
+
+ test "enforces unique SIREN" do
+ {:ok, _} = Ash.create(Company, @valid_attrs, action: :register, domain: Companies)
+
+ assert {:error, %Ash.Error.Invalid{}} =
+ Ash.create(
+ Company,
+ Map.put(@valid_attrs, :siret, "12345678900099"),
+ action: :register,
+ domain: Companies
+ )
+ end
+
+ test "rejects invalid SIREN format" do
+ invalid = Map.put(@valid_attrs, :siren, "12345")
+
+ assert {:error, %Ash.Error.Invalid{}} =
+ Ash.create(Company, invalid, action: :register, domain: Companies)
+ end
+
+ test "rejects invalid SIRET format" do
+ invalid = Map.put(@valid_attrs, :siret, "1234567890")
+
+ assert {:error, %Ash.Error.Invalid{}} =
+ Ash.create(Company, invalid, action: :register, domain: Companies)
+ end
+
+ test "allows nil SIRET" do
+ attrs = Map.delete(@valid_attrs, :siret)
+
+ assert {:ok, company} = Ash.create(Company, attrs, action: :register, domain: Companies)
+ assert is_nil(company.siret)
+ end
+ end
+
+ describe "suspend/1" do
+ test "suspends an active company" do
+ {:ok, company} = Ash.create(Company, @valid_attrs, action: :register, domain: Companies)
+
+ assert {:ok, suspended} = Ash.update(company, %{}, action: :suspend, domain: Companies)
+ assert suspended.status == :suspended
+ end
+ end
+
+ describe "reactivate/1" do
+ test "reactivates a suspended company" do
+ {:ok, company} = Ash.create(Company, @valid_attrs, action: :register, domain: Companies)
+ {:ok, suspended} = Ash.update(company, %{}, action: :suspend, domain: Companies)
+
+ assert {:ok, active} = Ash.update(suspended, %{}, action: :reactivate, domain: Companies)
+ assert active.status == :active
+ end
+ end
+
+ describe "close/1" do
+ test "closes a company" do
+ {:ok, company} = Ash.create(Company, @valid_attrs, action: :register, domain: Companies)
+
+ assert {:ok, closed} = Ash.update(company, %{}, action: :close, domain: Companies)
+ assert closed.status == :closed
+ end
+ end
+
+ describe "by_siren/1" do
+ test "finds a company by SIREN" do
+ {:ok, _company} = Ash.create(Company, @valid_attrs, action: :register, domain: Companies)
+
+ assert {:ok, [found]} =
+ Ash.read(Company, action: :by_siren, domain: Companies,
+ arguments: %{siren: "123456789"})
+
+ assert found.siren == "123456789"
+ end
+ end
+end
diff --git a/test/pa_ex/e_reporting/e_report_test.exs b/test/pa_ex/e_reporting/e_report_test.exs
new file mode 100644
index 0000000..798b2ee
--- /dev/null
+++ b/test/pa_ex/e_reporting/e_report_test.exs
@@ -0,0 +1,178 @@
+defmodule PAEx.EReporting.EReportTest do
+ @moduledoc "Unit tests for the EReport resource."
+ use PAEx.DataCase, async: true
+
+ alias PAEx.Companies
+ alias PAEx.Companies.Company
+ alias PAEx.EReporting
+ alias PAEx.EReporting.EReport
+
+ setup do
+ {:ok, company} =
+ Ash.create(
+ Company,
+ %{name: "Retailer SAS", siren: "333333333", siret: "33333333300033"},
+ action: :register,
+ domain: Companies
+ )
+
+ {:ok, company: company}
+ end
+
+ describe "create_draft/1" do
+ test "creates a draft B2C e-report", ctx do
+ assert {:ok, report} =
+ Ash.create(
+ EReport,
+ %{
+ report_type: :b2c,
+ period_start: ~D[2024-01-01],
+ period_end: ~D[2024-01-31],
+ company_id: ctx.company.id
+ },
+ action: :create_draft,
+ domain: EReporting
+ )
+
+ assert report.status == :draft
+ assert report.report_type == :b2c
+ assert report.total_amount_excl_tax == 0
+ end
+
+ test "rejects period_end before period_start", ctx do
+ assert {:error, %Ash.Error.Invalid{}} =
+ Ash.create(
+ EReport,
+ %{
+ report_type: :b2c,
+ period_start: ~D[2024-02-01],
+ period_end: ~D[2024-01-01],
+ company_id: ctx.company.id
+ },
+ action: :create_draft,
+ domain: EReporting
+ )
+ end
+ end
+
+ describe "e-report lifecycle" do
+ setup ctx do
+ {:ok, report} =
+ Ash.create(
+ EReport,
+ %{
+ report_type: :b2c,
+ period_start: ~D[2024-01-01],
+ period_end: ~D[2024-01-31],
+ company_id: ctx.company.id
+ },
+ action: :create_draft,
+ domain: EReporting
+ )
+
+ {:ok, report: report}
+ end
+
+ test "update totals on draft report", ctx do
+ assert {:ok, updated} =
+ Ash.update(
+ ctx.report,
+ %{
+ total_amount_excl_tax: 500_000,
+ total_vat_amount: 100_000,
+ total_amount_incl_tax: 600_000,
+ transaction_count: 42
+ },
+ action: :update_totals,
+ domain: EReporting
+ )
+
+ assert updated.total_amount_excl_tax == 500_000
+ assert updated.transaction_count == 42
+ end
+
+ test "submit a draft report", ctx do
+ assert {:ok, submitted} =
+ Ash.update(
+ ctx.report,
+ %{ppf_submission_id: "PPF-REPORT-001"},
+ action: :submit,
+ domain: EReporting
+ )
+
+ assert submitted.status == :submitted
+ assert submitted.ppf_submission_id == "PPF-REPORT-001"
+ assert not is_nil(submitted.submitted_at)
+ end
+
+ test "acknowledge a submitted report", ctx do
+ {:ok, submitted} =
+ Ash.update(ctx.report, %{ppf_submission_id: "PPF-REPORT-001"},
+ action: :submit,
+ domain: EReporting
+ )
+
+ assert {:ok, accepted} =
+ Ash.update(
+ submitted,
+ %{ppf_acknowledgement_id: "ACK-001"},
+ action: :acknowledge_acceptance,
+ domain: EReporting
+ )
+
+ assert accepted.status == :accepted
+ end
+
+ test "reject a submitted report", ctx do
+ {:ok, submitted} =
+ Ash.update(ctx.report, %{ppf_submission_id: "PPF-REPORT-001"},
+ action: :submit,
+ domain: EReporting
+ )
+
+ assert {:ok, rejected} =
+ Ash.update(
+ submitted,
+ %{rejection_reason: "Données manquantes"},
+ action: :reject,
+ domain: EReporting
+ )
+
+ assert rejected.status == :rejected
+ end
+
+ test "reopen a rejected report for correction", ctx do
+ {:ok, submitted} =
+ Ash.update(ctx.report, %{ppf_submission_id: "PPF-REPORT-001"},
+ action: :submit,
+ domain: EReporting
+ )
+
+ {:ok, rejected} =
+ Ash.update(submitted, %{rejection_reason: "Données manquantes"},
+ action: :reject,
+ domain: EReporting
+ )
+
+ assert {:ok, reopened} =
+ Ash.update(rejected, %{}, action: :reopen, domain: EReporting)
+
+ assert reopened.status == :draft
+ assert is_nil(reopened.rejection_reason)
+ end
+
+ test "cannot submit an already submitted report", ctx do
+ {:ok, submitted} =
+ Ash.update(ctx.report, %{ppf_submission_id: "PPF-REPORT-001"},
+ action: :submit,
+ domain: EReporting
+ )
+
+ assert {:error, %Ash.Error.Invalid{}} =
+ Ash.update(submitted, %{ppf_submission_id: "PPF-REPORT-002"},
+ action: :submit,
+ domain: EReporting
+ )
+ end
+ end
+end
diff --git a/test/pa_ex/invoices/invoice_test.exs b/test/pa_ex/invoices/invoice_test.exs
new file mode 100644
index 0000000..0c9e708
--- /dev/null
+++ b/test/pa_ex/invoices/invoice_test.exs
@@ -0,0 +1,155 @@
+defmodule PAEx.Invoices.InvoiceTest do
+ @moduledoc "Unit tests for the Invoice resource and lifecycle transitions."
+ use PAEx.DataCase, async: true
+
+ alias PAEx.Companies
+ alias PAEx.Companies.Company
+ alias PAEx.Invoices
+ alias PAEx.Invoices.Invoice
+
+ setup do
+ {:ok, emitter} =
+ Ash.create(
+ Company,
+ %{name: "Emitter SA", siren: "111111111", siret: "11111111100011", role: :emitter},
+ action: :register,
+ domain: Companies
+ )
+
+ {:ok, receiver} =
+ Ash.create(
+ Company,
+ %{name: "Receiver SA", siren: "222222222", siret: "22222222200022", role: :receiver},
+ action: :register,
+ domain: Companies
+ )
+
+ {:ok, emitter: emitter, receiver: receiver}
+ end
+
+ defp valid_invoice_attrs(emitter_id, receiver_id) do
+ %{
+ number: "FA-2024-001",
+ invoice_type: :facture,
+ format: :factur_x,
+ issue_date: ~D[2024-01-15],
+ due_date: ~D[2024-02-15],
+ amount_excl_tax: 10_000,
+ amount_vat: 2_000,
+ amount_incl_tax: 12_000,
+ currency: "EUR",
+ emitter_id: emitter_id,
+ receiver_id: receiver_id
+ }
+ end
+
+ describe "submit/1" do
+ test "creates an invoice in 'deposee' status", ctx do
+ attrs = valid_invoice_attrs(ctx.emitter.id, ctx.receiver.id)
+
+ assert {:ok, invoice} = Ash.create(Invoice, attrs, action: :submit, domain: Invoices)
+
+ assert invoice.status == :deposee
+ assert invoice.number == "FA-2024-001"
+ assert invoice.amount_excl_tax == 10_000
+ end
+
+ test "rejects negative amounts", ctx do
+ attrs =
+ valid_invoice_attrs(ctx.emitter.id, ctx.receiver.id)
+ |> Map.put(:amount_excl_tax, -100)
+
+ assert {:error, %Ash.Error.Invalid{}} =
+ Ash.create(Invoice, attrs, action: :submit, domain: Invoices)
+ end
+
+ test "rejects due_date before issue_date", ctx do
+ attrs =
+ valid_invoice_attrs(ctx.emitter.id, ctx.receiver.id)
+ |> Map.put(:due_date, ~D[2023-12-01])
+
+ assert {:error, %Ash.Error.Invalid{}} =
+ Ash.create(Invoice, attrs, action: :submit, domain: Invoices)
+ end
+ end
+
+ describe "lifecycle transitions" do
+ setup ctx do
+ attrs = valid_invoice_attrs(ctx.emitter.id, ctx.receiver.id)
+ {:ok, invoice} = Ash.create(Invoice, attrs, action: :submit, domain: Invoices)
+ {:ok, invoice: invoice}
+ end
+
+ test "deposee → en_cours_de_traitement", ctx do
+ assert {:ok, inv} =
+ Ash.update(ctx.invoice, %{}, action: :start_processing, domain: Invoices)
+
+ assert inv.status == :en_cours_de_traitement
+ end
+
+ test "deposee → rejetee", ctx do
+ assert {:ok, inv} =
+ Ash.update(ctx.invoice, %{rejection_reason: "Format invalide"},
+ action: :reject,
+ domain: Invoices
+ )
+
+ assert inv.status == :rejetee
+ assert inv.rejection_reason == "Format invalide"
+ end
+
+ test "deposee cannot transition directly to emise", ctx do
+ assert {:error, %Ash.Error.Invalid{}} =
+ Ash.update(ctx.invoice, %{}, action: :mark_emitted, domain: Invoices)
+ end
+
+ test "full happy-path lifecycle", ctx do
+ {:ok, inv} = Ash.update(ctx.invoice, %{}, action: :start_processing, domain: Invoices)
+ assert inv.status == :en_cours_de_traitement
+
+ {:ok, inv} = Ash.update(inv, %{}, action: :start_emission, domain: Invoices)
+ assert inv.status == :en_cours_d_emission
+
+ {:ok, inv} =
+ Ash.update(inv, %{ppf_submission_id: "PPF-123"}, action: :mark_emitted, domain: Invoices)
+
+ assert inv.status == :emise
+ assert inv.ppf_submission_id == "PPF-123"
+
+ {:ok, inv} = Ash.update(inv, %{}, action: :accept, domain: Invoices)
+ assert inv.status == :acceptee
+
+ {:ok, inv} = Ash.update(inv, %{}, action: :initiate_payment, domain: Invoices)
+ assert inv.status == :mise_en_paiement
+
+ {:ok, inv} = Ash.update(inv, %{}, action: :mark_accounted, domain: Invoices)
+ assert inv.status == :comptabilisee
+ end
+
+ test "can cancel an invoice in deposee status", ctx do
+ assert {:ok, inv} = Ash.update(ctx.invoice, %{}, action: :cancel, domain: Invoices)
+ assert inv.status == :annulee
+ end
+
+ test "cannot cancel a comptabilisee invoice", ctx do
+ {:ok, inv} = Ash.update(ctx.invoice, %{}, action: :start_processing, domain: Invoices)
+ {:ok, inv} = Ash.update(inv, %{}, action: :start_emission, domain: Invoices)
+ {:ok, inv} = Ash.update(inv, %{}, action: :mark_emitted, domain: Invoices)
+ {:ok, inv} = Ash.update(inv, %{}, action: :accept, domain: Invoices)
+ {:ok, inv} = Ash.update(inv, %{}, action: :initiate_payment, domain: Invoices)
+ {:ok, inv} = Ash.update(inv, %{}, action: :mark_accounted, domain: Invoices)
+
+ assert {:error, %Ash.Error.Invalid{}} =
+ Ash.update(inv, %{}, action: :cancel, domain: Invoices)
+ end
+
+ test "can raise dispute on emise invoice", ctx do
+ {:ok, inv} = Ash.update(ctx.invoice, %{}, action: :start_processing, domain: Invoices)
+ {:ok, inv} = Ash.update(inv, %{}, action: :start_emission, domain: Invoices)
+ {:ok, inv} = Ash.update(inv, %{}, action: :mark_emitted, domain: Invoices)
+
+ assert {:ok, inv} = Ash.update(inv, %{}, action: :raise_dispute, domain: Invoices)
+ assert inv.status == :en_litige
+ end
+ end
+end
diff --git a/test/pa_ex/transmission/transmission_event_test.exs b/test/pa_ex/transmission/transmission_event_test.exs
new file mode 100644
index 0000000..03dea7a
--- /dev/null
+++ b/test/pa_ex/transmission/transmission_event_test.exs
@@ -0,0 +1,122 @@
+defmodule PAEx.Transmission.TransmissionEventTest do
+ @moduledoc "Unit tests for TransmissionEvent."
+ use PAEx.DataCase, async: true
+
+ alias PAEx.Transmission
+ alias PAEx.Transmission.TransmissionEvent
+
+ @invoice_id "00000000-0000-0000-0000-000000000001"
+
+ describe "record_outbound/1" do
+ test "creates a pending outbound event" do
+ assert {:ok, event} =
+ Ash.create(
+ TransmissionEvent,
+ %{
+ counterparty_type: :ppf,
+ event_type: :invoice_submission,
+ reference_id: @invoice_id,
+ reference_type: "PAEx.Invoices.Invoice",
+ payload_summary: "Invoice FA-2024-001"
+ },
+ action: :record_outbound,
+ domain: Transmission
+ )
+
+ assert event.direction == :outbound
+ assert event.status == :pending
+ assert event.retry_count == 0
+ end
+ end
+
+ describe "outbound lifecycle" do
+ setup do
+ {:ok, event} =
+ Ash.create(
+ TransmissionEvent,
+ %{
+ counterparty_type: :ppf,
+ event_type: :invoice_submission,
+ reference_id: @invoice_id,
+ reference_type: "PAEx.Invoices.Invoice"
+ },
+ action: :record_outbound,
+ domain: Transmission
+ )
+
+ {:ok, event: event}
+ end
+
+ test "mark_sent transitions pending → sent", ctx do
+ assert {:ok, sent} =
+ Ash.update(ctx.event, %{http_status_code: 202},
+ action: :mark_sent,
+ domain: Transmission
+ )
+
+ assert sent.status == :sent
+ assert not is_nil(sent.sent_at)
+ end
+
+ test "acknowledge transitions sent → acked", ctx do
+ {:ok, sent} =
+ Ash.update(ctx.event, %{http_status_code: 202},
+ action: :mark_sent,
+ domain: Transmission
+ )
+
+ assert {:ok, acked} =
+ Ash.update(sent, %{http_status_code: 200},
+ action: :acknowledge,
+ domain: Transmission
+ )
+
+ assert acked.status == :acked
+ assert not is_nil(acked.acked_at)
+ end
+
+ test "mark_failed increments retry_count", ctx do
+ assert {:ok, failed} =
+ Ash.update(ctx.event, %{error_message: "Connection refused"},
+ action: :mark_failed,
+ domain: Transmission
+ )
+
+ assert failed.status == :failed
+ assert failed.retry_count == 1
+ end
+
+ test "retry resets failed event to pending", ctx do
+ {:ok, failed} =
+ Ash.update(ctx.event, %{error_message: "Timeout"},
+ action: :mark_failed,
+ domain: Transmission
+ )
+
+ assert {:ok, retried} = Ash.update(failed, %{}, action: :retry, domain: Transmission)
+ assert retried.status == :pending
+ assert retried.retry_count == 1
+ end
+ end
+
+ describe "record_inbound/1" do
+ test "creates a received inbound event" do
+ assert {:ok, event} =
+ Ash.create(
+ TransmissionEvent,
+ %{
+ counterparty_type: :ppf,
+ event_type: :ppf_webhook,
+ reference_id: @invoice_id,
+ reference_type: "PAEx.Invoices.Invoice",
+ http_status_code: 200
+ },
+ action: :record_inbound,
+ domain: Transmission
+ )
+
+ assert event.direction == :inbound
+ assert event.status == :received
+ end
+ end
+end
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
new file mode 100644
index 0000000..95cdfd0
--- /dev/null
+++ b/test/support/conn_case.ex
@@ -0,0 +1,24 @@
+defmodule PAEx.ConnCase do
+ @moduledoc """
+ Base test case for controller / integration tests.
+ """
+ use ExUnit.CaseTemplate
+
+ using do
+ quote do
+ use PAExWeb, :verified_routes
+
+ alias PAEx.Repo
+ import Plug.Conn
+ import Phoenix.ConnTest
+ import PAEx.ConnCase
+
+ @endpoint PAExWeb.Endpoint
+ end
+ end
+
+ setup tags do
+ PAEx.DataCase.setup_sandbox(tags)
+ {:ok, conn: Phoenix.ConnTest.build_conn()}
+ end
+end
diff --git a/test/support/data_case.ex b/test/support/data_case.ex
new file mode 100644
index 0000000..986025a
--- /dev/null
+++ b/test/support/data_case.ex
@@ -0,0 +1,29 @@
+defmodule PAEx.DataCase do
+ @moduledoc """
+ Base test case for tests that need a database connection.
+
+ Sets up the SQL sandbox for each test and provides helpers for building
+ Ash changesets/queries in the test context.
+ """
+ use ExUnit.CaseTemplate
+
+ using do
+ quote do
+ alias PAEx.Repo
+
+ import Ecto
+ import Ecto.Query
+ import PAEx.DataCase
+ end
+ end
+
+ setup tags do
+ PAEx.DataCase.setup_sandbox(tags)
+ :ok
+ end
+
+ def setup_sandbox(tags) do
+ pid = Ecto.Adapters.SQL.Sandbox.start_owner!(PAEx.Repo, shared: not tags[:async])
+ on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
+ end
+end
diff --git a/test/test_helper.exs b/test/test_helper.exs
new file mode 100644
index 0000000..bb77083
--- /dev/null
+++ b/test/test_helper.exs
@@ -0,0 +1,3 @@
+ExUnit.start()
+
+Ecto.Adapters.SQL.Sandbox.mode(PAEx.Repo, :manual)