Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -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"
]
]
212 changes: 210 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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).
44 changes: 44 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -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"
23 changes: 23 additions & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions config/prod.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Config

config :logger, level: :info

config :phoenix, :serve_endpoints, true
37 changes: 37 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions lib/pa_ex/accounts/accounts.ex
Original file line number Diff line number Diff line change
@@ -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
Loading