taler-rust

GNU Taler code in Rust. Largely core banking integrations.
Log | Files | Refs | Submodules | README | LICENSE

db.rs (49064B)


      1 /*
      2   This file is part of TALER
      3   Copyright (C) 2025, 2026 Taler Systems SA
      4 
      5   TALER is free software; you can redistribute it and/or modify it under the
      6   terms of the GNU Affero General Public License as published by the Free Software
      7   Foundation; either version 3, or (at your option) any later version.
      8 
      9   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11   A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details.
     12 
     13   You should have received a copy of the GNU Affero General Public License along with
     14   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 */
     16 
     17 use std::fmt::Display;
     18 
     19 use compact_str::CompactString;
     20 use jiff::Timestamp;
     21 use serde::{Serialize, de::DeserializeOwned};
     22 use sqlx::{PgConnection, PgPool, QueryBuilder, Row, postgres::PgRow};
     23 use taler_api::{
     24     db::{BindHelper, TypeHelper, history, page},
     25     serialized,
     26     subject::{IncomingKey, OutgoingSubject, fmt_out_subject},
     27 };
     28 use taler_common::{
     29     api::{
     30         HashCode, ShortHashCode,
     31         params::{History, Page},
     32         prepared::{RegistrationRequest, Unregistration},
     33         revenue::RevenueIncomingBankTransaction,
     34         wire::{
     35             IncomingBankTransaction, OutgoingBankTransaction, TransferListStatus, TransferState,
     36             TransferStatus,
     37         },
     38     },
     39     config::Config,
     40     db::IncomingType,
     41     types::{
     42         amount::{Currency, Decimal},
     43         payto::{PaytoImpl as _, PaytoURI},
     44     },
     45 };
     46 use tokio::sync::watch::{Receiver, Sender};
     47 use url::Url;
     48 
     49 use crate::{
     50     config::parse_db_cfg,
     51     payto::{CyclosAccount, CyclosId, FullCyclosPayto},
     52 };
     53 
     54 const SCHEMA: &str = "cyclos";
     55 
     56 pub async fn pool(cfg: &Config) -> anyhow::Result<PgPool> {
     57     let db = parse_db_cfg(cfg)?;
     58     let pool = taler_common::db::pool(db.cfg, SCHEMA).await?;
     59     Ok(pool)
     60 }
     61 
     62 pub async fn dbinit(cfg: &Config, reset: bool) -> anyhow::Result<PgPool> {
     63     let db_cfg = parse_db_cfg(cfg)?;
     64     let pool = taler_common::db::pool(db_cfg.cfg, SCHEMA).await?;
     65     let mut db = pool.acquire().await?;
     66     taler_common::db::dbinit(&mut db, db_cfg.sql_dir.as_ref(), SCHEMA, reset).await?;
     67     Ok(pool)
     68 }
     69 
     70 pub async fn notification_listener(
     71     pool: PgPool,
     72     in_channel: Sender<i64>,
     73     taler_in_channel: Sender<i64>,
     74     out_channel: Sender<i64>,
     75     taler_out_channel: Sender<i64>,
     76 ) {
     77     taler_api::notification::notification_listener!(&pool,
     78         "tx_in" => (row_id: i64) {
     79             in_channel.send_replace(row_id);
     80         },
     81         "taler_in" => (row_id: i64) {
     82             taler_in_channel.send_replace(row_id);
     83         },
     84         "tx_out" => (row_id: i64) {
     85             out_channel.send_replace(row_id);
     86         },
     87         "taler_out" => (row_id: i64) {
     88             taler_out_channel.send_replace(row_id);
     89         }
     90     )
     91 }
     92 
     93 #[derive(Debug, Clone)]
     94 pub struct TxIn {
     95     pub transfer_id: i64,
     96     pub tx_id: Option<i64>,
     97     pub amount: Decimal,
     98     pub subject: String,
     99     pub debtor_id: i64,
    100     pub debtor_name: CompactString,
    101     pub valued_at: Timestamp,
    102 }
    103 
    104 impl Display for TxIn {
    105     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    106         let Self {
    107             transfer_id,
    108             tx_id,
    109             amount,
    110             subject,
    111             valued_at,
    112             debtor_id,
    113             debtor_name,
    114         } = self;
    115         let tx_id = match tx_id {
    116             Some(id) => format_args!(":{}", *id),
    117             None => format_args!(""),
    118         };
    119         write!(
    120             f,
    121             "{valued_at} {transfer_id}{tx_id} {amount} ({debtor_id} {debtor_name}) '{subject}'"
    122         )
    123     }
    124 }
    125 
    126 #[derive(Debug, Clone)]
    127 pub struct TxOut {
    128     pub transfer_id: i64,
    129     pub tx_id: Option<i64>,
    130     pub amount: Decimal,
    131     pub subject: String,
    132     pub creditor_id: i64,
    133     pub creditor_name: CompactString,
    134     pub valued_at: Timestamp,
    135 }
    136 
    137 impl Display for TxOut {
    138     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    139         let Self {
    140             transfer_id,
    141             tx_id,
    142             amount,
    143             subject,
    144             creditor_id,
    145             creditor_name,
    146             valued_at,
    147         } = self;
    148         let tx_id = match tx_id {
    149             Some(id) => format_args!(":{}", *id),
    150             None => format_args!(""),
    151         };
    152         write!(
    153             f,
    154             "{valued_at} {transfer_id}{tx_id} {amount} ({creditor_id} {creditor_name}) '{subject}'"
    155         )
    156     }
    157 }
    158 
    159 #[derive(Debug, PartialEq, Eq)]
    160 pub struct Initiated {
    161     pub id: i64,
    162     pub amount: Decimal,
    163     pub subject: String,
    164     pub creditor_id: i64,
    165     pub creditor_name: CompactString,
    166 }
    167 
    168 impl Display for Initiated {
    169     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    170         let Self {
    171             id,
    172             amount,
    173             subject,
    174             creditor_id,
    175             creditor_name,
    176         } = self;
    177         write!(
    178             f,
    179             "{id} {amount} ({creditor_id} {creditor_name}) '{subject}'"
    180         )
    181     }
    182 }
    183 
    184 #[derive(Debug, Clone)]
    185 pub struct TxInAdmin {
    186     pub amount: Decimal,
    187     pub subject: String,
    188     pub debtor_id: i64,
    189     pub debtor_name: CompactString,
    190     pub metadata: IncomingKey,
    191 }
    192 
    193 #[derive(Debug, PartialEq, Eq)]
    194 pub enum AddIncomingResult {
    195     Success {
    196         new: bool,
    197         pending: bool,
    198         row_id: u64,
    199         valued_at: Timestamp,
    200     },
    201     ReservePubReuse,
    202     UnknownMapping,
    203     MappingReuse,
    204 }
    205 
    206 /// Lock the database for worker execution
    207 pub async fn worker_lock(e: &mut PgConnection) -> sqlx::Result<bool> {
    208     sqlx::query("SELECT pg_try_advisory_lock(42)")
    209         .try_map(|r: PgRow| r.try_get(0))
    210         .fetch_one(e)
    211         .await
    212 }
    213 
    214 pub async fn register_tx_in_admin(
    215     db: &PgPool,
    216     tx: &TxInAdmin,
    217     now: &Timestamp,
    218 ) -> sqlx::Result<AddIncomingResult> {
    219     serialized!(
    220         sqlx::query(
    221             "
    222             SELECT out_reserve_pub_reuse, out_mapping_reuse, out_unknown_mapping, out_tx_row_id, out_valued_at, out_new, out_pending
    223             FROM register_tx_in(NULL, NULL, $1, $2, $3, $4, $5, $6, $7, $5)
    224         ",
    225         )
    226         .bind(tx.amount)
    227         .bind(&tx.subject)
    228         .bind(tx.debtor_id)
    229         .bind(&tx.debtor_name)
    230         .bind_timestamp(now)
    231         .bind(tx.metadata.ty)
    232         .bind(tx.metadata.key)
    233        .try_map(|r: PgRow| {
    234             Ok(if r.try_get_flag(0)? {
    235                 AddIncomingResult::ReservePubReuse
    236             } else if r.try_get_flag(1)? {
    237                 AddIncomingResult::MappingReuse
    238             } else if r.try_get_flag(2)? {
    239                 AddIncomingResult::UnknownMapping
    240             } else {
    241                 AddIncomingResult::Success {
    242                     row_id: r.try_get_u64(3)?,
    243                     valued_at: r.try_get_timestamp(4)?,
    244                     new: r.try_get(5)?,
    245                     pending: r.try_get(6)?
    246                 }
    247             })
    248         })
    249         .fetch_one(db)
    250     )
    251 }
    252 
    253 pub async fn register_tx_in(
    254     db: &mut PgConnection,
    255     tx: &TxIn,
    256     subject: &Option<IncomingKey>,
    257     now: &Timestamp,
    258 ) -> sqlx::Result<AddIncomingResult> {
    259     serialized!(
    260         sqlx::query(
    261             "
    262             SELECT out_reserve_pub_reuse, out_mapping_reuse, out_unknown_mapping, out_tx_row_id, out_valued_at, out_new, out_pending
    263             FROM register_tx_in($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
    264         ",
    265         )
    266         .bind(tx.transfer_id)
    267         .bind(tx.tx_id)
    268         .bind(tx.amount)
    269         .bind(&tx.subject)
    270         .bind(tx.debtor_id)
    271         .bind(&tx.debtor_name)
    272         .bind(tx.valued_at.as_microsecond())
    273         .bind(subject.as_ref().map(|it| it.ty))
    274         .bind(subject.as_ref().map(|it| it.key))
    275         .bind(now.as_microsecond())
    276         .try_map(|r: PgRow| {
    277             Ok(if r.try_get_flag(0)? {
    278                 AddIncomingResult::ReservePubReuse
    279             } else if r.try_get_flag(1)? {
    280                 AddIncomingResult::MappingReuse
    281             } else if r.try_get_flag(2)? {
    282                 AddIncomingResult::UnknownMapping
    283             } else {
    284                 AddIncomingResult::Success {
    285                     row_id: r.try_get_u64(3)?,
    286                     valued_at: r.try_get_timestamp(4)?,
    287                     new: r.try_get(5)?,
    288                     pending: r.try_get(6)?
    289                 }
    290             })
    291         })
    292         .fetch_one(&mut *db)
    293     )
    294 }
    295 
    296 #[derive(Debug)]
    297 pub enum TxOutKind {
    298     Simple,
    299     Bounce(i64),
    300     Talerable(OutgoingSubject),
    301 }
    302 
    303 #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
    304 #[allow(non_camel_case_types)]
    305 #[sqlx(type_name = "register_result")]
    306 pub enum RegisterResult {
    307     /// Already registered
    308     idempotent,
    309     /// Initiated transaction
    310     known,
    311     /// Recovered unknown outgoing transaction
    312     recovered,
    313 }
    314 
    315 #[derive(Debug, PartialEq, Eq)]
    316 pub struct AddOutgoingResult {
    317     pub result: RegisterResult,
    318     pub row_id: i64,
    319 }
    320 
    321 pub async fn register_tx_out(
    322     db: &mut PgConnection,
    323     tx: &TxOut,
    324     kind: &TxOutKind,
    325     now: &Timestamp,
    326 ) -> sqlx::Result<AddOutgoingResult> {
    327     serialized!({
    328         let query = sqlx::query(
    329             "
    330                 SELECT out_result, out_tx_row_id
    331                 FROM register_tx_out($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
    332             ",
    333         )
    334         .bind(tx.transfer_id)
    335         .bind(tx.tx_id)
    336         .bind(tx.amount)
    337         .bind(&tx.subject)
    338         .bind(tx.creditor_id)
    339         .bind(&tx.creditor_name)
    340         .bind_timestamp(&tx.valued_at);
    341         let query = match kind {
    342             TxOutKind::Simple => query
    343                 .bind(None::<&[u8]>)
    344                 .bind(None::<&str>)
    345                 .bind(None::<&str>)
    346                 .bind(None::<i64>),
    347             TxOutKind::Bounce(bounced) => query
    348                 .bind(None::<&[u8]>)
    349                 .bind(None::<&str>)
    350                 .bind(None::<&str>)
    351                 .bind(*bounced),
    352             TxOutKind::Talerable(subject) => query
    353                 .bind(subject.wtid)
    354                 .bind(subject.exchange_base_url.as_str())
    355                 .bind(&subject.metadata)
    356                 .bind(None::<i64>),
    357         };
    358         query
    359             .bind_timestamp(now)
    360             .try_map(|r: PgRow| {
    361                 Ok(AddOutgoingResult {
    362                     result: r.try_get(0)?,
    363                     row_id: r.try_get(1)?,
    364                 })
    365             })
    366             .fetch_one(&mut *db)
    367     })
    368 }
    369 
    370 #[derive(Debug, PartialEq, Eq)]
    371 pub enum TransferResult {
    372     Success { id: u64, initiated_at: Timestamp },
    373     RequestUidReuse,
    374     WtidReuse,
    375 }
    376 
    377 #[derive(Debug, Clone)]
    378 pub struct Transfer {
    379     pub request_uid: HashCode,
    380     pub amount: Decimal,
    381     pub exchange_base_url: Url,
    382     pub metadata: Option<CompactString>,
    383     pub wtid: ShortHashCode,
    384     pub creditor_id: i64,
    385     pub creditor_name: CompactString,
    386 }
    387 
    388 pub async fn make_transfer(
    389     db: &PgPool,
    390     tx: &Transfer,
    391     now: &Timestamp,
    392 ) -> sqlx::Result<TransferResult> {
    393     let subject = fmt_out_subject(&tx.wtid, &tx.exchange_base_url, tx.metadata.as_deref());
    394     serialized!(
    395         sqlx::query(
    396             "
    397                 SELECT out_request_uid_reuse, out_wtid_reuse, out_initiated_row_id, out_initiated_at
    398                 FROM taler_transfer($1, $2, $3, $4, $5, $6, $7, $8, $9)
    399             ",
    400         )
    401         .bind(tx.request_uid)
    402         .bind(tx.wtid)
    403         .bind(&subject)
    404         .bind(tx.amount)
    405         .bind(tx.exchange_base_url.as_str())
    406         .bind(&tx.metadata)
    407         .bind(tx.creditor_id)
    408         .bind(&tx.creditor_name)
    409         .bind_timestamp(now)
    410         .try_map(|r: PgRow| {
    411             Ok(if r.try_get_flag(0)? {
    412                 TransferResult::RequestUidReuse
    413             } else if r.try_get_flag(1)? {
    414                 TransferResult::WtidReuse
    415             } else {
    416                 TransferResult::Success {
    417                     id: r.try_get_u64(2)?,
    418                     initiated_at: r.try_get_timestamp(3)?,
    419                 }
    420             })
    421         })
    422         .fetch_one(db)
    423     )
    424 }
    425 
    426 #[derive(Debug, PartialEq, Eq)]
    427 pub struct BounceResult {
    428     pub tx_id: u64,
    429     pub tx_new: bool,
    430 }
    431 
    432 pub async fn register_bounced_tx_in(
    433     db: &mut PgConnection,
    434     tx: &TxIn,
    435     chargeback_id: i64,
    436     reason: &str,
    437     now: &Timestamp,
    438 ) -> sqlx::Result<BounceResult> {
    439     serialized!(
    440         sqlx::query(
    441             "
    442                 SELECT out_tx_row_id, out_tx_new
    443                 FROM register_bounced_tx_in($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
    444             ",
    445         )
    446         .bind(tx.transfer_id)
    447         .bind(tx.tx_id)
    448         .bind(tx.amount)
    449         .bind(&tx.subject)
    450         .bind(tx.debtor_id)
    451         .bind(&tx.debtor_name)
    452         .bind_timestamp(&tx.valued_at)
    453         .bind(chargeback_id)
    454         .bind(reason)
    455         .bind_timestamp(now)
    456         .try_map(|r: PgRow| {
    457             Ok(BounceResult {
    458                 tx_id: r.try_get_u64(0)?,
    459                 tx_new: r.try_get(1)?,
    460             })
    461         })
    462         .fetch_one(&mut *db)
    463     )
    464 }
    465 
    466 pub async fn transfer_page(
    467     db: &PgPool,
    468     status: &Option<TransferState>,
    469     currency: &Currency,
    470     root: &CompactString,
    471     params: &Page,
    472 ) -> sqlx::Result<Vec<TransferListStatus>> {
    473     page(
    474         db,
    475         params,
    476         "initiated_id",
    477         || {
    478             let mut builder = QueryBuilder::new(
    479                 "
    480                     SELECT
    481                         initiated_id,
    482                         status,
    483                         amount,
    484                         credit_account,
    485                         credit_name,
    486                         initiated_at
    487                     FROM transfer
    488                     JOIN initiated USING (initiated_id)
    489                     WHERE
    490                 ",
    491             );
    492             if let Some(status) = status {
    493                 builder.push(" status = ").push_bind(status).push(" AND ");
    494             }
    495             builder
    496         },
    497         |r: PgRow| {
    498             Ok(TransferListStatus {
    499                 row_id: r.try_get_u64(0)?,
    500                 status: r.try_get(1)?,
    501                 amount: r.try_get_amount(2, currency)?,
    502                 credit_account: r.try_get_cyclos_fullpaytouri(3, 4, root)?,
    503                 timestamp: r.try_get_timestamp(5)?.into(),
    504             })
    505         },
    506     )
    507     .await
    508 }
    509 
    510 pub async fn outgoing_history(
    511     db: &PgPool,
    512     params: &History,
    513     currency: &Currency,
    514     root: &CompactString,
    515     listen: impl FnOnce() -> Receiver<i64>,
    516 ) -> sqlx::Result<Vec<OutgoingBankTransaction>> {
    517     history(
    518         db,
    519         "tx_out_id",
    520         params,
    521         listen,
    522         || {
    523             QueryBuilder::new(
    524                 "
    525                 SELECT
    526                     tx_out_id,
    527                     amount,
    528                     credit_account,
    529                     credit_name,
    530                     valued_at,
    531                     exchange_base_url,
    532                     metadata,
    533                     wtid
    534                 FROM taler_out
    535                 JOIN tx_out USING (tx_out_id)
    536                 WHERE
    537             ",
    538             )
    539         },
    540         |r: PgRow| {
    541             Ok(OutgoingBankTransaction {
    542                 row_id: r.try_get_u64(0)?,
    543                 amount: r.try_get_amount(1, currency)?,
    544                 debit_fee: None,
    545                 credit_account: r.try_get_cyclos_fullpaytouri(2, 3, root)?,
    546                 date: r.try_get_timestamp(4)?.into(),
    547                 exchange_base_url: r.try_get_url(5)?,
    548                 metadata: r.try_get(6)?,
    549                 wtid: r.try_get(7)?,
    550             })
    551         },
    552     )
    553     .await
    554 }
    555 
    556 pub async fn incoming_history(
    557     db: &PgPool,
    558     params: &History,
    559     currency: &Currency,
    560     root: &CompactString,
    561     listen: impl FnOnce() -> Receiver<i64>,
    562 ) -> sqlx::Result<Vec<IncomingBankTransaction>> {
    563     history(
    564         db,
    565         "tx_in_id",
    566         params,
    567         listen,
    568         || {
    569             QueryBuilder::new(
    570                 "
    571                 SELECT
    572                     type,
    573                     tx_in_id,
    574                     amount,
    575                     debit_account,
    576                     debit_name,
    577                     valued_at,
    578                     metadata,
    579                     authorization_pub,
    580                     authorization_sig
    581                 FROM taler_in
    582                 JOIN tx_in USING (tx_in_id)
    583                 WHERE
    584             ",
    585             )
    586         },
    587         |r: PgRow| {
    588             Ok(match r.try_get(0)? {
    589                 IncomingType::reserve => IncomingBankTransaction::Reserve {
    590                     row_id: r.try_get_u64(1)?,
    591                     amount: r.try_get_amount(2, currency)?,
    592                     credit_fee: None,
    593                     debit_account: r.try_get_cyclos_fullpaytouri(3, 4, root)?,
    594                     date: r.try_get_timestamp(5)?.into(),
    595                     reserve_pub: r.try_get(6)?,
    596                     authorization_pub: r.try_get(7)?,
    597                     authorization_sig: r.try_get(8)?,
    598                 },
    599                 IncomingType::kyc => IncomingBankTransaction::Kyc {
    600                     row_id: r.try_get_u64(1)?,
    601                     amount: r.try_get_amount(2, currency)?,
    602                     credit_fee: None,
    603                     debit_account: r.try_get_cyclos_fullpaytouri(3, 4, root)?,
    604                     date: r.try_get_timestamp(5)?.into(),
    605                     account_pub: r.try_get(6)?,
    606                     authorization_pub: r.try_get(7)?,
    607                     authorization_sig: r.try_get(8)?,
    608                 },
    609                 IncomingType::map => unimplemented!("MAP are never listed in the history"),
    610             })
    611         },
    612     )
    613     .await
    614 }
    615 
    616 pub async fn revenue_history(
    617     db: &PgPool,
    618     params: &History,
    619     currency: &Currency,
    620     root: &CompactString,
    621     listen: impl FnOnce() -> Receiver<i64>,
    622 ) -> sqlx::Result<Vec<RevenueIncomingBankTransaction>> {
    623     history(
    624         db,
    625         "tx_in_id",
    626         params,
    627         listen,
    628         || {
    629             QueryBuilder::new(
    630                 "
    631                 SELECT
    632                     tx_in_id,
    633                     valued_at,
    634                     amount,
    635                     debit_account,
    636                     debit_name,
    637                     subject
    638                 FROM tx_in
    639                 WHERE
    640             ",
    641             )
    642         },
    643         |r: PgRow| {
    644             Ok(RevenueIncomingBankTransaction {
    645                 row_id: r.try_get_u64(0)?,
    646                 date: r.try_get_timestamp(1)?.into(),
    647                 amount: r.try_get_amount(2, currency)?,
    648                 credit_fee: None,
    649                 debit_account: r.try_get_cyclos_fullpaytouri(3, 4, root)?,
    650                 subject: r.try_get(5)?,
    651             })
    652         },
    653     )
    654     .await
    655 }
    656 
    657 pub async fn transfer_by_id(
    658     db: &PgPool,
    659     id: u64,
    660     currency: &Currency,
    661     root: &CompactString,
    662 ) -> sqlx::Result<Option<TransferStatus>> {
    663     serialized!(
    664         sqlx::query(
    665             "
    666                 SELECT
    667                     status,
    668                     status_msg,
    669                     amount,
    670                     exchange_base_url,
    671                     metadata,
    672                     wtid,
    673                     credit_account,
    674                     credit_name,
    675                     initiated_at
    676                 FROM transfer
    677                 JOIN initiated USING (initiated_id)
    678                 WHERE initiated_id = $1
    679             ",
    680         )
    681         .bind(id as i64)
    682         .try_map(|r: PgRow| {
    683             Ok(TransferStatus {
    684                 status: r.try_get(0)?,
    685                 status_msg: r.try_get(1)?,
    686                 amount: r.try_get_amount(2, currency)?,
    687                 exchange_base_url: r.try_get(3)?,
    688                 metadata: r.try_get(4)?,
    689                 wtid: r.try_get(5)?,
    690                 credit_account: r.try_get_cyclos_fullpaytouri(6, 7, root)?,
    691                 timestamp: r.try_get_timestamp(8)?.into(),
    692             })
    693         })
    694         .fetch_optional(db)
    695     )
    696 }
    697 
    698 /** Get a batch of pending initiated transactions not attempted since [start] */
    699 pub async fn pending_batch(
    700     db: &mut PgConnection,
    701     start: &Timestamp,
    702 ) -> sqlx::Result<Vec<Initiated>> {
    703     serialized!(
    704         sqlx::query(
    705             "
    706             SELECT initiated_id, amount, subject, credit_account, credit_name
    707             FROM initiated
    708             WHERE tx_id IS NULL
    709                 AND status='pending'
    710                 AND (last_submitted IS NULL OR last_submitted < $1)
    711             LIMIT 100
    712         ",
    713         )
    714         .bind_timestamp(start)
    715         .try_map(|r: PgRow| {
    716             Ok(Initiated {
    717                 id: r.try_get(0)?,
    718                 amount: r.try_get(1)?,
    719                 subject: r.try_get(2)?,
    720                 creditor_id: r.try_get(3)?,
    721                 creditor_name: r.try_get(4)?,
    722             })
    723         })
    724         .fetch_all(&mut *db)
    725     )
    726 }
    727 
    728 /** Update status of a successful submitted initiated transaction */
    729 pub async fn initiated_submit_success(
    730     db: &mut PgConnection,
    731     initiated_id: i64,
    732     timestamp: &Timestamp,
    733     tx_id: i64,
    734 ) -> sqlx::Result<()> {
    735     serialized!(
    736         sqlx::query(
    737             "
    738                 UPDATE initiated
    739                 SET status='pending', submission_counter=submission_counter+1, last_submitted=$1, tx_id=$2
    740                 WHERE initiated_id=$3
    741             "
    742         )
    743         .bind_timestamp(timestamp)
    744         .bind(tx_id)
    745         .bind(initiated_id)
    746         .execute(&mut *db)
    747     )?;
    748     Ok(())
    749 }
    750 
    751 /** Update status of a permanently failed initiated transaction */
    752 pub async fn initiated_submit_permanent_failure(
    753     db: &mut PgConnection,
    754     initiated_id: i64,
    755     msg: &str,
    756 ) -> sqlx::Result<()> {
    757     serialized!(
    758         sqlx::query(
    759             "
    760                 UPDATE initiated
    761                 SET status='permanent_failure', status_msg=$1
    762                 WHERE initiated_id=$2
    763             ",
    764         )
    765         .bind(msg)
    766         .bind(initiated_id)
    767         .execute(&mut *db)
    768     )?;
    769     Ok(())
    770 }
    771 
    772 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    773 pub enum ChargebackFailureResult {
    774     Unknown,
    775     Known(u64),
    776     Idempotent(u64),
    777 }
    778 
    779 /** Update status of a charged back initiated transaction */
    780 pub async fn initiated_chargeback_failure(
    781     db: &mut PgConnection,
    782     transfer_id: i64,
    783 ) -> sqlx::Result<ChargebackFailureResult> {
    784     Ok(serialized!(
    785         sqlx::query("SELECT out_initiated_id, out_new FROM register_charge_back_failure($1)")
    786             .bind(transfer_id)
    787             .try_map(|r: PgRow| {
    788                 let id = r.try_get_u64(0)?;
    789                 Ok(if id == 0 {
    790                     ChargebackFailureResult::Unknown
    791                 } else if r.try_get(1)? {
    792                     ChargebackFailureResult::Known(id)
    793                 } else {
    794                     ChargebackFailureResult::Idempotent(id)
    795                 })
    796             })
    797             .fetch_optional(&mut *db)
    798     )?
    799     .unwrap_or(ChargebackFailureResult::Unknown))
    800 }
    801 
    802 /** Get JSON value from KV table */
    803 pub async fn kv_get<T: DeserializeOwned + Unpin + Send>(
    804     db: &mut PgConnection,
    805     key: &str,
    806 ) -> sqlx::Result<Option<T>> {
    807     serialized!(
    808         sqlx::query("SELECT value FROM kv WHERE key=$1")
    809             .bind(key)
    810             .try_map(|r| Ok(r.try_get::<sqlx::types::Json<T>, _>(0)?.0))
    811             .fetch_optional(&mut *db)
    812     )
    813 }
    814 
    815 /** Set JSON value in KV table */
    816 pub async fn kv_set<T: Serialize>(db: &mut PgConnection, key: &str, value: &T) -> sqlx::Result<()> {
    817     serialized!(
    818         sqlx::query("INSERT INTO kv (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value")
    819             .bind(key)
    820             .bind(sqlx::types::Json(value))
    821             .execute(&mut *db)
    822     )?;
    823     Ok(())
    824 }
    825 
    826 pub enum RegistrationResult {
    827     Success,
    828     ReservePubReuse,
    829 }
    830 
    831 pub async fn transfer_register(
    832     db: &PgPool,
    833     req: &RegistrationRequest,
    834 ) -> sqlx::Result<RegistrationResult> {
    835     let ty: IncomingType = req.r#type.into();
    836     serialized!(
    837         sqlx::query(
    838             "SELECT out_reserve_pub_reuse FROM register_prepared_transfers($1,$2,$3,$4,$5,$6)"
    839         )
    840         .bind(ty)
    841         .bind(req.account_pub)
    842         .bind(req.authorization_pub)
    843         .bind(req.authorization_sig)
    844         .bind(req.recurrent)
    845         .bind_timestamp(&Timestamp::now())
    846         .try_map(|r: PgRow| {
    847             Ok(if r.try_get_flag("out_reserve_pub_reuse")? {
    848                 RegistrationResult::ReservePubReuse
    849             } else {
    850                 RegistrationResult::Success
    851             })
    852         })
    853         .fetch_one(db)
    854     )
    855 }
    856 
    857 pub async fn transfer_unregister(db: &PgPool, req: &Unregistration) -> sqlx::Result<bool> {
    858     serialized!(
    859         sqlx::query("SELECT out_found FROM delete_prepared_transfers($1)")
    860             .bind(req.authorization_pub)
    861             .try_map(|r: PgRow| r.try_get_flag("out_found"))
    862             .fetch_one(db)
    863     )
    864 }
    865 
    866 pub trait CyclosTypeHelper {
    867     fn try_get_cyclos_fullpayto<I: sqlx::ColumnIndex<Self>>(
    868         &self,
    869         idx: I,
    870         name: I,
    871         root: &CompactString,
    872     ) -> sqlx::Result<FullCyclosPayto>;
    873     fn try_get_cyclos_fullpaytouri<I: sqlx::ColumnIndex<Self>>(
    874         &self,
    875         idx: I,
    876         name: I,
    877         root: &CompactString,
    878     ) -> sqlx::Result<PaytoURI>;
    879 }
    880 
    881 impl CyclosTypeHelper for PgRow {
    882     fn try_get_cyclos_fullpayto<I: sqlx::ColumnIndex<Self>>(
    883         &self,
    884         idx: I,
    885         name: I,
    886         root: &CompactString,
    887     ) -> sqlx::Result<FullCyclosPayto> {
    888         let idx = self.try_get(idx)?;
    889         let name = self.try_get(name)?;
    890         Ok(FullCyclosPayto::new(
    891             CyclosAccount {
    892                 id: CyclosId(idx),
    893                 root: root.clone(),
    894             },
    895             name,
    896         ))
    897     }
    898     fn try_get_cyclos_fullpaytouri<I: sqlx::ColumnIndex<Self>>(
    899         &self,
    900         idx: I,
    901         name: I,
    902         root: &CompactString,
    903     ) -> sqlx::Result<PaytoURI> {
    904         let idx = self.try_get(idx)?;
    905         let name = self.try_get(name)?;
    906         Ok(CyclosAccount {
    907             id: CyclosId(idx),
    908             root: root.clone(),
    909         }
    910         .as_full_uri(name))
    911     }
    912 }
    913 
    914 #[cfg(test)]
    915 mod test {
    916     use compact_str::CompactString;
    917     use jiff::{Span, Timestamp};
    918     use serde_json::json;
    919     use sqlx::{PgPool, Postgres, Row as _, pool::PoolConnection, postgres::PgRow};
    920     use taler_api::{
    921         db::TypeHelper,
    922         notification::dummy_listen,
    923         subject::{IncomingKey, OutgoingSubject},
    924     };
    925     use taler_common::{
    926         api::{
    927             EddsaPublicKey, HashCode, ShortHashCode,
    928             params::{History, Page},
    929             wire::TransferState,
    930         },
    931         types::{
    932             amount::{Currency, decimal},
    933             url,
    934             utils::now_sql_stable_ts,
    935         },
    936     };
    937 
    938     use crate::{
    939         constants::CONFIG_SOURCE,
    940         db::{
    941             self, AddIncomingResult, AddOutgoingResult, BounceResult, ChargebackFailureResult,
    942             Transfer, TransferResult, TxIn, TxInAdmin, TxOut, TxOutKind,
    943         },
    944     };
    945 
    946     pub const CURR: Currency = Currency::TEST;
    947     pub const ROOT: CompactString = CompactString::const_new("localhost");
    948 
    949     async fn setup() -> (PoolConnection<Postgres>, PgPool) {
    950         taler_test_utils::db::db_test_setup(CONFIG_SOURCE).await
    951     }
    952 
    953     #[tokio::test]
    954     async fn kv() {
    955         let (mut db, _) = setup().await;
    956 
    957         let value = json!({
    958             "name": "Mr Smith",
    959             "no way": 32
    960         });
    961 
    962         assert_eq!(
    963             db::kv_get::<serde_json::Value>(&mut db, "value")
    964                 .await
    965                 .unwrap(),
    966             None
    967         );
    968         db::kv_set(&mut db, "value", &value).await.unwrap();
    969         db::kv_set(&mut db, "value", &value).await.unwrap();
    970         assert_eq!(
    971             db::kv_get::<serde_json::Value>(&mut db, "value")
    972                 .await
    973                 .unwrap(),
    974             Some(value)
    975         );
    976     }
    977 
    978     #[tokio::test]
    979     async fn tx_in() {
    980         let (mut db, pool) = setup().await;
    981 
    982         let mut routine = async |first: &Option<IncomingKey>, second: &Option<IncomingKey>| {
    983             let id = sqlx::query("SELECT count(*) + 1 FROM tx_in")
    984                 .try_map(|r: PgRow| r.try_get_u64(0))
    985                 .fetch_one(&mut *db)
    986                 .await
    987                 .unwrap();
    988             let now = now_sql_stable_ts();
    989             let later = now + Span::new().hours(2);
    990             let tx = TxIn {
    991                 transfer_id: now.as_microsecond() as i64,
    992                 tx_id: None,
    993                 amount: decimal("10"),
    994                 subject: "subject".to_owned(),
    995                 debtor_id: 31000163100000000,
    996                 debtor_name: "Name".into(),
    997                 valued_at: now,
    998             };
    999             // Insert
   1000             assert_eq!(
   1001                 db::register_tx_in(&mut db, &tx, first, &now)
   1002                     .await
   1003                     .expect("register tx in"),
   1004                 AddIncomingResult::Success {
   1005                     new: true,
   1006                     pending: false,
   1007                     row_id: id,
   1008                     valued_at: now,
   1009                 }
   1010             );
   1011             // Idempotent
   1012             assert_eq!(
   1013                 db::register_tx_in(
   1014                     &mut db,
   1015                     &TxIn {
   1016                         valued_at: later,
   1017                         ..tx.clone()
   1018                     },
   1019                     first,
   1020                     &now
   1021                 )
   1022                 .await
   1023                 .expect("register tx in"),
   1024                 AddIncomingResult::Success {
   1025                     new: false,
   1026                     pending: false,
   1027                     row_id: id,
   1028                     valued_at: now
   1029                 }
   1030             );
   1031             // Many
   1032             assert_eq!(
   1033                 db::register_tx_in(
   1034                     &mut db,
   1035                     &TxIn {
   1036                         transfer_id: later.as_microsecond() as i64,
   1037                         valued_at: later,
   1038                         ..tx
   1039                     },
   1040                     second,
   1041                     &now
   1042                 )
   1043                 .await
   1044                 .expect("register tx in"),
   1045                 AddIncomingResult::Success {
   1046                     new: true,
   1047                     pending: false,
   1048                     row_id: id + 1,
   1049                     valued_at: later
   1050                 }
   1051             );
   1052         };
   1053 
   1054         // Empty db
   1055         assert_eq!(
   1056             db::revenue_history(&pool, &History::default(), &CURR, &ROOT, dummy_listen)
   1057                 .await
   1058                 .unwrap(),
   1059             Vec::new()
   1060         );
   1061         assert_eq!(
   1062             db::incoming_history(&pool, &History::default(), &CURR, &ROOT, dummy_listen)
   1063                 .await
   1064                 .unwrap(),
   1065             Vec::new()
   1066         );
   1067 
   1068         // Regular transaction
   1069         routine(&None, &None).await;
   1070 
   1071         let first = EddsaPublicKey::rand();
   1072         let second = EddsaPublicKey::rand();
   1073 
   1074         // Reserve transaction
   1075         routine(
   1076             &Some(IncomingKey::reserve(first)),
   1077             &Some(IncomingKey::reserve(second)),
   1078         )
   1079         .await;
   1080 
   1081         // Kyc transaction
   1082         routine(
   1083             &Some(IncomingKey::kyc(first)),
   1084             &Some(IncomingKey::kyc(first)),
   1085         )
   1086         .await;
   1087 
   1088         // History
   1089         assert_eq!(
   1090             db::revenue_history(&pool, &History::default(), &CURR, &ROOT, dummy_listen)
   1091                 .await
   1092                 .unwrap()
   1093                 .len(),
   1094             6
   1095         );
   1096         assert_eq!(
   1097             db::incoming_history(&pool, &History::default(), &CURR, &ROOT, dummy_listen)
   1098                 .await
   1099                 .unwrap()
   1100                 .len(),
   1101             4
   1102         );
   1103     }
   1104 
   1105     #[tokio::test]
   1106     async fn tx_in_admin() {
   1107         let (_, pool) = setup().await;
   1108 
   1109         // Empty db
   1110         assert_eq!(
   1111             db::incoming_history(&pool, &History::default(), &CURR, &ROOT, dummy_listen)
   1112                 .await
   1113                 .unwrap(),
   1114             Vec::new()
   1115         );
   1116 
   1117         let now = now_sql_stable_ts();
   1118         let later = now + Span::new().hours(2);
   1119         let tx = TxInAdmin {
   1120             amount: decimal("10"),
   1121             subject: "subject".to_owned(),
   1122             debtor_id: 31000163100000000,
   1123             debtor_name: "Name".into(),
   1124             metadata: IncomingKey::reserve(EddsaPublicKey::rand()),
   1125         };
   1126         // Insert
   1127         assert_eq!(
   1128             db::register_tx_in_admin(&pool, &tx, &now)
   1129                 .await
   1130                 .expect("register tx in"),
   1131             AddIncomingResult::Success {
   1132                 new: true,
   1133                 pending: false,
   1134                 row_id: 1,
   1135                 valued_at: now
   1136             }
   1137         );
   1138         // Many
   1139         assert_eq!(
   1140             db::register_tx_in_admin(
   1141                 &pool,
   1142                 &TxInAdmin {
   1143                     subject: "Other".to_owned(),
   1144                     metadata: IncomingKey::reserve(EddsaPublicKey::rand()),
   1145                     ..tx.clone()
   1146                 },
   1147                 &later
   1148             )
   1149             .await
   1150             .expect("register tx in"),
   1151             AddIncomingResult::Success {
   1152                 new: true,
   1153                 pending: false,
   1154                 row_id: 2,
   1155                 valued_at: later
   1156             }
   1157         );
   1158 
   1159         // History
   1160         assert_eq!(
   1161             db::incoming_history(&pool, &History::default(), &CURR, &ROOT, dummy_listen)
   1162                 .await
   1163                 .unwrap()
   1164                 .len(),
   1165             2
   1166         );
   1167     }
   1168 
   1169     #[tokio::test]
   1170     async fn tx_out() {
   1171         let (mut db, pool) = setup().await;
   1172 
   1173         let mut routine = async |first: &TxOutKind, second: &TxOutKind| {
   1174             let transfer_id = sqlx::query("SELECT count(*) + 1 FROM tx_out")
   1175                 .try_map(|r: PgRow| r.try_get(0))
   1176                 .fetch_one(&mut *db)
   1177                 .await
   1178                 .unwrap();
   1179             let now = now_sql_stable_ts();
   1180             let later = now + Span::new().hours(2);
   1181             let tx = TxOut {
   1182                 transfer_id,
   1183                 tx_id: Some(transfer_id),
   1184                 amount: decimal("10"),
   1185                 subject: "subject".to_owned(),
   1186                 creditor_id: 31000163100000000,
   1187                 creditor_name: "Name".into(),
   1188                 valued_at: now,
   1189             };
   1190             // TODO revert to assert_matches! once rust-lang#82775 is stable
   1191             assert!(matches!(
   1192                 db::make_transfer(
   1193                     &pool,
   1194                     &Transfer {
   1195                         request_uid: HashCode::rand(),
   1196                         amount: decimal("10"),
   1197                         exchange_base_url: url("https://exchange.test.com/"),
   1198                         metadata: None,
   1199                         wtid: ShortHashCode::rand(),
   1200                         creditor_id: 31000163100000000,
   1201                         creditor_name: "Name".into()
   1202                     },
   1203                     &now
   1204                 )
   1205                 .await
   1206                 .unwrap(),
   1207                 TransferResult::Success { .. }
   1208             ));
   1209             db::initiated_submit_success(&mut db, 1, &Timestamp::now(), transfer_id)
   1210                 .await
   1211                 .expect("status success");
   1212 
   1213             // Insert
   1214             assert_eq!(
   1215                 db::register_tx_out(&mut db, &tx, first, &now)
   1216                     .await
   1217                     .expect("register tx out"),
   1218                 AddOutgoingResult {
   1219                     result: db::RegisterResult::known,
   1220                     row_id: transfer_id,
   1221                 }
   1222             );
   1223             // Idempotent
   1224             assert_eq!(
   1225                 db::register_tx_out(
   1226                     &mut db,
   1227                     &TxOut {
   1228                         valued_at: later,
   1229                         ..tx.clone()
   1230                     },
   1231                     first,
   1232                     &now
   1233                 )
   1234                 .await
   1235                 .expect("register tx out"),
   1236                 AddOutgoingResult {
   1237                     result: db::RegisterResult::idempotent,
   1238                     row_id: transfer_id,
   1239                 }
   1240             );
   1241             // Recovered
   1242             assert_eq!(
   1243                 db::register_tx_out(
   1244                     &mut db,
   1245                     &TxOut {
   1246                         transfer_id: transfer_id + 1,
   1247                         tx_id: Some(transfer_id + 1),
   1248                         valued_at: later,
   1249                         ..tx.clone()
   1250                     },
   1251                     second,
   1252                     &now
   1253                 )
   1254                 .await
   1255                 .expect("register tx out"),
   1256                 AddOutgoingResult {
   1257                     result: db::RegisterResult::recovered,
   1258                     row_id: transfer_id + 1,
   1259                 }
   1260             );
   1261         };
   1262 
   1263         // Empty db
   1264         assert_eq!(
   1265             db::outgoing_history(&pool, &History::default(), &CURR, &ROOT, dummy_listen)
   1266                 .await
   1267                 .unwrap(),
   1268             Vec::new()
   1269         );
   1270 
   1271         // Regular transaction
   1272         routine(&TxOutKind::Simple, &TxOutKind::Simple).await;
   1273 
   1274         // Talerable transaction
   1275         routine(
   1276             &TxOutKind::Talerable(OutgoingSubject::rand()),
   1277             &TxOutKind::Talerable(OutgoingSubject::rand()),
   1278         )
   1279         .await;
   1280 
   1281         // Bounced transaction
   1282         routine(&TxOutKind::Bounce(21), &TxOutKind::Bounce(42)).await;
   1283 
   1284         // History
   1285         assert_eq!(
   1286             db::outgoing_history(&pool, &History::default(), &CURR, &ROOT, dummy_listen)
   1287                 .await
   1288                 .unwrap()
   1289                 .len(),
   1290             2
   1291         );
   1292     }
   1293 
   1294     // TODO tx out failure
   1295 
   1296     #[tokio::test]
   1297     async fn transfer() {
   1298         let (_, pool) = setup().await;
   1299 
   1300         // Empty db
   1301         assert_eq!(
   1302             db::transfer_by_id(&pool, 0, &CURR, &ROOT).await.unwrap(),
   1303             None
   1304         );
   1305         assert_eq!(
   1306             db::transfer_page(&pool, &None, &CURR, &ROOT, &Page::default())
   1307                 .await
   1308                 .unwrap(),
   1309             Vec::new()
   1310         );
   1311 
   1312         let req = Transfer {
   1313             request_uid: HashCode::rand(),
   1314             amount: decimal("10"),
   1315             exchange_base_url: url("https://exchange.test.com/"),
   1316             metadata: None,
   1317             wtid: ShortHashCode::rand(),
   1318             creditor_id: 31000163100000000,
   1319             creditor_name: "Name".into(),
   1320         };
   1321         let now = now_sql_stable_ts();
   1322         let later = now + Span::new().hours(2);
   1323         // Insert
   1324         assert_eq!(
   1325             db::make_transfer(&pool, &req, &now)
   1326                 .await
   1327                 .expect("transfer"),
   1328             TransferResult::Success {
   1329                 id: 1,
   1330                 initiated_at: now
   1331             }
   1332         );
   1333         // Idempotent
   1334         assert_eq!(
   1335             db::make_transfer(&pool, &req, &later)
   1336                 .await
   1337                 .expect("transfer"),
   1338             TransferResult::Success {
   1339                 id: 1,
   1340                 initiated_at: now
   1341             }
   1342         );
   1343         // Request UID reuse
   1344         assert_eq!(
   1345             db::make_transfer(
   1346                 &pool,
   1347                 &Transfer {
   1348                     wtid: ShortHashCode::rand(),
   1349                     ..req.clone()
   1350                 },
   1351                 &now
   1352             )
   1353             .await
   1354             .expect("transfer"),
   1355             TransferResult::RequestUidReuse
   1356         );
   1357         // wtid reuse
   1358         assert_eq!(
   1359             db::make_transfer(
   1360                 &pool,
   1361                 &Transfer {
   1362                     request_uid: HashCode::rand(),
   1363                     ..req.clone()
   1364                 },
   1365                 &now
   1366             )
   1367             .await
   1368             .expect("transfer"),
   1369             TransferResult::WtidReuse
   1370         );
   1371         // Many
   1372         assert_eq!(
   1373             db::make_transfer(
   1374                 &pool,
   1375                 &Transfer {
   1376                     request_uid: HashCode::rand(),
   1377                     wtid: ShortHashCode::rand(),
   1378                     ..req
   1379                 },
   1380                 &later
   1381             )
   1382             .await
   1383             .expect("transfer"),
   1384             TransferResult::Success {
   1385                 id: 2,
   1386                 initiated_at: later
   1387             }
   1388         );
   1389 
   1390         // Get
   1391         assert!(
   1392             db::transfer_by_id(&pool, 1, &CURR, &ROOT)
   1393                 .await
   1394                 .unwrap()
   1395                 .is_some()
   1396         );
   1397         assert!(
   1398             db::transfer_by_id(&pool, 2, &CURR, &ROOT)
   1399                 .await
   1400                 .unwrap()
   1401                 .is_some()
   1402         );
   1403         assert!(
   1404             db::transfer_by_id(&pool, 3, &CURR, &ROOT)
   1405                 .await
   1406                 .unwrap()
   1407                 .is_none()
   1408         );
   1409         assert_eq!(
   1410             db::transfer_page(&pool, &None, &CURR, &ROOT, &Page::default())
   1411                 .await
   1412                 .unwrap()
   1413                 .len(),
   1414             2
   1415         );
   1416     }
   1417 
   1418     #[tokio::test]
   1419     async fn bounce() {
   1420         let (mut db, _) = setup().await;
   1421 
   1422         let amount = decimal("10");
   1423         let now = now_sql_stable_ts();
   1424 
   1425         // Bounce
   1426         assert_eq!(
   1427             db::register_bounced_tx_in(
   1428                 &mut db,
   1429                 &TxIn {
   1430                     transfer_id: 12,
   1431                     tx_id: None,
   1432                     amount,
   1433                     subject: "subject".to_owned(),
   1434                     debtor_id: 31000163100000000,
   1435                     debtor_name: "Name".into(),
   1436                     valued_at: now
   1437                 },
   1438                 22,
   1439                 "good reason",
   1440                 &now
   1441             )
   1442             .await
   1443             .expect("bounce"),
   1444             BounceResult {
   1445                 tx_id: 1,
   1446                 tx_new: true
   1447             }
   1448         );
   1449         // Idempotent
   1450         assert_eq!(
   1451             db::register_bounced_tx_in(
   1452                 &mut db,
   1453                 &TxIn {
   1454                     transfer_id: 12,
   1455                     tx_id: None,
   1456                     amount,
   1457                     subject: "subject".to_owned(),
   1458                     debtor_id: 31000163100000000,
   1459                     debtor_name: "Name".into(),
   1460                     valued_at: now
   1461                 },
   1462                 22,
   1463                 "good reason",
   1464                 &now
   1465             )
   1466             .await
   1467             .expect("bounce"),
   1468             BounceResult {
   1469                 tx_id: 1,
   1470                 tx_new: false
   1471             }
   1472         );
   1473 
   1474         // Many
   1475         assert_eq!(
   1476             db::register_bounced_tx_in(
   1477                 &mut db,
   1478                 &TxIn {
   1479                     transfer_id: 13,
   1480                     tx_id: None,
   1481                     amount,
   1482                     subject: "subject".to_owned(),
   1483                     debtor_id: 31000163100000000,
   1484                     debtor_name: "Name".into(),
   1485                     valued_at: now
   1486                 },
   1487                 23,
   1488                 "good reason",
   1489                 &now
   1490             )
   1491             .await
   1492             .expect("bounce"),
   1493             BounceResult {
   1494                 tx_id: 2,
   1495                 tx_new: true
   1496             }
   1497         );
   1498     }
   1499 
   1500     #[tokio::test]
   1501     async fn status() {
   1502         let (mut db, pool) = setup().await;
   1503 
   1504         let check_status = async |id: u64, status: TransferState, msg: Option<&str>| {
   1505             let transfer = db::transfer_by_id(&pool, id, &CURR, &ROOT)
   1506                 .await
   1507                 .unwrap()
   1508                 .unwrap();
   1509             assert_eq!(
   1510                 (status, msg),
   1511                 (transfer.status, transfer.status_msg.as_deref())
   1512             );
   1513         };
   1514 
   1515         // Unknown transfer
   1516         db::initiated_submit_permanent_failure(&mut db, 1, "msg")
   1517             .await
   1518             .unwrap();
   1519         db::initiated_submit_success(&mut db, 1, &Timestamp::now(), 12)
   1520             .await
   1521             .unwrap();
   1522         assert_eq!(
   1523             db::initiated_chargeback_failure(&mut db, 1).await.unwrap(),
   1524             ChargebackFailureResult::Unknown
   1525         );
   1526 
   1527         // Failure
   1528         db::make_transfer(
   1529             &pool,
   1530             &Transfer {
   1531                 request_uid: HashCode::rand(),
   1532                 amount: decimal("1"),
   1533                 exchange_base_url: url("https://exchange.test.com/"),
   1534                 metadata: None,
   1535                 wtid: ShortHashCode::rand(),
   1536                 creditor_id: 31000163100000000,
   1537                 creditor_name: "Name".into(),
   1538             },
   1539             &Timestamp::now(),
   1540         )
   1541         .await
   1542         .expect("transfer");
   1543         check_status(1, TransferState::pending, None).await;
   1544         db::initiated_submit_permanent_failure(&mut db, 1, "error status")
   1545             .await
   1546             .unwrap();
   1547         check_status(1, TransferState::permanent_failure, Some("error status")).await;
   1548 
   1549         // Success
   1550         db::make_transfer(
   1551             &pool,
   1552             &Transfer {
   1553                 request_uid: HashCode::rand(),
   1554                 amount: decimal("1"),
   1555                 exchange_base_url: url("https://exchange.test.com/"),
   1556                 metadata: None,
   1557                 wtid: ShortHashCode::rand(),
   1558                 creditor_id: 31000163100000000,
   1559                 creditor_name: "Name".into(),
   1560             },
   1561             &Timestamp::now(),
   1562         )
   1563         .await
   1564         .expect("transfer");
   1565         check_status(2, TransferState::pending, None).await;
   1566         db::initiated_submit_success(&mut db, 2, &Timestamp::now(), 3)
   1567             .await
   1568             .unwrap();
   1569         check_status(2, TransferState::pending, None).await;
   1570         db::register_tx_out(
   1571             &mut db,
   1572             &TxOut {
   1573                 transfer_id: 5,
   1574                 tx_id: Some(3),
   1575                 amount: decimal("2"),
   1576                 subject: "".to_string(),
   1577                 creditor_id: 31000163100000000,
   1578                 creditor_name: "Name".into(),
   1579                 valued_at: Timestamp::now(),
   1580             },
   1581             &TxOutKind::Simple,
   1582             &Timestamp::now(),
   1583         )
   1584         .await
   1585         .unwrap();
   1586         check_status(2, TransferState::success, None).await;
   1587 
   1588         // Chargeback
   1589         assert_eq!(
   1590             db::initiated_chargeback_failure(&mut db, 5).await.unwrap(),
   1591             ChargebackFailureResult::Known(2)
   1592         );
   1593         check_status(2, TransferState::late_failure, Some("charged back")).await;
   1594         assert_eq!(
   1595             db::initiated_chargeback_failure(&mut db, 5).await.unwrap(),
   1596             ChargebackFailureResult::Idempotent(2)
   1597         );
   1598     }
   1599 
   1600     #[tokio::test]
   1601     async fn batch() {
   1602         let (mut db, pool) = setup().await;
   1603         let start = Timestamp::now();
   1604 
   1605         // Empty db
   1606         let pendings = db::pending_batch(&mut db, &start)
   1607             .await
   1608             .expect("pending_batch");
   1609         assert_eq!(pendings.len(), 0);
   1610 
   1611         // Some transfers
   1612         for i in 0..3 {
   1613             db::make_transfer(
   1614                 &pool,
   1615                 &Transfer {
   1616                     request_uid: HashCode::rand(),
   1617                     amount: decimal(format!("{}", i + 1)),
   1618                     exchange_base_url: url("https://exchange.test.com/"),
   1619                     metadata: None,
   1620                     wtid: ShortHashCode::rand(),
   1621                     creditor_id: 31000163100000000,
   1622                     creditor_name: "Name".into(),
   1623                 },
   1624                 &Timestamp::now(),
   1625             )
   1626             .await
   1627             .expect("transfer");
   1628         }
   1629         let pendings = db::pending_batch(&mut db, &start)
   1630             .await
   1631             .expect("pending_batch");
   1632         assert_eq!(pendings.len(), 3);
   1633 
   1634         // Max 100 txs in batch
   1635         for i in 0..100 {
   1636             db::make_transfer(
   1637                 &pool,
   1638                 &Transfer {
   1639                     request_uid: HashCode::rand(),
   1640                     amount: decimal(format!("{}", i + 1)),
   1641                     exchange_base_url: url("https://exchange.test.com/"),
   1642                     metadata: None,
   1643                     wtid: ShortHashCode::rand(),
   1644                     creditor_id: 31000163100000000,
   1645                     creditor_name: "Name".into(),
   1646                 },
   1647                 &Timestamp::now(),
   1648             )
   1649             .await
   1650             .expect("transfer");
   1651         }
   1652         let pendings = db::pending_batch(&mut db, &start)
   1653             .await
   1654             .expect("pending_batch");
   1655         assert_eq!(pendings.len(), 100);
   1656 
   1657         // Skip uploaded
   1658         for i in 0..=10 {
   1659             db::initiated_submit_success(&mut db, i, &Timestamp::now(), i)
   1660                 .await
   1661                 .expect("status success");
   1662         }
   1663         let pendings = db::pending_batch(&mut db, &start)
   1664             .await
   1665             .expect("pending_batch");
   1666         assert_eq!(pendings.len(), 93);
   1667 
   1668         // Skip failed
   1669         for i in 0..=10 {
   1670             db::initiated_submit_permanent_failure(&mut db, 10 + i, "failure")
   1671                 .await
   1672                 .expect("status failure");
   1673         }
   1674         let pendings = db::pending_batch(&mut db, &start)
   1675             .await
   1676             .expect("pending_batch");
   1677         assert_eq!(pendings.len(), 83);
   1678     }
   1679 }