use rust_decimal::Decimal;
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::emv_qr::{
build_emv_merchant_payload, EmvMerchantProfile, EmvQrError,
};
#[derive(Debug, Clone)]
pub struct EmvQrConfigInput {
pub bank_account_id: Option<Uuid>,
pub merchant_name: String,
pub merchant_city: String,
pub country: String,
pub mcc: String,
pub gui: String,
pub merchant_identifier: String,
pub currency: String,
pub initiation: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct EmvQrConfigAck {
pub config_id: Uuid,
pub bank_account_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize)]
pub struct EmvPayloadAck {
pub payload: String,
pub currency: String,
pub initiation_method: String,
}
#[derive(Debug)]
pub enum EmvQrServiceError {
Build(EmvQrError),
ConfigMissing(Uuid),
Internal(String),
}
impl EmvQrServiceError {
pub fn code(&self) -> &'static str {
match self {
EmvQrServiceError::Build(e) => e.code(),
EmvQrServiceError::ConfigMissing(_) => "qr_config_missing",
EmvQrServiceError::Internal(_) => "internal_error",
}
}
pub fn http_status(&self) -> u16 {
match self {
EmvQrServiceError::Build(_) => 422,
EmvQrServiceError::ConfigMissing(_) => 404,
EmvQrServiceError::Internal(_) => 500,
}
}
}
impl std::fmt::Display for EmvQrServiceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EmvQrServiceError::Build(e) => write!(f, "{e}"),
EmvQrServiceError::ConfigMissing(company_id) => write!(
f,
"qr_config_missing: no EMV QR configuration for company {company_id}"
),
EmvQrServiceError::Internal(e) => write!(f, "internal_error: {e}"),
}
}
}
impl std::error::Error for EmvQrServiceError {}
impl From<EmvQrError> for EmvQrServiceError {
fn from(e: EmvQrError) -> Self {
EmvQrServiceError::Build(e)
}
}
fn internal(e: impl std::fmt::Display) -> EmvQrServiceError {
EmvQrServiceError::Internal(e.to_string())
}
#[derive(Clone)]
pub struct EmvQrService {
pool: PgPool,
}
fn ambient_company() -> Uuid {
backbone_orm::org_scope::current_org_scope()
.and_then(|s| s.legacy_company_id())
.unwrap_or_default()
}
impl EmvQrService {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn profile_of(c: &EmvQrConfigInput) -> EmvMerchantProfile {
EmvMerchantProfile {
merchant_name: c.merchant_name.clone(),
merchant_city: c.merchant_city.clone(),
country: c.country.clone(),
mcc: c.mcc.clone(),
gui: c.gui.clone(),
merchant_identifier: c.merchant_identifier.clone(),
initiation: c.initiation.clone(),
}
}
pub async fn upsert_config(
&self,
input: EmvQrConfigInput,
) -> Result<EmvQrConfigAck, EmvQrServiceError> {
build_emv_merchant_payload(&Self::profile_of(&input), &input.currency, None, None)?;
let mut tx = self
.pool
.begin()
.await
.map_err(internal)?;
if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
backbone_orm::org_scope::bind_org_scope_on(&mut tx, &scope)
.await
.map_err(internal)?;
backbone_orm::audit_context::relay_ambient_audit_on(&mut tx)
.await
.map_err(internal)?;
}
let config_id: Uuid = sqlx::query_scalar(
r#"INSERT INTO accounting.emv_qr_configs
(bank_account_id, merchant_name, merchant_city,
country_code, mcc, gui, merchant_identifier, currency,
initiation_method, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NOW())
ON CONFLICT
(COALESCE(bank_account_id, '00000000-0000-0000-0000-000000000000'::uuid))
DO UPDATE SET
merchant_name = EXCLUDED.merchant_name,
merchant_city = EXCLUDED.merchant_city,
country_code = EXCLUDED.country_code,
mcc = EXCLUDED.mcc,
gui = EXCLUDED.gui,
merchant_identifier = EXCLUDED.merchant_identifier,
currency = EXCLUDED.currency,
initiation_method = EXCLUDED.initiation_method,
updated_at = NOW()
RETURNING id"#,
)
.bind(input.bank_account_id)
.bind(&input.merchant_name)
.bind(&input.merchant_city)
.bind(input.country.trim().to_ascii_uppercase())
.bind(&input.mcc)
.bind(&input.gui)
.bind(&input.merchant_identifier)
.bind(&input.currency)
.bind(&input.initiation)
.fetch_one(&mut *tx)
.await
.map_err(internal)?;
tx.commit().await.map_err(internal)?;
Ok(EmvQrConfigAck {
config_id,
bank_account_id: input.bank_account_id,
})
}
pub async fn invoice_payload(
&self,
bank_account_id: Option<Uuid>,
amount: Option<Decimal>,
currency: Option<String>,
reference: Option<String>,
) -> Result<EmvPayloadAck, EmvQrServiceError> {
let mut tx = self.pool.begin().await.map_err(internal)?;
if let Some(scope) = backbone_orm::org_scope::current_org_scope() {
backbone_orm::org_scope::bind_org_scope_on(&mut tx, &scope)
.await
.map_err(internal)?;
backbone_orm::audit_context::relay_ambient_audit_on(&mut tx)
.await
.map_err(internal)?;
}
let row = sqlx::query_as::<_, StoredConfig>(
r#"SELECT merchant_name, merchant_city, country_code, mcc, gui,
merchant_identifier, currency, initiation_method
FROM accounting.emv_qr_configs
WHERE (bank_account_id IS NOT DISTINCT FROM $1
OR bank_account_id IS NULL)
ORDER BY (bank_account_id IS NOT DISTINCT FROM $1) DESC
LIMIT 1"#,
)
.bind(bank_account_id)
.fetch_optional(&mut *tx)
.await
.map_err(internal)?;
tx.commit().await.map_err(internal)?;
let Some(cfg) = row else {
return Err(EmvQrServiceError::ConfigMissing(ambient_company()));
};
let profile = EmvMerchantProfile {
merchant_name: cfg.merchant_name,
merchant_city: cfg.merchant_city,
country: cfg.country_code,
mcc: cfg.mcc,
gui: cfg.gui,
merchant_identifier: cfg.merchant_identifier,
initiation: cfg.initiation_method.clone(),
};
let currency = currency.unwrap_or(cfg.currency);
let payload =
build_emv_merchant_payload(&profile, ¤cy, amount, reference.as_deref())?;
Ok(EmvPayloadAck {
payload,
currency,
initiation_method: cfg.initiation_method,
})
}
}
#[derive(sqlx::FromRow)]
struct StoredConfig {
merchant_name: String,
merchant_city: String,
country_code: String,
mcc: String,
gui: String,
merchant_identifier: String,
currency: String,
initiation_method: String,
}