Skip to main content

backbone_accounting/presentation/http/
emv_qr_handler.rs

1//! EMV(QRCPS)/QRIS display HTTP surface — hand-authored (user-owned; see
2//! `metaphor.codegen.yaml`).
3//!
4//! Two routes over the `EmvQrService`:
5//! - `PUT  /accounting/emv-qr/config`   — write/replace the QR
6//!   display configuration (validated through the domain builder first)
7//! - `GET  /accounting/emv-qr/payload`  — render the merchant-presented payload
8//!   for one invoice display (amount / currency / reference as query params —
9//!   invoice fields live in the billing module and arrive here already read)
10//!
11//! The module default-deny posture applies: hosts mount these behind their own
12//! auth + role layers; the tenant-consistency check below refuses a body/query
13//! company that disagrees with the ambient scope.
14//!
15//! Tenancy (ADR-0029): the `company_id` wire fields are the legacy twin — kept
16//! so unstripped callers compile and run unchanged; the module keys no statement
17//! on them and the composing service's tenancy decorator scopes the underlying
18//! reads/writes.
19
20use std::sync::Arc;
21
22use axum::{
23    extract::{Query, State},
24    http::StatusCode,
25    response::IntoResponse,
26    routing::{get, put},
27    Json, Router,
28};
29use rust_decimal::Decimal;
30use serde::{Deserialize, Serialize};
31use uuid::Uuid;
32
33use crate::application::service::emv_qr_service::{
34    EmvPayloadAck, EmvQrConfigAck, EmvQrConfigInput, EmvQrService, EmvQrServiceError,
35};
36
37#[derive(Debug, Deserialize)]
38pub struct EmvQrConfigBody {
39    #[serde(default)]
40    pub bank_account_id: Option<Uuid>,
41    pub merchant_name: String,
42    pub merchant_city: String,
43    #[serde(default = "default_country")]
44    pub country: String,
45    pub mcc: String,
46    #[serde(default = "default_gui")]
47    pub gui: String,
48    pub merchant_identifier: String,
49    #[serde(default = "default_currency")]
50    pub currency: String,
51    #[serde(default = "default_initiation")]
52    pub initiation: String,
53}
54
55fn default_country() -> String {
56    "ID".into()
57}
58fn default_gui() -> String {
59    "ID.CO.QRIS.WWW".into()
60}
61fn default_currency() -> String {
62    "IDR".into()
63}
64fn default_initiation() -> String {
65    "11".into()
66}
67
68#[derive(Debug, Deserialize)]
69pub struct EmvQrPayloadQuery {
70    #[serde(default)]
71    pub bank_account_id: Option<Uuid>,
72    /// Invoice residual amount; omit for a static QR without tag 54.
73    pub amount: Option<Decimal>,
74    /// Currency override (ISO-4217 alpha); defaults to the configured one.
75    pub currency: Option<String>,
76    /// Invoice number/reference (tag 62-01).
77    pub reference: Option<String>,
78}
79
80#[derive(Debug, Serialize)]
81struct ErrorBody {
82    error: String,
83    message: String,
84}
85
86fn error_response(e: &EmvQrServiceError) -> axum::response::Response {
87    let status = StatusCode::from_u16(e.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
88    (
89        status,
90        Json(ErrorBody {
91            error: e.code().to_string(),
92            message: e.to_string(),
93        }),
94    )
95        .into_response()
96}
97
98// ── Tenant consistency ────────────────────────────────────────────────────────
99// Same contract as the reconciliation verbs: when a host has mounted an ambient
100// scope, the request's company must agree with it. Scope-aware (ADR-0029):
101// prefer the ambient org scope's legacy company id; fall back to the legacy
102// company lane for unstripped hosts. Neither bound — e.g. an undecorated
103// deployment — means nothing to compare against, so no refusal.
104
105
106
107async fn upsert_config(
108    State(service): State<Arc<EmvQrService>>,
109    Json(body): Json<EmvQrConfigBody>,
110) -> impl IntoResponse {
111    let input = EmvQrConfigInput {
112        bank_account_id: body.bank_account_id,
113        merchant_name: body.merchant_name,
114        merchant_city: body.merchant_city,
115        country: body.country,
116        mcc: body.mcc,
117        gui: body.gui,
118        merchant_identifier: body.merchant_identifier,
119        currency: body.currency,
120        initiation: body.initiation,
121    };
122    match service.upsert_config(input).await {
123        Ok(ack) => (
124            StatusCode::OK,
125            Json(EmvQrConfigAck {
126                config_id: ack.config_id,
127                bank_account_id: ack.bank_account_id,
128            }),
129        )
130            .into_response(),
131        Err(e) => error_response(&e),
132    }
133}
134
135async fn payload(
136    State(service): State<Arc<EmvQrService>>,
137    Query(q): Query<EmvQrPayloadQuery>,
138) -> impl IntoResponse {
139    match service
140        .invoice_payload(
141            q.bank_account_id,
142            q.amount,
143            q.currency,
144            q.reference,
145        )
146        .await
147    {
148        Ok(ack) => (StatusCode::OK, Json(EmvPayloadAck {
149            payload: ack.payload,
150            currency: ack.currency,
151            initiation_method: ack.initiation_method,
152        }))
153            .into_response(),
154        Err(e) => error_response(&e),
155    }
156}
157
158/// The EMV QR display routes. Hosts with auth middleware wrap these handlers
159/// with their own layers; the config verb additionally belongs behind an
160/// accounting-officer role gate at the host.
161pub fn create_emv_qr_routes(service: Arc<EmvQrService>) -> Router {
162    Router::new()
163        .route("/accounting/emv-qr/config", put(upsert_config))
164        .route("/accounting/emv-qr/payload", get(payload))
165        .with_state(service)
166}