use crate::channel::Channel;
use crate::charge::{Charge, ChargeRequest, ChargeStatus, Instructions};
use crate::gateway::{Gateway, GatewayFuture};
use crate::signature::{hmac_sha256_hex, verify_hmac_sha256_hex};
use crate::transfer::{Transfer, TransferRequest, TransferStatus};
use crate::webhook::{EventKind, WebhookEvent};
use rustlavel_core::{Error, Json, Result};
use rustlavel_http::Headers;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
pub const SIGNATURE_HEADER: &str = "x-fake-signature";
pub struct FakeGateway {
secret: Vec<u8>,
next: AtomicU64,
charges: Mutex<HashMap<String, Charge>>,
transfers: Mutex<HashMap<String, Transfer>>,
transfer_references: Mutex<HashMap<String, String>>,
}
impl FakeGateway {
pub fn new(secret: impl Into<Vec<u8>>) -> FakeGateway {
FakeGateway {
secret: secret.into(),
next: AtomicU64::new(1),
charges: Mutex::new(HashMap::new()),
transfers: Mutex::new(HashMap::new()),
transfer_references: Mutex::new(HashMap::new()),
}
}
fn id(&self, prefix: &str) -> String {
format!("{prefix}_{}", self.next.fetch_add(1, Ordering::Relaxed))
}
pub fn mark_paid(&self, id: &str) -> Option<Charge> {
let mut charges = self.charges.lock().expect("the fake's charges are poisoned");
let charge = charges.get_mut(id)?;
if charge.status != ChargeStatus::Pending {
return None;
}
charge.status = ChargeStatus::Paid;
charge.paid_at = Some(now());
Some(charge.clone())
}
pub fn mark_expired(&self, id: &str) -> Option<Charge> {
let mut charges = self.charges.lock().expect("the fake's charges are poisoned");
let charge = charges.get_mut(id)?;
if charge.status != ChargeStatus::Pending {
return None;
}
charge.status = ChargeStatus::Expired;
Some(charge.clone())
}
pub fn webhook_for(&self, charge_id: &str, event_id: &str) -> Option<(Headers, Vec<u8>)> {
let charge = self.charges.lock().expect("the fake's charges are poisoned").get(charge_id)?.clone();
let kind = match charge.status {
ChargeStatus::Paid => "charge.paid",
ChargeStatus::Expired => "charge.expired",
ChargeStatus::Failed => "charge.failed",
_ => return None,
};
let body = Json::object([
("event_id", Json::from(event_id)),
("type", Json::from(kind)),
("charge", charge.to_json()),
])
.to_string()
.into_bytes();
Some((self.sign(&body), body))
}
pub fn sign(&self, body: &[u8]) -> Headers {
let mut headers = Headers::new();
headers.set(SIGNATURE_HEADER, hmac_sha256_hex(&self.secret, body));
headers.set("content-type", "application/json");
headers
}
pub fn charges(&self) -> Vec<Charge> {
self.charges.lock().expect("the fake's charges are poisoned").values().cloned().collect()
}
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
fn instructions_for(channel: Channel, id: &str) -> Instructions {
match channel {
Channel::VirtualAccount(_) => Instructions {
account_number: Some(format!("8808{:012}", id.len() * 7919)),
..Instructions::default()
},
Channel::Qris => Instructions {
qr_string: Some(format!("00020101021226fake{id}6304ABCD")),
..Instructions::default()
},
Channel::EWallet(_) => Instructions {
url: Some(format!("https://fake.gateway.test/pay/{id}")),
..Instructions::default()
},
Channel::Retail(_) => Instructions {
payment_code: Some(format!("{:08}", id.len() * 104729)),
..Instructions::default()
},
}
}
impl Gateway for FakeGateway {
fn name(&self) -> &'static str {
"fake"
}
fn create_charge<'a>(&'a self, request: &'a ChargeRequest) -> GatewayFuture<'a, Charge> {
Box::pin(async move {
request.validate()?;
let id = self.id("fake_ch");
let charge = Charge {
id: id.clone(),
reference: request.reference.clone(),
status: ChargeStatus::Pending,
channel: request.channel,
amount: request.amount.clone(),
instructions: instructions_for(request.channel, &id),
expires_at: now() + request.expires_in.as_secs(),
paid_at: None,
metadata: request.metadata.clone(),
};
self.charges.lock().expect("the fake's charges are poisoned").insert(id, charge.clone());
Ok(charge)
})
}
fn charge<'a>(&'a self, id: &'a str) -> GatewayFuture<'a, Charge> {
Box::pin(async move {
self.charges
.lock()
.expect("the fake's charges are poisoned")
.get(id)
.cloned()
.ok_or_else(|| Error::msg(format!("no charge `{id}`")))
})
}
fn cancel_charge<'a>(&'a self, id: &'a str) -> GatewayFuture<'a, Charge> {
Box::pin(async move {
let mut charges = self.charges.lock().expect("the fake's charges are poisoned");
let charge = charges.get_mut(id).ok_or_else(|| Error::msg(format!("no charge `{id}`")))?;
if charge.status != ChargeStatus::Pending {
return Err(Error::msg(format!(
"charge `{id}` is {} and cannot be cancelled; a paid charge is refunded, not cancelled",
charge.status.as_str()
)));
}
charge.status = ChargeStatus::Cancelled;
Ok(charge.clone())
})
}
fn transfer<'a>(&'a self, request: &'a TransferRequest) -> GatewayFuture<'a, Transfer> {
Box::pin(async move {
request.validate()?;
{
let refs = self.transfer_references.lock().expect("poisoned");
if let Some(existing) = refs.get(&request.reference) {
return Err(Error::msg(format!(
"a transfer with reference `{}` already exists as `{existing}`",
request.reference
)));
}
}
let id = self.id("fake_tr");
let transfer = Transfer {
id: id.clone(),
reference: request.reference.clone(),
status: TransferStatus::Completed,
amount: request.amount.clone(),
failure: None,
};
self.transfer_references.lock().expect("poisoned").insert(request.reference.clone(), id.clone());
self.transfers.lock().expect("poisoned").insert(id, transfer.clone());
Ok(transfer)
})
}
fn transfer_status<'a>(&'a self, id: &'a str) -> GatewayFuture<'a, Transfer> {
Box::pin(async move {
self.transfers
.lock()
.expect("poisoned")
.get(id)
.cloned()
.ok_or_else(|| Error::msg(format!("no transfer `{id}`")))
})
}
fn verify_webhook<'a>(&'a self, headers: &'a Headers, body: &'a [u8]) -> Result<WebhookEvent> {
let presented = headers
.get(SIGNATURE_HEADER)
.ok_or_else(|| Error::msg(format!("no {SIGNATURE_HEADER} header")))?;
if !verify_hmac_sha256_hex(&self.secret, body, presented) {
return Err(Error::msg("the signature did not verify"));
}
let json = Json::parse(&String::from_utf8_lossy(body))?;
let event_id = json.get("event_id").and_then(Json::as_str).ok_or_else(|| Error::msg("no event_id"))?;
let kind = match json.get("type").and_then(Json::as_str).unwrap_or("") {
"charge.paid" => EventKind::ChargePaid,
"charge.expired" => EventKind::ChargeExpired,
"charge.failed" => EventKind::ChargeFailed,
other => EventKind::Other(other.to_string()),
};
let charge = json.get("charge").and_then(charge_from_json);
Ok(WebhookEvent { id: event_id.to_string(), kind, charge, transfer: None, raw: json })
}
}
fn charge_from_json(json: &Json) -> Option<Charge> {
Some(Charge {
id: json.get("id")?.as_str()?.to_string(),
reference: json.get("reference")?.as_str()?.to_string(),
status: ChargeStatus::parse(json.get("status")?.as_str()?)?,
channel: Channel::parse(json.get("channel")?.as_str()?)?,
amount: crate::money::Money::from_json(json.get("amount")?)?,
instructions: Instructions {
account_number: json.get("instructions.account_number").and_then(Json::as_str).map(str::to_string),
qr_string: json.get("instructions.qr_string").and_then(Json::as_str).map(str::to_string),
url: json.get("instructions.url").and_then(Json::as_str).map(str::to_string),
payment_code: json.get("instructions.payment_code").and_then(Json::as_str).map(str::to_string),
},
expires_at: json.get("expires_at").and_then(Json::as_i64).unwrap_or(0).max(0) as u64,
paid_at: json.get("paid_at").and_then(Json::as_i64).map(|at| at.max(0) as u64),
metadata: json.get("metadata").cloned().unwrap_or(Json::Null),
})
}