commit 02ba8844ac5e62515dd6b4bbba900cf27cf4582b
parent 70339a8b15cc3b103fb93048a8e9e99891280836
Author: Antoine A <>
Date: Tue, 16 Dec 2025 15:40:31 +0100
cyclos: link outgoing transactions to initiated and chargeback
Diffstat:
9 files changed, 388 insertions(+), 71 deletions(-)
diff --git a/taler-cyclos/db/cyclos-0001.sql b/taler-cyclos/db/cyclos-0001.sql
@@ -24,6 +24,7 @@ COMMENT ON TYPE taler_amount IS 'Stores an amount, fraction is in units of 1/100
CREATE TABLE tx_in(
tx_in_id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
transfer_id INT8 UNIQUE,
+ tx_id INT8,
amount taler_amount NOT NULL,
subject TEXT NOT NULL,
debit_account INT8 NOT NULL,
@@ -36,6 +37,7 @@ COMMENT ON TABLE tx_in IS 'Incoming transactions';
CREATE TABLE tx_out(
tx_out_id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
transfer_id INT8 UNIQUE,
+ tx_id INT8,
amount taler_amount NOT NULL,
subject TEXT NOT NULL,
credit_account INT8 NOT NULL,
@@ -43,7 +45,7 @@ CREATE TABLE tx_out(
valued_at INT8 NOT NULL,
registered_at INT8 NOT NULL
);
-COMMENT ON TABLE tx_in IS 'Outgoing transactions';
+COMMENT ON TABLE tx_out IS 'Outgoing transactions';
CREATE TYPE incoming_type AS ENUM
('reserve' ,'kyc', 'wad');
@@ -61,7 +63,7 @@ CREATE TABLE taler_in(
END
)
);
-COMMENT ON TABLE tx_in IS 'Incoming talerable transactions';
+COMMENT ON TABLE taler_in IS 'Incoming talerable transactions';
CREATE UNIQUE INDEX taler_in_unique_reserve_pub ON taler_in (metadata) WHERE type = 'reserve';
@@ -70,7 +72,7 @@ CREATE TABLE taler_out(
wtid BYTEA NOT NULL UNIQUE CHECK (LENGTH(wtid)=32),
exchange_base_url TEXT NOT NULL
);
-COMMENT ON TABLE tx_in IS 'Outgoing talerable transactions';
+COMMENT ON TABLE taler_out IS 'Outgoing talerable transactions';
CREATE TYPE transfer_status AS ENUM(
'pending',
@@ -95,7 +97,7 @@ CREATE TABLE initiated(
tx_out_id INT8 UNIQUE REFERENCES tx_out(tx_out_id) ON DELETE CASCADE,
initiated_at INT8 NOT NULL
);
-COMMENT ON TABLE tx_in IS 'Initiated outgoing transactions';
+COMMENT ON TABLE initiated IS 'Initiated outgoing transactions';
CREATE TABLE transfer(
initiated_id INT8 PRIMARY KEY REFERENCES initiated(initiated_id) ON DELETE CASCADE,
@@ -111,7 +113,7 @@ CREATE TABLE bounced(
chargeback_id INT8 NOT NULL UNIQUE,
reason TEXT NOT NULL
);
-COMMENT ON TABLE tx_in IS 'Bounced transactions';
+COMMENT ON TABLE bounced IS 'Bounced transactions';
CREATE TABLE kv(
key TEXT NOT NULL UNIQUE PRIMARY KEY,
diff --git a/taler-cyclos/db/cyclos-procedures.sql b/taler-cyclos/db/cyclos-procedures.sql
@@ -40,6 +40,7 @@ $do$;
CREATE FUNCTION register_tx_in(
IN in_transfer_id INT8,
+ IN in_tx_id INT8,
IN in_amount taler_amount,
IN in_subject TEXT,
IN in_debit_account INT8,
@@ -80,6 +81,7 @@ END IF;
out_valued_at = in_valued_at;
INSERT INTO tx_in (
transfer_id,
+ tx_id,
amount,
subject,
debit_account,
@@ -88,6 +90,7 @@ INSERT INTO tx_in (
registered_at
) VALUES (
in_transfer_id,
+ in_tx_id,
in_amount,
in_subject,
in_debit_account,
@@ -117,6 +120,7 @@ COMMENT ON FUNCTION register_tx_in IS 'Register an incoming transaction idempote
CREATE FUNCTION register_tx_out(
IN in_transfer_id INT8,
+ IN in_tx_id INT8,
IN in_amount taler_amount,
IN in_subject TEXT,
IN in_credit_account INT8,
@@ -144,6 +148,7 @@ END IF;
-- Insert new outgoing transaction
INSERT INTO tx_out (
transfer_id,
+ tx_id,
amount,
subject,
credit_account,
@@ -152,6 +157,7 @@ INSERT INTO tx_out (
registered_at
) VALUES (
in_transfer_id,
+ in_tx_id,
in_amount,
in_subject,
in_credit_account,
@@ -169,8 +175,8 @@ SET
tx_out_id = out_tx_row_id,
status = 'success',
status_msg = NULL
-WHERE tx_id = in_transfer_id; -- This will not work, should we pass the transaction id ?
-IF FOUND THEN
+WHERE tx_id = in_tx_id;
+IF FOUND OR EXISTS(SELECT FROM bounced WHERE chargeback_id = in_transfer_id) THEN
out_result = 'known';
ELSE
out_result = 'recovered';
@@ -192,13 +198,7 @@ IF in_wtid IS NOT NULL THEN
PERFORM pg_notify('taler_out', out_tx_row_id || '');
END IF;
ELSIF in_bounced IS NOT NULL THEN
- --UPDATE initiated
- --SET
- -- tx_out_id = out_tx_row_id,
- -- status = 'success',
- -- status_msg = NULL
- --FROM bounced JOIN tx_in USING (tx_in_id)
- --WHERE initiated.initiated_id = bounced.initiated_id AND tx_in.transfer_id = in_transfer_id;
+ -- TODO Reconstruct bounces ?
END IF;
END $$;
COMMENT ON FUNCTION register_tx_out IS 'Register an outgoing transaction idempotently';
@@ -300,6 +300,7 @@ END $$;
CREATE FUNCTION register_bounced_tx_in(
IN in_transfer_id INT8,
+ IN in_tx_id INT8,
IN in_amount taler_amount,
IN in_subject TEXT,
IN in_debit_account INT8,
@@ -317,7 +318,7 @@ BEGIN
-- Register incoming transaction idempotently
SELECT register_tx_in.out_tx_row_id, register_tx_in.out_new
INTO out_tx_row_id, out_tx_new
-FROM register_tx_in(in_transfer_id, in_amount, in_subject, in_debit_account, in_debit_name, in_valued_at, NULL, NULL, in_now);
+FROM register_tx_in(in_transfer_id, in_tx_id, in_amount, in_subject, in_debit_account, in_debit_name, in_valued_at, NULL, NULL, in_now);
-- Register the bounce
INSERT INTO bounced (
diff --git a/taler-cyclos/src/bin/cyclos-harness.rs b/taler-cyclos/src/bin/cyclos-harness.rs
@@ -295,6 +295,30 @@ async fn logic_harness(cfg: &Config, reset: bool) -> anyhow::Result<()> {
harness.worker().await?;
balance.expect_sub(amount).await;
+ step("Test transfer transactions");
+ let amount = decimal("3.5");
+ // Init a transfer to client
+ let transfer_id = harness
+ .custom_transfer(
+ amount,
+ &FullCyclosPayto::new(CyclosId(harness.client_id), "Client".to_string()),
+ )
+ .await;
+ // Check transfer pending
+ harness
+ .expect_transfer_status(transfer_id, TransferState::pending, None)
+ .await;
+ // Should send
+ harness.worker().await?;
+ // Wait for transaction to finalize
+ balance.expect_sub(amount).await;
+ // Should register
+ harness.worker().await?;
+ // Check transfer is now successful
+ harness
+ .expect_transfer_status(transfer_id, TransferState::success, None)
+ .await;
+
step("Test transfer to self");
// Init a transfer to self
let transfer_id = harness
@@ -314,6 +338,25 @@ async fn logic_harness(cfg: &Config, reset: bool) -> anyhow::Result<()> {
)
.await;
+ step("Test transfer to unknown account");
+ // Init a transfer to self
+ let transfer_id = harness
+ .custom_transfer(
+ decimal("10.1"),
+ &FullCyclosPayto::new(CyclosId(42), "Unknown".to_string()),
+ )
+ .await;
+ // Should failed
+ harness.worker().await?;
+ // Check transfer failed
+ harness
+ .expect_transfer_status(
+ transfer_id,
+ TransferState::permanent_failure,
+ Some("unknown BasicUser 42"),
+ )
+ .await;
+
step("Test unexpected outgoing");
// Manual tx from the exchange
let amount = decimal("4");
@@ -324,6 +367,7 @@ async fn logic_harness(cfg: &Config, reset: bool) -> anyhow::Result<()> {
// Wait for transaction to finalize
balance.expect_sub(amount).await;
harness.worker().await?;
+
step("Finish");
Ok(())
diff --git a/taler-cyclos/src/cyclos_api/api.rs b/taler-cyclos/src/cyclos_api/api.rs
@@ -75,7 +75,7 @@ fn parse<'de, T: Deserialize<'de>>(str: &'de str) -> Result<T, ErrKind> {
async fn json_body<T: DeserializeOwned>(res: reqwest::Response) -> Result<T, ErrKind> {
// TODO check content type?
let body = res.text().await?;
- //println!("{body}");
+ // println!("{body}");
let parsed = parse(&body)?;
Ok(parsed)
}
diff --git a/taler-cyclos/src/cyclos_api/types.rs b/taler-cyclos/src/cyclos_api/types.rs
@@ -100,6 +100,7 @@ pub struct HistoryItem {
pub ty: Type,
pub description: Option<String>,
pub related_account: RelatedAccount,
+ pub transaction: Option<TransactionMini>
}
#[derive(Debug, Deserialize)]
@@ -139,6 +140,13 @@ pub struct Transaction {
pub ty: Type,
}
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TransactionMini {
+ pub id: CyclosId,
+ pub kind: TxKind,
+}
+
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
/// Indicates the reason the transfer was created.
diff --git a/taler-cyclos/src/db.rs b/taler-cyclos/src/db.rs
@@ -41,7 +41,8 @@ use crate::{CyclosId, FullCyclosPayto};
#[derive(Debug, Clone)]
pub struct TxIn {
- pub id: u64,
+ pub transfer_id: u64,
+ pub tx_id: Option<u64>,
pub amount: Decimal,
pub subject: String,
pub debtor: FullCyclosPayto,
@@ -51,15 +52,20 @@ pub struct TxIn {
impl Display for TxIn {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self {
- id,
+ transfer_id,
+ tx_id,
amount,
subject,
debtor,
valued_at,
} = self;
+ let tx_id = match tx_id {
+ Some(id) => format_args!(":{}", *id),
+ None => format_args!(""),
+ };
write!(
f,
- "{valued_at} {id} {amount} ({} {}) '{subject}'",
+ "{valued_at} {transfer_id}{tx_id} {amount} ({} {}) '{subject}'",
debtor.0, debtor.name
)
}
@@ -67,7 +73,8 @@ impl Display for TxIn {
#[derive(Debug, Clone)]
pub struct TxOut {
- pub id: u64,
+ pub transfer_id: u64,
+ pub tx_id: Option<u64>,
pub amount: Decimal,
pub subject: String,
pub creditor: FullCyclosPayto,
@@ -77,15 +84,20 @@ pub struct TxOut {
impl Display for TxOut {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self {
- id,
+ transfer_id,
+ tx_id,
amount,
subject,
creditor,
valued_at,
} = self;
+ let tx_id = match tx_id {
+ Some(id) => format_args!(":{}", *id),
+ None => format_args!(""),
+ };
write!(
f,
- "{valued_at} {id} {amount} ({} {}) '{subject}'",
+ "{valued_at} {transfer_id}{tx_id} {amount} ({} {}) '{subject}'",
creditor.0, creditor.name
)
}
@@ -141,7 +153,7 @@ pub async fn register_tx_in_admin(
sqlx::query(
"
SELECT out_reserve_pub_reuse, out_tx_row_id, out_valued_at, out_new
- FROM register_tx_in(NULL, ($1, $2)::taler_amount, $3, $4, $5, $6, $7, $8, $6)
+ FROM register_tx_in(NULL, NULL, ($1, $2)::taler_amount, $3, $4, $5, $6, $7, $8, $6)
",
)
.bind_decimal(&tx.amount)
@@ -175,10 +187,11 @@ pub async fn register_tx_in(
sqlx::query(
"
SELECT out_reserve_pub_reuse, out_tx_row_id, out_valued_at, out_new
- FROM register_tx_in($1, ($2, $3)::taler_amount, $4, $5, $6, $7, $8, $9, $10)
+ FROM register_tx_in($1, $2, ($3, $4)::taler_amount, $5, $6, $7, $8, $9, $10, $11)
",
)
- .bind(tx.id as i64)
+ .bind(tx.transfer_id as i64)
+ .bind(tx.tx_id.map(|it| it as i64))
.bind_decimal(&tx.amount)
.bind(&tx.subject)
.bind(tx.debtor.0 as i64)
@@ -236,10 +249,11 @@ pub async fn register_tx_out(
let query = sqlx::query(
"
SELECT out_result, out_tx_row_id
- FROM register_tx_out($1, ($2, $3)::taler_amount, $4, $5, $6, $7, $8, $9, $10, $11)
+ FROM register_tx_out($1, $2, ($3, $4)::taler_amount, $5, $6, $7, $8, $9, $10, $11, $12)
",
)
- .bind(tx.id as i64)
+ .bind(tx.transfer_id as i64)
+ .bind(tx.tx_id.map(|it| it as i64))
.bind_decimal(&tx.amount)
.bind(&tx.subject)
.bind(tx.creditor.0 as i64)
@@ -331,10 +345,11 @@ pub async fn register_bounced_tx_in(
sqlx::query(
"
SELECT out_tx_row_id, out_tx_new
- FROM register_bounced_tx_in($1, ($2, $3)::taler_amount, $4, $5, $6, $7, $8, $9, $10)
+ FROM register_bounced_tx_in($1, $2, ($3, $4)::taler_amount, $5, $6, $7, $8, $9, $10, $11)
",
)
- .bind(tx.id as i64)
+ .bind(tx.transfer_id as i64)
+ .bind(tx.tx_id.map(|it| it as i64))
.bind_decimal(&tx.amount)
.bind(&tx.subject)
.bind(tx.debtor.0 as i64)
@@ -750,7 +765,7 @@ mod test {
use taler_common::{
api_common::{EddsaPublicKey, HashCode, ShortHashCode},
api_params::{History, Page},
- api_wire::TransferRequest,
+ api_wire::{TransferRequest, TransferState, TransferStatus},
types::{
amount::{Currency, amount, decimal},
payto::payto,
@@ -764,8 +779,8 @@ mod test {
constants::CONFIG_SOURCE,
cyclos_payto,
db::{
- self, AddIncomingResult, AddOutgoingResult, BounceResult, TransferResult, TxIn,
- TxInAdmin, TxOut, TxOutKind,
+ self, AddIncomingResult, AddOutgoingResult, BounceResult, Initiated, TransferResult,
+ TxIn, TxInAdmin, TxOut, TxOutKind,
},
};
@@ -823,7 +838,8 @@ mod test {
let now = now_sql_stable_timestamp();
let later = now + Span::new().hours(2);
let tx = TxIn {
- id: now.as_microsecond() as u64,
+ transfer_id: now.as_microsecond() as u64,
+ tx_id: None,
amount: decimal("10"),
subject: "subject".to_owned(),
debtor: cyclos_payto("payto://cyclos/31000163100000000?receiver-name=name"),
@@ -864,7 +880,7 @@ mod test {
db::register_tx_in(
db,
&TxIn {
- id: later.as_microsecond() as u64,
+ transfer_id: later.as_microsecond() as u64,
valued_at: later,
..tx
},
@@ -1008,7 +1024,7 @@ mod test {
let (mut db, pool) = setup().await;
async fn routine(db: &mut PgConnection, first: &TxOutKind, second: &TxOutKind) {
- let id = sqlx::query("SELECT count(*) + 1 FROM tx_out")
+ let transfer_id = sqlx::query("SELECT count(*) + 1 FROM tx_out")
.try_map(|r: PgRow| r.try_get_u64(0))
.fetch_one(&mut *db)
.await
@@ -1016,7 +1032,8 @@ mod test {
let now = now_sql_stable_timestamp();
let later = now + Span::new().hours(2);
let tx = TxOut {
- id,
+ transfer_id,
+ tx_id: Some(transfer_id),
amount: decimal("10"),
subject: "subject".to_owned(),
creditor: cyclos_payto("payto://cyclos/31000163100000000?receiver-name=name"),
@@ -1041,7 +1058,7 @@ mod test {
.unwrap(),
TransferResult::Success { .. }
));
- db::initiated_submit_success(&mut *db, 1, &Timestamp::now(), id)
+ db::initiated_submit_success(&mut *db, 1, &Timestamp::now(), transfer_id)
.await
.expect("status success");
@@ -1052,7 +1069,7 @@ mod test {
.expect("register tx out"),
AddOutgoingResult {
result: db::RegisterResult::known,
- row_id: id,
+ row_id: transfer_id,
}
);
// Idempotent
@@ -1070,7 +1087,7 @@ mod test {
.expect("register tx out"),
AddOutgoingResult {
result: db::RegisterResult::idempotent,
- row_id: id,
+ row_id: transfer_id,
}
);
// Recovered
@@ -1078,7 +1095,8 @@ mod test {
db::register_tx_out(
&mut *db,
&TxOut {
- id: id + 1,
+ transfer_id: transfer_id + 1,
+ tx_id: Some(transfer_id + 1),
valued_at: later,
..tx.clone()
},
@@ -1089,7 +1107,7 @@ mod test {
.expect("register tx out"),
AddOutgoingResult {
result: db::RegisterResult::recovered,
- row_id: id + 1,
+ row_id: transfer_id + 1,
}
);
}
@@ -1271,7 +1289,8 @@ mod test {
db::register_bounced_tx_in(
&mut db,
&TxIn {
- id: 12,
+ transfer_id: 12,
+ tx_id: None,
amount,
subject: "subject".to_owned(),
debtor: payto.clone(),
@@ -1293,7 +1312,8 @@ mod test {
db::register_bounced_tx_in(
&mut db,
&TxIn {
- id: 12,
+ transfer_id: 12,
+ tx_id: None,
amount: amount.clone(),
subject: "subject".to_owned(),
debtor: payto.clone(),
@@ -1316,7 +1336,8 @@ mod test {
db::register_bounced_tx_in(
&mut db,
&TxIn {
- id: 13,
+ transfer_id: 13,
+ tx_id: None,
amount: amount.clone(),
subject: "subject".to_owned(),
debtor: payto.clone(),
@@ -1336,6 +1357,104 @@ mod test {
}
#[tokio::test]
+ async fn status() {
+ let (mut db, _) = setup().await;
+ let cyclos_payto = cyclos_payto("payto://cyclos/31000163100000000?receiver-name=name");
+
+ async fn check_status(
+ db: &mut PgConnection,
+ id: u64,
+ status: TransferState,
+ msg: Option<&str>,
+ ) {
+ let transfer = db::transfer_by_id(db, id, &CURRENCY)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(
+ (status, msg),
+ (transfer.status, transfer.status_msg.as_deref())
+ );
+ }
+
+ // Unknown transfer
+ db::initiated_submit_permanent_failure(&mut db, 1, &Timestamp::now(), "msg")
+ .await
+ .unwrap();
+ db::initiated_submit_success(&mut db, 1, &Timestamp::now(), 12)
+ .await
+ .unwrap();
+
+ // Failure
+ db::make_transfer(
+ &mut db,
+ &TransferRequest {
+ request_uid: HashCode::rand(),
+ amount: amount(format!("{}:1", *CURRENCY)),
+ exchange_base_url: url("https://exchange.test.com/"),
+ wtid: ShortHashCode::rand(),
+ credit_account: payto(
+ "payto://iban/HU02162000031000164800000000?receiver-name=name",
+ ),
+ },
+ &cyclos_payto,
+ &Timestamp::now(),
+ )
+ .await
+ .expect("transfer");
+ check_status(&mut db, 1, TransferState::pending, None).await;
+ db::initiated_submit_permanent_failure(&mut db, 1, &Timestamp::now(), "error status")
+ .await
+ .unwrap();
+ check_status(
+ &mut db,
+ 1,
+ TransferState::permanent_failure,
+ Some("error status"),
+ )
+ .await;
+
+ // Success
+ db::make_transfer(
+ &mut db,
+ &TransferRequest {
+ request_uid: HashCode::rand(),
+ amount: amount(format!("{}:2", *CURRENCY)),
+ exchange_base_url: url("https://exchange.test.com/"),
+ wtid: ShortHashCode::rand(),
+ credit_account: payto(
+ "payto://iban/HU02162000031000164800000000?receiver-name=name",
+ ),
+ },
+ &cyclos_payto,
+ &Timestamp::now(),
+ )
+ .await
+ .expect("transfer");
+ check_status(&mut db, 2, TransferState::pending, None).await;
+ db::initiated_submit_success(&mut db, 2, &Timestamp::now(), 3)
+ .await
+ .unwrap();
+ check_status(&mut db, 2, TransferState::pending, None).await;
+ db::register_tx_out(
+ &mut db,
+ &TxOut {
+ transfer_id: 2,
+ tx_id: Some(3),
+ amount: decimal("2"),
+ subject: "".to_string(),
+ creditor: cyclos_payto,
+ valued_at: Timestamp::now(),
+ },
+ &TxOutKind::Simple,
+ &Timestamp::now(),
+ )
+ .await
+ .unwrap();
+ check_status(&mut db, 2, TransferState::success, None).await;
+ }
+
+ #[tokio::test]
async fn batch() {
let (mut db, _) = setup().await;
let start = Timestamp::now();
diff --git a/taler-cyclos/src/worker.rs b/taler-cyclos/src/worker.rs
@@ -86,7 +86,6 @@ impl Worker<'_> {
// TODO fail_point("init-tx")?;
match res {
Ok(tx) => {
- dbg!(tx.date);
// Update transaction status, on failure the initiated transaction will be orphan
db::initiated_submit_success(
&mut *self.db,
@@ -95,11 +94,12 @@ impl Worker<'_> {
tx.id.0,
)
.await?;
+ trace!(target: "worker", "init tx {}", tx.id);
}
Err(e) => {
let msg = match e.kind {
ErrKind::Unknown(NotFoundError { entity_type, key }) => {
- format!("Unknown {entity_type} {key}")
+ format!("unknown {entity_type} {key}")
}
ErrKind::Forbidden(err) => err.to_string(),
_ => return Err(e.into()),
@@ -124,7 +124,7 @@ impl Worker<'_> {
async fn ingest_in(&mut self, tx: TxIn) -> WorkerResult {
match self.account_type {
AccountType::Exchange => {
- let transfer = self.client.transfer(tx.id).await?;
+ let transfer = self.client.transfer(tx.transfer_id).await?;
let bounce = async |db: &mut PgConnection,
reason: &str|
-> Result<(), WorkerError> {
@@ -220,7 +220,7 @@ impl Worker<'_> {
async fn ingest_out(&mut self, tx: TxOut) -> WorkerResult {
match self.account_type {
AccountType::Exchange => {
- let transfer = self.client.transfer(tx.id).await?;
+ let transfer = self.client.transfer(tx.transfer_id).await?;
let kind = if let Ok(subject) = subject::parse_outgoing(&tx.subject) {
TxOutKind::Talerable(subject)
@@ -264,8 +264,7 @@ impl Worker<'_> {
warn!(target: "worker", "out malformed (recovered) {tx}")
}
TxOutKind::Bounce(_) => {
- // Chargeback are not stored as initiated and are therefor always recovered
- info!(target: "worker", "out bounce {tx}")
+ warn!(target: "worker", "out bounce (recovered) {tx}")
}
TxOutKind::Talerable(_) => {
warn!(target: "worker", "out (recovered) {tx}")
@@ -308,7 +307,8 @@ pub fn extract_tx_info(tx: HistoryItem) -> Tx {
};
if tx.amount.starts_with("-") {
Tx::Out(TxOut {
- id: *tx.id,
+ transfer_id: *tx.id,
+ tx_id: tx.transaction.map(|it| *it.id),
amount,
subject: tx.description.unwrap_or_default(),
creditor: payto,
@@ -316,7 +316,8 @@ pub fn extract_tx_info(tx: HistoryItem) -> Tx {
})
} else {
Tx::In(TxIn {
- id: *tx.id,
+ transfer_id: *tx.id,
+ tx_id: tx.transaction.map(|it| *it.id),
amount,
subject: tx.description.unwrap_or_default(),
debtor: payto,
diff --git a/taler-cyclos/tests/api.rs b/taler-cyclos/tests/api.rs
@@ -0,0 +1,141 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2025 Taler Systems SA
+
+ TALER is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Affero General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ TALER is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License along with
+ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+*/
+
+use std::sync::Arc;
+
+use jiff::{Timestamp, Zoned};
+use sqlx::PgPool;
+use taler_api::{api::TalerRouter as _, auth::AuthMethod, subject::OutgoingSubject};
+use taler_common::{
+ api_common::ShortHashCode,
+ api_revenue::RevenueConfig,
+ api_wire::{OutgoingHistory, TransferState, WireConfig},
+ types::{amount::amount, payto::payto, url},
+};
+use taler_test_utils::{
+ Router, db_test_setup,
+ routine::{admin_add_incoming_routine, revenue_routine, routine_pagination, transfer_routine},
+ server::TestServer,
+};
+
+/*
+
+async fn setup() -> (Router, PgPool) {
+ let pool = db_test_setup(CONFIG_SOURCE).await;
+ let api = Arc::new(
+ MagnetApi::start(
+ pool.clone(),
+ payto("payto://iban/HU02162000031000164800000000?receiver-name=name"),
+ )
+ .await,
+ );
+ let server = Router::new()
+ .wire_gateway(api.clone(), AuthMethod::None)
+ .revenue(api, AuthMethod::None)
+ .finalize();
+
+ (server, pool)
+}
+
+#[tokio::test]
+async fn config() {
+ let (server, _) = setup().await;
+ server
+ .get("/taler-wire-gateway/config")
+ .await
+ .assert_ok_json::<WireConfig>();
+ server
+ .get("/taler-revenue/config")
+ .await
+ .assert_ok_json::<RevenueConfig>();
+}
+
+#[tokio::test]
+async fn transfer() {
+ let (server, _) = setup().await;
+ transfer_routine(
+ &server,
+ TransferState::pending,
+ &payto("payto://iban/HU02162000031000164800000000?receiver-name=name"),
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn outgoing_history() {
+ let (server, pool) = setup().await;
+ routine_pagination::<OutgoingHistory, _>(
+ &server,
+ "/taler-wire-gateway/history/outgoing",
+ |it| {
+ it.outgoing_transactions
+ .into_iter()
+ .map(|it| *it.row_id as i64)
+ .collect()
+ },
+ |_, i| {
+ let acquire = pool.acquire();
+ async move {
+ let mut conn = acquire.await.unwrap();
+ let now = Zoned::now().date();
+ db::register_tx_out(
+ &mut *conn,
+ &db::TxOut {
+ code: i as u64,
+ amount: amount("EUR:10"),
+ subject: "subject".to_owned(),
+ creditor: magnet_payto(
+ "payto://iban/HU30162000031000163100000000?receiver-name=name",
+ ),
+ value_date: now,
+ status: TxStatus::Completed,
+ },
+ &TxOutKind::Talerable(OutgoingSubject(
+ ShortHashCode::rand(),
+ url("https://exchange.test"),
+ )),
+ &Timestamp::now(),
+ )
+ .await
+ .unwrap();
+ }
+ },
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn admin_add_incoming() {
+ let (server, _) = setup().await;
+ admin_add_incoming_routine(
+ &server,
+ &payto("payto://iban/HU02162000031000164800000000?receiver-name=name"),
+ true,
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn revenue() {
+ let (server, _) = setup().await;
+ revenue_routine(
+ &server,
+ &payto("payto://iban/HU02162000031000164800000000?receiver-name=name"),
+ true,
+ )
+ .await;
+}
+*/
+\ No newline at end of file
diff --git a/taler-magnet-bank/src/bin/magnet-bank-harness.rs b/taler-magnet-bank/src/bin/magnet-bank-harness.rs
@@ -393,25 +393,6 @@ async fn logic_harness(cfg: &Config, reset: bool) -> anyhow::Result<()> {
balance.expect(-34).await;
harness.worker().await?;
- step("Test transfer to self");
- // Init a transfer to self
- let transfer_id = harness
- .custom_transfer(
- 101,
- &FullHuPayto::new(harness.exchange.iban.clone(), "Self".to_string()),
- )
- .await;
- // Should failed
- harness.worker().await?;
- // Check transfer failed
- harness
- .expect_transfer_status(
- transfer_id,
- TransferState::permanent_failure,
- Some("409 FORRAS_SZAMLA_ESZAMLA_EGYEZIK 'A forrás és az ellenszámla egyezik!'"),
- )
- .await;
-
step("Test transfer transactions");
// Init a transfer to client
let transfer_id = harness
@@ -435,6 +416,25 @@ async fn logic_harness(cfg: &Config, reset: bool) -> anyhow::Result<()> {
.expect_transfer_status(transfer_id, TransferState::success, None)
.await;
+ step("Test transfer to self");
+ // Init a transfer to self
+ let transfer_id = harness
+ .custom_transfer(
+ 101,
+ &FullHuPayto::new(harness.exchange.iban.clone(), "Self".to_string()),
+ )
+ .await;
+ // Should failed
+ harness.worker().await?;
+ // Check transfer failed
+ harness
+ .expect_transfer_status(
+ transfer_id,
+ TransferState::permanent_failure,
+ Some("409 FORRAS_SZAMLA_ESZAMLA_EGYEZIK 'A forrás és az ellenszámla egyezik!'"),
+ )
+ .await;
+
step("Test transfer to unknown account");
let transfer_id = harness.custom_transfer(103, &unknown_account).await;
harness.worker().await?;