Skip to main content

ocpp_service/tariff/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)
3
4//! Tariff and cost (OCPP 2.0.1 block I, OCPP 2.1 block I).
5//!
6//! OCPP 1.6 has no tariff or cost messages; nothing here applies to it.
7//!
8//! Tariffs use the OCPP 2.1 `TariffType` structure. Per station, `cost_mode` selects:
9//! - `Central`: the CSMS calculates costs (2.0.1 I01–I03; 2.1 stations without
10//!   local calculation). The driver sees the tariff text in
11//!   `IdTokenInfo.personalMessage` and costs in `TransactionEventResponse.totalCost`.
12//! - `Local` (2.1 only): the station calculates costs from tariffs it receives
13//!   (SetDefaultTariff, AuthorizeResponse.tariff, ChangeTransactionTariff) and
14//!   reports `costDetails`; the CSMS recalculates them for verification and
15//!   falls back to its own calculation when the station could not calculate.
16
17pub mod cost;
18
19use ocpp_core::{DateTime, OcppVersion};
20use ocpp21::Tariff;
21use serde::Serialize;
22use serde_json::{Value, json};
23
24use crate::calls;
25use crate::db::{self, Ts, stations::Station, tariffs::TxCostInfo};
26use crate::error::AppError;
27use crate::state::App;
28
29pub fn decode(v: &Value) -> Result<Tariff, String> {
30    ocpp_core::decode_value(v.clone()).map_err(|e| e.to_string())
31}
32
33fn valid_tod(s: &str) -> bool {
34    let b = s.as_bytes();
35    b.len() == 5
36        && b[2] == b':'
37        && s[..2].parse::<u32>().is_ok_and(|h| h < 24)
38        && s[3..].parse::<u32>().is_ok_and(|m| m < 60)
39}
40
41fn valid_date(s: &str) -> bool {
42    DateTime::parse(&format!("{s}T00:00:00Z")).is_ok() && s.len() == 10
43}
44
45/// startTimeOfDay, endTimeOfDay, validFromDate, validToDate of a condition.
46type CalendarFields<'a> = (
47    Option<&'a str>,
48    Option<&'a str>,
49    Option<&'a str>,
50    Option<&'a str>,
51);
52
53/// Checks a tariff beyond its schema: currency, time and date formats, at least one price.
54pub fn validate(t: &Tariff) -> Result<(), String> {
55    if !(t.currency.len() == 3 && t.currency.chars().all(|c| c.is_ascii_uppercase())) {
56        return Err("currency must be an ISO 4217 code (e.g. EUR)".into());
57    }
58    if t.energy.is_none()
59        && t.charging_time.is_none()
60        && t.idle_time.is_none()
61        && t.fixed_fee.is_none()
62        && t.reservation_time.is_none()
63        && t.reservation_fixed.is_none()
64    {
65        return Err("a tariff needs at least one of energy, chargingTime, idleTime, fixedFee, reservationTime, reservationFixed".into());
66    }
67    let mut checks: Vec<CalendarFields> = Vec::new();
68    if let Some(e) = &t.energy {
69        checks.extend(
70            e.prices
71                .iter()
72                .filter_map(|p| p.conditions.as_ref())
73                .map(|c| {
74                    (
75                        c.start_time_of_day.as_deref(),
76                        c.end_time_of_day.as_deref(),
77                        c.valid_from_date.as_deref(),
78                        c.valid_to_date.as_deref(),
79                    )
80                }),
81        );
82    }
83    for tt in t
84        .charging_time
85        .iter()
86        .chain(&t.idle_time)
87        .chain(&t.reservation_time)
88    {
89        checks.extend(
90            tt.prices
91                .iter()
92                .filter_map(|p| p.conditions.as_ref())
93                .map(|c| {
94                    (
95                        c.start_time_of_day.as_deref(),
96                        c.end_time_of_day.as_deref(),
97                        c.valid_from_date.as_deref(),
98                        c.valid_to_date.as_deref(),
99                    )
100                }),
101        );
102    }
103    for f in t.fixed_fee.iter().chain(&t.reservation_fixed) {
104        checks.extend(
105            f.prices
106                .iter()
107                .filter_map(|p| p.conditions.as_ref())
108                .map(|c| {
109                    (
110                        c.start_time_of_day.as_deref(),
111                        c.end_time_of_day.as_deref(),
112                        c.valid_from_date.as_deref(),
113                        c.valid_to_date.as_deref(),
114                    )
115                }),
116        );
117    }
118    for (s, e, from, to) in checks {
119        if s.is_some_and(|x| !valid_tod(x)) || e.is_some_and(|x| !valid_tod(x)) {
120            return Err("startTimeOfDay/endTimeOfDay must be hh:mm (24h)".into());
121        }
122        if from.is_some_and(|x| !valid_date(x)) || to.is_some_and(|x| !valid_date(x)) {
123            return Err("validFromDate/validToDate must be YYYY-MM-DD".into());
124        }
125    }
126    Ok(())
127}
128
129/// Human-readable tariff text (I01): the tariff description, or a summary.
130pub fn text(t: &Tariff) -> String {
131    if let Some(d) = t.description.as_ref().and_then(|d| d.first()) {
132        return d.content.clone();
133    }
134    let mut parts = Vec::new();
135    let c = &t.currency;
136    let suffix = |has_cond: bool| if has_cond { " (conditions apply)" } else { "" };
137    if let Some(f) = t.fixed_fee.as_ref().and_then(|f| f.prices.first()) {
138        parts.push(format!(
139            "{:.2} {c} start fee{}",
140            f.price_fixed,
141            suffix(f.conditions.is_some())
142        ));
143    }
144    if let Some(e) = t.energy.as_ref().and_then(|e| e.prices.first()) {
145        parts.push(format!(
146            "{:.2} {c}/kWh{}",
147            e.price_kwh,
148            suffix(e.conditions.is_some())
149        ));
150    }
151    if let Some(p) = t.charging_time.as_ref().and_then(|x| x.prices.first()) {
152        parts.push(format!(
153            "{:.2} {c}/min charging{}",
154            p.price_minute,
155            suffix(p.conditions.is_some())
156        ));
157    }
158    if let Some(p) = t.idle_time.as_ref().and_then(|x| x.prices.first()) {
159        parts.push(format!(
160            "{:.2} {c}/min idle{}",
161            p.price_minute,
162            suffix(p.conditions.is_some())
163        ));
164    }
165    parts.join(", ")
166}
167
168pub async fn load(app: &App, tariff_id: &str) -> Result<Option<Tariff>, AppError> {
169    Ok(db::tariffs::get(&app.db, tariff_id)
170        .await?
171        .and_then(|row| decode(&row.tariff).ok()))
172}
173
174/// Tariff for a driver at an EVSE: the driver-specific tariff, else the default one.
175pub async fn applicable(
176    app: &App,
177    station_id: i64,
178    evse: i32,
179    id_token: Option<(&str, Option<&str>)>,
180    at: DateTime,
181) -> Result<Option<Tariff>, AppError> {
182    if let Some((token, ty)) = id_token
183        && let Some(id) = db::tariffs::driver_tariff(&app.db, token, ty).await?
184        && let Some(t) = load(app, &id).await?
185    {
186        return Ok(Some(t));
187    }
188    match db::tariffs::default_for(&app.db, station_id, evse, Ts(at)).await? {
189        Some(id) => load(app, &id).await,
190        None => Ok(None),
191    }
192}
193
194pub async fn driver_tariff(
195    app: &App,
196    token: &str,
197    ty: Option<&str>,
198) -> Result<Option<Tariff>, AppError> {
199    match db::tariffs::driver_tariff(&app.db, token, ty).await? {
200        Some(id) => load(app, &id).await,
201        None => Ok(None),
202    }
203}
204
205/// What the station should be told at Authorize, depending on its cost mode.
206pub struct AuthorizeExtras {
207    /// I01: tariff text for `IdTokenInfo.personalMessage` (central cost).
208    pub personal_message: Option<String>,
209    /// I08 (2.1 local cost): driver-specific tariff for `AuthorizeResponse.tariff`, without validFrom.
210    pub tariff: Option<Tariff>,
211}
212
213pub async fn on_authorize(
214    app: &App,
215    station_id: i64,
216    version: OcppVersion,
217    token: &str,
218    ty: Option<&str>,
219) -> Result<AuthorizeExtras, AppError> {
220    let none = AuthorizeExtras {
221        personal_message: None,
222        tariff: None,
223    };
224    let Some(station) = db::stations::get(&app.db, station_id).await? else {
225        return Ok(none);
226    };
227    match (station.cost_mode.as_str(), version) {
228        ("Central", OcppVersion::V201 | OcppVersion::V21) => {
229            let t = applicable(app, station_id, 0, Some((token, ty)), DateTime::now()).await?;
230            Ok(AuthorizeExtras {
231                personal_message: t.as_ref().map(text),
232                tariff: None,
233            })
234        }
235        ("Local", OcppVersion::V21) => {
236            let t = driver_tariff(app, token, ty).await?.map(|mut t| {
237                t.valid_from = None; // I08.FR.09
238                t
239            });
240            Ok(AuthorizeExtras {
241                personal_message: None,
242                tariff: t,
243            })
244        }
245        _ => Ok(none),
246    }
247}
248
249/// Version-neutral content of a 2.x TransactionEvent relevant for cost.
250#[derive(Debug, Default)]
251pub struct TxEventCost<'a> {
252    pub event_type: &'a str,
253    pub at: DateTime,
254    pub seq_no: i64,
255    pub charging_state: Option<&'a str>,
256    pub energy_wh: Option<f64>,
257    pub power_w: Option<f64>,
258    pub current_a: Option<f64>,
259    /// 2.1 transactionInfo.tariffId.
260    pub tariff_id: Option<&'a str>,
261    /// 2.1 costDetails (local cost calculation).
262    pub cost_details: Option<Value>,
263    pub payment_brand: Option<&'a str>,
264    pub payment_recognition: Option<&'a str>,
265}
266
267fn round2(v: f64) -> f64 {
268    (v * 100.0).round() / 100.0
269}
270
271fn zone_for(app: &App, station: &Station) -> std::sync::Arc<crate::tz::Zone> {
272    app.zones.get(
273        &app.cfg.zoneinfo_dir,
274        station
275            .timezone
276            .as_deref()
277            .unwrap_or(&app.cfg.default_timezone),
278    )
279}
280
281/// Calculates the cost of a transaction from its samples and tariff(s).
282pub async fn calculate(
283    app: &App,
284    station: &Station,
285    tx: &TxCostInfo,
286    final_: bool,
287) -> Result<Option<ocpp21::CostDetails>, AppError> {
288    let Some(first_id) = &tx.tariff_id else {
289        return Ok(None);
290    };
291    let Some(first) = load(app, first_id).await? else {
292        return Ok(None);
293    };
294    let rows = db::tariffs::samples(&app.db, tx.id).await?;
295    let start = tx
296        .started_at
297        .map(|t| t.0)
298        .or(rows.first().map(|r| r.at.0))
299        .unwrap_or_else(DateTime::now);
300    let end = if final_ {
301        tx.ended_at
302            .or(tx.last_event_at)
303            .map(|t| t.0)
304            .unwrap_or_else(DateTime::now)
305    } else {
306        DateTime::now().max(start)
307    };
308    // Tariff changes reported by the station (2.1 TariffChanged) apply from that moment on (I11.FR.08).
309    let mut tariffs = vec![(start, first)];
310    let mut current_id = first_id.clone();
311    for r in &rows {
312        if let Some(id) = &r.tariff_id
313            && *id != current_id
314            && let Some(t) = load(app, id).await?
315        {
316            tariffs.push((r.at.0, t));
317            current_id = id.clone();
318        }
319    }
320    let samples: Vec<cost::Sample> = rows
321        .iter()
322        .map(|r| cost::Sample {
323            at: r.at.0,
324            energy_wh: r.energy_wh,
325            power_w: r.power_w,
326            current_a: r.current_a,
327            charging_state: r.charging_state.clone(),
328        })
329        .collect();
330    let zone = zone_for(app, station);
331    let input = cost::Input {
332        tariffs: &tariffs,
333        start,
334        end: end.max(start),
335        samples: &samples,
336        zone: &zone,
337        evse_kind: None,
338        payment_brand: tx.payment_brand.as_deref(),
339        payment_recognition: tx.payment_recognition.as_deref(),
340        reserved_at: tx.reserved_at.map(|t| t.0),
341        final_,
342    };
343    Ok(Some(cost::calculate(&input)))
344}
345
346/// Records a TransactionEvent for cost purposes and returns the `totalCost`
347/// to put in the TransactionEventResponse, if any (I02, I03, I12).
348pub async fn on_transaction_event(
349    app: &App,
350    station_id: i64,
351    transaction_id: i64,
352    e: &TxEventCost<'_>,
353) -> Result<Option<f64>, AppError> {
354    db::tariffs::insert_sample(
355        &app.db,
356        transaction_id,
357        &db::tariffs::NewSample {
358            at: Some(Ts(e.at)),
359            seq_no: Some(e.seq_no.clamp(0, i32::MAX as i64) as i32),
360            event_type: Some(e.event_type),
361            charging_state: e.charging_state,
362            energy_wh: e.energy_wh,
363            power_w: e.power_w,
364            current_a: e.current_a,
365            tariff_id: e.tariff_id,
366        },
367    )
368    .await?;
369    if e.payment_brand.is_some() || e.payment_recognition.is_some() {
370        db::tariffs::set_payment(
371            &app.db,
372            transaction_id,
373            e.payment_brand,
374            e.payment_recognition,
375        )
376        .await?;
377    }
378    let Some(station) = db::stations::get(&app.db, station_id).await? else {
379        return Ok(None);
380    };
381    if station.cost_mode == "None" {
382        return Ok(None);
383    }
384    let Some(tx) = db::tariffs::tx_info(&app.db, transaction_id).await? else {
385        return Ok(None);
386    };
387
388    // The transaction's tariff, fixed at its start (I07.FR.24).
389    if tx.tariff_id.is_none() {
390        let tariff = match e.tariff_id {
391            Some(id) => load(app, id).await?,
392            None => {
393                let token = tx
394                    .id_token
395                    .as_deref()
396                    .map(|t| (t, tx.id_token_type.as_deref()));
397                applicable(
398                    app,
399                    station_id,
400                    tx.evse_number.unwrap_or(0),
401                    token,
402                    tx.started_at.map(|t| t.0).unwrap_or(e.at),
403                )
404                .await?
405            }
406        };
407        if let Some(t) = tariff {
408            db::tariffs::set_tx_tariff_once(&app.db, transaction_id, &t.tariff_id, &t.currency)
409                .await?;
410        }
411    }
412    let ended = e.event_type == "Ended";
413    if let Some(details) = &e.cost_details {
414        db::tariffs::set_station_cost(&app.db, transaction_id, details).await?;
415    }
416    let tx = db::tariffs::tx_info(&app.db, transaction_id)
417        .await?
418        .expect("exists");
419
420    // CSMS calculation (central cost, verification of local cost).
421    let csms = calculate(app, &station, &tx, ended).await?;
422    if let Some(c) = &csms {
423        let v = serde_json::to_value(c).map_err(|e| AppError::Internal(e.to_string()))?;
424        db::tariffs::set_cost(
425            &app.db,
426            transaction_id,
427            &v,
428            &c.total_cost.currency,
429            c.total_cost.total.excl_tax,
430            c.total_cost.total.incl_tax,
431        )
432        .await?;
433    }
434    // Total of the station when it calculated successfully (local cost), else the CSMS one.
435    let station_total = e
436        .cost_details
437        .as_ref()
438        .filter(|d| d.get("failureToCalculate") != Some(&Value::Bool(true)))
439        .and_then(|d| {
440            d["totalCost"]["total"]["inclTax"]
441                .as_f64()
442                .or(d["totalCost"]["total"]["exclTax"].as_f64())
443        });
444    if let (Some(st), Some(c)) = (station_total, &csms)
445        && let Some(own) = c.total_cost.total.incl_tax
446        && (st - own).abs() > 0.01
447    {
448        tracing::warn!(station = %station.ocpp_identity, transaction = %tx.transaction_uid, station_total = st, csms_total = own, "station cost differs from the CSMS calculation");
449    }
450    let total = station_total.or_else(|| csms.as_ref().and_then(|c| c.total_cost.total.incl_tax));
451    Ok(match (ended, station.cost_mode.as_str()) {
452        (true, _) => total.map(round2),
453        // I02 (alternative scenario): running cost in the response to Updated events.
454        (false, "Central") if e.event_type == "Updated" => total.map(round2),
455        _ => None,
456    })
457}
458
459/// Cost of a transaction now (running) or final, plus what the station reported.
460#[derive(Debug, Serialize)]
461pub struct CostReport {
462    pub transaction_id: i64,
463    pub tariff_id: Option<String>,
464    pub final_: bool,
465    pub csms: Option<ocpp21::CostDetails>,
466    pub station: Option<Value>,
467}
468
469pub async fn report(app: &App, transaction_id: i64) -> Result<CostReport, AppError> {
470    let tx = db::tariffs::tx_info(&app.db, transaction_id)
471        .await?
472        .ok_or_else(|| AppError::NotFound(format!("transaction {transaction_id} not found")))?;
473    let station = db::stations::get(&app.db, tx.station_id)
474        .await?
475        .ok_or_else(|| AppError::NotFound("station".into()))?;
476    let final_ = tx.status == "Ended";
477    let csms = calculate(app, &station, &tx, final_).await?;
478    Ok(CostReport {
479        transaction_id,
480        tariff_id: tx.tariff_id.clone(),
481        final_,
482        csms,
483        station: tx.station_cost_details,
484    })
485}
486
487/// Assigns a default tariff; for 2.1 local-cost stations it is sent with SetDefaultTariff (I07).
488pub async fn set_default(
489    app: &App,
490    station: &Station,
491    evse: i64,
492    tariff_id: &str,
493) -> Result<Value, AppError> {
494    let row = db::tariffs::get(&app.db, tariff_id)
495        .await?
496        .ok_or_else(|| AppError::NotFound(format!("tariff {tariff_id} not found")))?;
497    let tariff = decode(&row.tariff).map_err(AppError::Internal)?;
498    let evse32 = evse.clamp(0, i32::MAX as i64) as i32;
499    let valid_from = tariff.valid_from.map(Ts);
500    if station.cost_mode == "Local" {
501        let version = calls::connected_version(app, &station.ocpp_identity).await?;
502        if version != OcppVersion::V21 {
503            return Err(AppError::BadRequest(
504                "local cost calculation (SetDefaultTariff) requires OCPP 2.1".into(),
505            ));
506        }
507        let response = calls::call(
508            app,
509            &station.ocpp_identity,
510            version,
511            "SetDefaultTariff",
512            json!({ "evseId": evse, "tariff": row.tariff }),
513        )
514        .await?;
515        let status = calls::status_of(&response).to_owned();
516        if status == "Accepted" {
517            db::tariffs::assign_default(
518                &app.db,
519                station.id,
520                evse32,
521                tariff_id,
522                valid_from,
523                Some(&status),
524            )
525            .await?;
526        }
527        return Ok(json!({ "status": status, "response": response }));
528    }
529    db::tariffs::assign_default(&app.db, station.id, evse32, tariff_id, valid_from, None).await?;
530    Ok(json!({ "status": "Accepted" }))
531}
532
533/// Removes a default tariff; for 2.1 local-cost stations with ClearTariffs (I10).
534pub async fn clear_default(
535    app: &App,
536    station: &Station,
537    tariff_id: &str,
538    evse: Option<i64>,
539) -> Result<Value, AppError> {
540    let evse32 = evse.map(|e| e.clamp(0, i32::MAX as i64) as i32);
541    if station.cost_mode == "Local" {
542        let version = calls::connected_version(app, &station.ocpp_identity).await?;
543        if version == OcppVersion::V21 {
544            let payload =
545                crate::smart::strip_nulls(json!({ "tariffIds": [tariff_id], "evseId": evse }));
546            let response = calls::call(
547                app,
548                &station.ocpp_identity,
549                version,
550                "ClearTariffs",
551                payload,
552            )
553            .await?;
554            let cleared = response["clearTariffsResult"].as_array().is_some_and(|r| {
555                r.iter()
556                    .any(|x| x["status"] == "Accepted" || x["status"] == "NoTariff")
557            });
558            if cleared {
559                db::tariffs::remove_default(&app.db, station.id, tariff_id, evse32).await?;
560            }
561            return Ok(response);
562        }
563    }
564    let n = db::tariffs::remove_default(&app.db, station.id, tariff_id, evse32).await?;
565    Ok(json!({ "removed": n }))
566}
567
568/// I11: changes the tariff of an ongoing transaction (2.1 local cost calculation).
569pub async fn change_transaction_tariff(
570    app: &App,
571    transaction_id: i64,
572    tariff_id: &str,
573) -> Result<Value, AppError> {
574    let tx = db::tariffs::tx_info(&app.db, transaction_id)
575        .await?
576        .ok_or_else(|| AppError::NotFound(format!("transaction {transaction_id} not found")))?;
577    if tx.status != "Active" {
578        return Err(AppError::Conflict(
579            "the transaction is not active (I11.FR.04)".into(),
580        ));
581    }
582    let station = db::stations::get(&app.db, tx.station_id)
583        .await?
584        .ok_or_else(|| AppError::NotFound("station".into()))?;
585    let version = calls::connected_version(app, &station.ocpp_identity).await?;
586    if version != OcppVersion::V21 {
587        return Err(AppError::BadRequest(
588            "ChangeTransactionTariff requires OCPP 2.1".into(),
589        ));
590    }
591    let row = db::tariffs::get(&app.db, tariff_id)
592        .await?
593        .ok_or_else(|| AppError::NotFound(format!("tariff {tariff_id} not found")))?;
594    if let Some(cur) = &tx.currency
595        && *cur != row.currency
596    {
597        return Err(AppError::Conflict(format!(
598            "the currency cannot change during a transaction ({cur} → {}) (I11.FR.05)",
599            row.currency
600        )));
601    }
602    calls::call(
603        app,
604        &station.ocpp_identity,
605        version,
606        "ChangeTransactionTariff",
607        json!({ "transactionId": tx.transaction_uid, "tariff": row.tariff }),
608    )
609    .await
610}