1pub 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
45type CalendarFields<'a> = (
47 Option<&'a str>,
48 Option<&'a str>,
49 Option<&'a str>,
50 Option<&'a str>,
51);
52
53pub 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
129pub 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
174pub 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
205pub struct AuthorizeExtras {
207 pub personal_message: Option<String>,
209 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; t
239 });
240 Ok(AuthorizeExtras {
241 personal_message: None,
242 tariff: t,
243 })
244 }
245 _ => Ok(none),
246 }
247}
248
249#[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 pub tariff_id: Option<&'a str>,
261 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
281pub 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 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
346pub 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 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 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 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 (false, "Central") if e.event_type == "Updated" => total.map(round2),
455 _ => None,
456 })
457}
458
459#[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
487pub 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
533pub 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
568pub 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}