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

+ + + + + + + + + + + + <%= for c <- @companies do %> + + + + + + + + <% end %> + +
NomSIRENSIRETStatutRôle
{c.name}{c.siren}{c.siret}{c.status}{c.role}
+
+ """ + 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

+ + + + + + + + + + + + + <%= for r <- @reports do %> + + + + + + + + + <% end %> + +
TypeStatutPériode débutPériode finTransactionsMontant TTC
{r.report_type}{r.status}{r.period_start}{r.period_end}{r.transaction_count}{r.total_amount_incl_tax} cts
+
+ """ + 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

+ + + + + + + + + + + + + <%= for inv <- @invoices do %> + + + + + + + + + <% end %> + +
NuméroTypeFormatStatutDate d'émissionMontant TTC
{inv.number}{inv.invoice_type}{inv.format}{inv.status}{inv.issue_date}{inv.amount_incl_tax} cts
+
+ """ + 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)