use grabcli_core::error::{Error, Result};
use grabcli_core::location::Location;
use grabcli_core::money::Money;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use crate::cart::Cart;
#[derive(Debug, Clone)]
pub struct Fee {
pub name: String,
pub amount: Money,
}
#[derive(Debug, Clone)]
pub struct Promo {
pub name: String,
pub amount: Money,
}
#[derive(Debug, Clone, Default)]
pub struct Quote {
pub merchant_name: String,
pub subtotal: Money,
pub delivery_before: Money,
pub delivery_after: Money,
pub fees: Vec<Fee>,
pub promos: Vec<Promo>,
pub total_before: Money,
pub total_after: Money,
pub eta_min: Option<i64>,
pub quote_signature: String,
pub payment_type_id: String,
pub address: String,
pub address_detail: Option<String>,
pub note_to_driver: Option<String>,
pub poi_id: String,
pub id: String,
}
pub fn build_body(
cart: &Cart,
loc: &Location,
payment_type_id: &str,
promo_codes: &[String],
) -> Value {
let promo: Vec<Value> = promo_codes
.iter()
.map(|c| json!({ "promoCode": c }))
.collect();
json!({
"merchants": [cart.to_quote_merchant()],
"paymentTypeID": payment_type_id,
"promoCode": promo,
"comment": "",
"campaignIDs": [],
"enterprise": null,
"scheduledTime": null,
"countryCode": "PH",
"destination": {
"longitude": loc.longitude,
"latitude": loc.latitude,
"address": loc.address,
"keywords": "",
"meta": "",
"poiID": loc.id,
},
})
}
fn money(v: &Value, ptr: &str) -> Money {
Money(v.pointer(ptr).and_then(Value::as_i64).unwrap_or(0))
}
impl Quote {
pub fn parse(
resp: &Value,
cart: &Cart,
loc: &Location,
payment_type_id: &str,
promo_codes: &[String],
) -> Result<Self> {
if let Some(msg) = resp.get("errMessage").and_then(Value::as_str)
&& !msg.is_empty()
{
return Err(Error::new(
grabcli_core::error::ErrorKind::Api,
format!("quote error: {msg}"),
));
}
let merchant_name = resp
.pointer("/merchants/0/name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let mut fees = Vec::new();
let mut promos = Vec::new();
if let Some(bs) = resp.get("breakdowns").and_then(Value::as_array) {
for b in bs {
let key = b.get("key").and_then(Value::as_str).unwrap_or_default();
match key {
"service_fees" => {
for d in b
.get("details")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
fees.push(Fee {
name: d
.get("name")
.and_then(Value::as_str)
.unwrap_or("Fee")
.to_string(),
amount: money(d, "/value/amountInMinor"),
});
}
}
"stackable_offer" => {
promos.push(Promo {
name: b
.get("name")
.and_then(Value::as_str)
.unwrap_or("Promo")
.to_string(),
amount: money(b, "/value/amountInMinor"),
});
}
_ => {}
}
}
}
let total_before = money(resp, "/totalQuote/priceInMinorUnit");
let total_after = {
let r = money(resp, "/totalQuote/reducedPriceInMinorUnit");
if r.minor() == 0 { total_before } else { r }
};
let quote_signature = resp
.get("quoteSignature")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let address = resp
.pointer("/destination/address")
.and_then(Value::as_str)
.unwrap_or(&loc.address)
.to_string();
let poi_id = resp
.pointer("/destination/poiID")
.and_then(Value::as_str)
.unwrap_or(&loc.id)
.to_string();
let mut q = Quote {
address_detail: loc.address_detail.clone(),
note_to_driver: loc.note_to_driver.clone(),
merchant_name,
subtotal: money(resp, "/foodQuote/priceInMinorUnit"),
delivery_before: money(resp, "/deliveryQuote/priceInMinorUnit"),
delivery_after: {
let r = money(resp, "/deliveryQuote/reducedPriceInMinorUnit");
if r.minor() == 0 {
money(resp, "/deliveryQuote/priceInMinorUnit")
} else {
r
}
},
fees,
promos,
total_before,
total_after,
eta_min: resp
.pointer("/merchants/0/merchantETA/maxETAInMinute")
.and_then(Value::as_i64),
quote_signature,
payment_type_id: payment_type_id.to_string(),
address,
poi_id,
id: String::new(),
};
q.id = q.compute_id(cart, promo_codes);
Ok(q)
}
fn compute_id(&self, cart: &Cart, promo_codes: &[String]) -> String {
let canon = json!({
"cart": cart,
"poiID": self.poi_id,
"payment": self.payment_type_id,
"promos": promo_codes,
"total": self.total_after.minor(),
});
let bytes = serde_json::to_vec(&canon).unwrap_or_default();
let digest = Sha256::digest(&bytes);
digest.iter().take(6).map(|b| format!("{b:02x}")).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cart::{Cart, CartItem};
fn loc() -> Location {
let json = r#"{"id":"POI-X","latitude":1.0,"longitude":2.0,"address":"1 Example St","countryCode":"PH"}"#;
serde_json::from_str(json).expect("loc")
}
fn cart() -> Cart {
Cart {
merchant_id: "2-EX".into(),
section: "S1".into(),
items: vec![CartItem {
item_id: "I1".into(),
quantity: 1,
modifiers: vec![],
comment: String::new(),
}],
voucher: None,
}
}
fn resp() -> Value {
serde_json::json!({
"quoteSignature": "SIG-EXAMPLE",
"merchants": [{"name": "Example Noodles", "merchantETA": {"maxETAInMinute": 45}}],
"destination": {"address": "1 Example St", "poiID": "POI-X"},
"foodQuote": {"priceInMinorUnit": 44000},
"deliveryQuote": {"priceInMinorUnit": 18000, "reducedPriceInMinorUnit": 13100},
"totalQuote": {"priceInMinorUnit": 62000, "reducedPriceInMinorUnit": 57100},
"breakdowns": [
{"key": "subtotal", "value": {"amountInMinor": 44000}},
{"key": "service_fees", "name": "Applicable fees",
"details": [{"name": "Delivery fee", "value": {"amountInMinor": 18000}}]},
{"key": "stackable_offer", "name": "₱49 off delivery", "value": {"amountInMinor": -4900}}
]
})
}
#[test]
fn parses_quote() -> Result<()> {
let q = Quote::parse(&resp(), &cart(), &loc(), "PAY-1", &[])?;
assert_eq!(q.subtotal, Money(44000));
assert_eq!(q.delivery_after, Money(13100));
assert_eq!(q.total_after, Money(57100));
assert_eq!(q.promos[0].amount, Money(-4900));
assert_eq!(q.fees[0].name, "Delivery fee");
assert_eq!(q.eta_min, Some(45));
assert_eq!(q.quote_signature, "SIG-EXAMPLE");
assert_eq!(q.id.len(), 12);
Ok(())
}
#[test]
fn quote_id_is_stable_and_sensitive() -> Result<()> {
let a = Quote::parse(&resp(), &cart(), &loc(), "PAY-1", &[])?;
let b = Quote::parse(&resp(), &cart(), &loc(), "PAY-1", &[])?;
assert_eq!(a.id, b.id);
let c = Quote::parse(&resp(), &cart(), &loc(), "PAY-2", &[])?;
assert_ne!(a.id, c.id);
Ok(())
}
#[test]
fn build_body_shape() {
let body = build_body(&cart(), &loc(), "PAY-1", &["SAVE10".into()]);
assert_eq!(body["paymentTypeID"], "PAY-1");
assert_eq!(body["promoCode"][0]["promoCode"], "SAVE10");
assert_eq!(body["destination"]["poiID"], "POI-X");
assert_eq!(body["merchants"][0]["ID"], "2-EX");
}
}