codetics-ocpp-service 0.1.0

Codetics OCPP: Charging Station Management System (CSMS) service for OCPP 1.6J, 2.0.1 and 2.1, with TLS security profiles, smart charging, reservations and tariffs
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)

//! Tariff endpoints (OCPP 2.x tariff and cost; OCPP 1.6 has no tariff messages).

use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use serde::Deserialize;
use serde_json::{Value, json};

use super::stations::load;
use crate::calls;
use crate::db;
use crate::error::AppError;
use crate::state::App;
use crate::tariff;

/// Creates a tariff (OCPP 2.1 TariffType). A tariff id never changes content (I08.FR.08):
/// re-sending the same content is accepted, different content is a conflict.
pub async fn put(
    State(app): State<App>,
    Path(tariff_id): Path<String>,
    Json(mut body): Json<Value>,
) -> Result<(StatusCode, Json<Value>), AppError> {
    match body.get("tariffId").and_then(Value::as_str) {
        None => body["tariffId"] = json!(tariff_id),
        Some(id) if id != tariff_id => {
            return Err(AppError::BadRequest(
                "tariffId in the body differs from the path".into(),
            ));
        }
        Some(_) => {}
    }
    let t = tariff::decode(&body).map_err(AppError::BadRequest)?;
    tariff::validate(&t).map_err(AppError::BadRequest)?;
    let canonical = serde_json::to_value(&t).map_err(|e| AppError::Internal(e.to_string()))?;
    match db::tariffs::upsert(&app.db, &tariff_id, &t.currency, &canonical).await? {
        db::tariffs::Upsert::Created(row) => Ok((StatusCode::CREATED, Json(json!(row)))),
        db::tariffs::Upsert::Unchanged(row) => Ok((StatusCode::OK, Json(json!(row)))),
        db::tariffs::Upsert::Conflict => Err(AppError::Conflict(format!(
            "tariff {tariff_id} exists with different content; use a new tariffId (I08.FR.08)"
        ))),
    }
}

pub async fn list(State(app): State<App>) -> Result<Json<Value>, AppError> {
    Ok(Json(json!(db::tariffs::list(&app.db).await?)))
}

pub async fn get(
    State(app): State<App>,
    Path(tariff_id): Path<String>,
) -> Result<Json<Value>, AppError> {
    let row = db::tariffs::get(&app.db, &tariff_id)
        .await?
        .ok_or_else(|| AppError::NotFound(format!("tariff {tariff_id} not found")))?;
    let t = tariff::decode(&row.tariff).map_err(AppError::Internal)?;
    Ok(Json(json!({ "tariff": row, "text": tariff::text(&t) })))
}

pub async fn delete(
    State(app): State<App>,
    Path(tariff_id): Path<String>,
) -> Result<StatusCode, AppError> {
    if db::tariffs::delete(&app.db, &tariff_id).await? {
        Ok(StatusCode::NO_CONTENT)
    } else {
        Err(AppError::NotFound(format!("tariff {tariff_id} not found")))
    }
}

#[derive(Debug, Deserialize)]
pub struct StationTariffQuery {
    /// `station`: ask the station (GetTariffs, 2.1 local cost); default: CSMS assignments.
    pub source: Option<String>,
    pub evse_id: Option<i64>,
}

pub async fn station_list(
    State(app): State<App>,
    Path(identity): Path<String>,
    Query(q): Query<StationTariffQuery>,
) -> Result<Json<Value>, AppError> {
    if q.source.as_deref() == Some("station") {
        let version = calls::connected_version(&app, &identity).await?;
        let response = calls::call(
            &app,
            &identity,
            version,
            "GetTariffs",
            json!({ "evseId": q.evse_id.unwrap_or(0) }),
        )
        .await?;
        return Ok(Json(json!({ "source": "station", "response": response })));
    }
    let station = load(&app, &identity).await?;
    Ok(Json(
        json!({ "cost_mode": station.cost_mode, "default_tariffs": db::tariffs::defaults(&app.db, station.id).await? }),
    ))
}

#[derive(Debug, Deserialize)]
pub struct AssignDefault {
    /// 0 (default) = all EVSEs.
    #[serde(default)]
    pub evse_id: i64,
    pub tariff_id: String,
}

pub async fn station_set(
    State(app): State<App>,
    Path(identity): Path<String>,
    Json(body): Json<AssignDefault>,
) -> Result<Json<Value>, AppError> {
    let station = load(&app, &identity).await?;
    Ok(Json(
        tariff::set_default(&app, &station, body.evse_id, &body.tariff_id).await?,
    ))
}

#[derive(Debug, Deserialize)]
pub struct ClearQuery {
    pub evse_id: Option<i64>,
}

pub async fn station_clear(
    State(app): State<App>,
    Path((identity, tariff_id)): Path<(String, String)>,
    Query(q): Query<ClearQuery>,
) -> Result<Json<Value>, AppError> {
    let station = load(&app, &identity).await?;
    Ok(Json(
        tariff::clear_default(&app, &station, &tariff_id, q.evse_id).await?,
    ))
}

#[derive(Debug, Deserialize)]
pub struct ChangeTariff {
    pub tariff_id: String,
}

pub async fn change_transaction(
    State(app): State<App>,
    Path(id): Path<i64>,
    Json(body): Json<ChangeTariff>,
) -> Result<Json<Value>, AppError> {
    Ok(Json(
        json!({ "response": tariff::change_transaction_tariff(&app, id, &body.tariff_id).await? }),
    ))
}

pub async fn transaction_cost(
    State(app): State<App>,
    Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
    Ok(Json(json!(tariff::report(&app, id).await?)))
}