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)

//! Authorization tokens (1.6 idTag, 2.x idToken).

use serde::{Deserialize, Serialize};

use super::{Db, Ts};

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct IdToken {
    pub id_token: String,
    pub token_type: String,
    pub status: String,
    pub expires_at: Option<Ts>,
    pub group_id: Option<String>,
    /// Driver-specific tariff (OCPP 2.x tariff and cost).
    #[serde(default)]
    pub tariff_id: Option<String>,
}

/// Result of an authorization lookup, version-neutral.
#[derive(Debug, Clone, PartialEq)]
pub struct AuthResult {
    /// `Accepted`, `Blocked`, `Expired`, `Invalid` or `Unknown` (not in the database).
    pub status: &'static str,
    pub expires_at: Option<Ts>,
    pub group_id: Option<String>,
}

/// Looks up a token. A row with type `*` matches any type; 1.6 idTags are
/// matched case-insensitively (CiString20).
pub async fn authorize(
    db: &Db,
    token: &str,
    token_type: Option<&str>,
    accept_unknown: bool,
) -> sqlx::Result<AuthResult> {
    let row: Option<IdToken> = sqlx::query_as(
        "SELECT id_token, token_type, status, expires_at, group_id, tariff_id FROM id_tokens \
         WHERE ($2::text IS NULL AND lower(id_token) = lower($1)) OR ($2::text IS NOT NULL AND id_token = $1 AND token_type IN ($2, '*')) \
         ORDER BY (token_type = '*') ASC LIMIT 1",
    )
    .bind(token)
    .bind(token_type)
    .fetch_optional(db)
    .await?;
    let Some(row) = row else {
        return Ok(AuthResult {
            status: if accept_unknown {
                "Accepted"
            } else {
                "Unknown"
            },
            expires_at: None,
            group_id: None,
        });
    };
    let expired = row
        .expires_at
        .is_some_and(|e| e.0 < ocpp_core::DateTime::now());
    let status = match row.status.as_str() {
        _ if expired => "Expired",
        "Accepted" => "Accepted",
        "Blocked" => "Blocked",
        "Expired" => "Expired",
        _ => "Invalid",
    };
    Ok(AuthResult {
        status,
        expires_at: row.expires_at,
        group_id: row.group_id,
    })
}

pub async fn upsert(db: &Db, t: &IdToken) -> sqlx::Result<IdToken> {
    sqlx::query_as(
        "INSERT INTO id_tokens (id_token, token_type, status, expires_at, group_id, tariff_id) VALUES ($1, $2, $3, $4, $5, $6) \
         ON CONFLICT (id_token, token_type) DO UPDATE SET status = EXCLUDED.status, expires_at = EXCLUDED.expires_at, group_id = EXCLUDED.group_id, tariff_id = EXCLUDED.tariff_id \
         RETURNING id_token, token_type, status, expires_at, group_id, tariff_id",
    )
    .bind(&t.id_token)
    .bind(&t.token_type)
    .bind(&t.status)
    .bind(t.expires_at)
    .bind(&t.group_id)
    .bind(&t.tariff_id)
    .fetch_one(db)
    .await
}

pub async fn delete(db: &Db, token: &str, token_type: &str) -> sqlx::Result<bool> {
    Ok(
        sqlx::query("DELETE FROM id_tokens WHERE id_token = $1 AND token_type = $2")
            .bind(token)
            .bind(token_type)
            .execute(db)
            .await?
            .rows_affected()
            > 0,
    )
}

pub async fn list(db: &Db, limit: i64, offset: i64) -> sqlx::Result<Vec<IdToken>> {
    sqlx::query_as("SELECT id_token, token_type, status, expires_at, group_id, tariff_id FROM id_tokens ORDER BY id_token, token_type LIMIT $1 OFFSET $2")
        .bind(limit)
        .bind(offset)
        .fetch_all(db)
        .await
}