commit 4071009a46ea4d0b9f1ed418fe27d4037b3a1254
parent 0b262a884fa9afaf4b718cf7b841f0fa0a9dc62f
Author: Antoine A <>
Date: Tue, 28 Jul 2026 18:57:39 +0200
wise: add taler-wise adapter
Diffstat:
57 files changed, 3438 insertions(+), 90 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -9,9 +9,11 @@ keys.json
Cargo.lock
debian/taler-magnet-bank
debian/taler-cyclos
+debian/taler-wise
debian/taler-apns-relay
debian/files
debian/*.substvars
debian/*debhelper*
postgres-language-server.jsonc
-*.p8
-\ No newline at end of file
+*.p8
+*.ts
+\ No newline at end of file
diff --git a/Cargo.toml b/Cargo.toml
@@ -36,7 +36,7 @@ criterion = { version = "0.8", default-features = false }
tracing = "0.1"
tracing-subscriber = "0.3"
clap = { version = "4.5", features = ["derive"] }
-jiff = { version = "0.2", default-features = false, features = ["perf-inline", "std"] }
+jiff = { version = "0.2", default-features = false, features = ["perf-inline", "std", "serde"] }
tempfile = "3.15"
taler-common = { path = "common/taler-common" }
taler-api = { path = "common/taler-api" }
diff --git a/Makefile b/Makefile
@@ -13,7 +13,7 @@ all: build
.PHONY: build
build:
- cargo build --release --bin taler-magnet-bank --bin taler-cyclos --bin taler-apns-relay
+ cargo build --release --bin taler-magnet-bank --bin taler-cyclos --bin taler-wise --bin taler-apns-relay
.PHONY: install-files
install-files:
@@ -27,6 +27,11 @@ install-files:
install -m 644 -D -t $(share_dir)/taler-cyclos/sql adapters/taler-cyclos/db/cyclos*.sql
install -m 644 -D -t $(man_dir)/man1 doc/prebuilt/man/taler-cyclos.1
install -m 644 -D -t $(man_dir)/man5 doc/prebuilt/man/taler-cyclos.conf.5
+ install -m 644 -D -t $(share_dir)/taler-wise/config.d adapters/taler-wise/wise.conf
+ install -m 644 -D -t $(share_dir)/taler-wise/sql common/taler-common/db/versioning.sql
+ install -m 644 -D -t $(share_dir)/taler-wise/sql adapters/taler-wise/db/wise*.sql
+ install -m 644 -D -t $(man_dir)/man1 doc/prebuilt/man/taler-wise.1
+ install -m 644 -D -t $(man_dir)/man5 doc/prebuilt/man/taler-wise.conf.5
install -m 644 -D -t $(share_dir)/taler-apns-relay/config.d taler-apns-relay/apns-relay.conf
install -m 644 -D -t $(share_dir)/taler-apns-relay/sql common/taler-common/db/versioning.sql
install -m 644 -D -t $(share_dir)/taler-apns-relay/sql taler-apns-relay/db/apns-relay*.sql
@@ -34,12 +39,14 @@ install-files:
install -m 644 -D -t $(man_dir)/man5 doc/prebuilt/man/taler-apns-relay.conf.5
install -D -t $(bin_dir) contrib/taler-magnet-bank-dbconfig
install -D -t $(bin_dir) contrib/taler-cyclos-dbconfig
+ install -D -t $(bin_dir) contrib/taler-wise-dbconfig
install -D -t $(bin_dir) contrib/taler-apns-relay-dbconfig
.PHONY: install
install: build install-files
install -D -t $(bin_dir) target/release/taler-magnet-bank
install -D -t $(bin_dir) target/release/taler-cyclos
+ install -D -t $(bin_dir) target/release/taler-wise
install -D -t $(bin_dir) target/release/taler-apns-relay
.PHONY: check
diff --git a/adapters/taler-cyclos/Cargo.toml b/adapters/taler-cyclos/Cargo.toml
@@ -14,7 +14,7 @@ doctest = false
[dependencies]
sqlx.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
-jiff = { workspace = true, features = ["serde"] }
+jiff.workspace = true
taler-common.workspace = true
taler-api.workspace = true
taler-build.workspace = true
diff --git a/adapters/taler-cyclos/db/cyclos-procedures.sql b/adapters/taler-cyclos/db/cyclos-procedures.sql
@@ -300,7 +300,7 @@ CREATE FUNCTION taler_transfer(
LANGUAGE plpgsql AS $$
BEGIN
-- Check for idempotence and conflict
-SELECT (amount != in_amount
+SELECT (amount != in_amount
OR credit_account != in_credit_account
OR exchange_base_url != in_exchange_base_url
OR wtid != in_wtid
@@ -331,7 +331,7 @@ INSERT INTO initiated (
in_credit_account,
in_credit_name,
in_now
-) RETURNING initiated_id
+) RETURNING initiated_id
INTO out_initiated_row_id;
-- Insert a transfer operation
INSERT INTO transfer (
@@ -366,11 +366,11 @@ BEGIN
IF FOUND THEN
-- Update unsettled transaction status
IF current_status = 'success' AND in_status = 'permanent_failure' THEN
- UPDATE initiated
+ UPDATE initiated
SET status = 'late_failure', status_msg = in_status_msg
WHERE initiated_id = in_initiated_id;
ELSIF current_status NOT IN ('success', 'permanent_failure', 'late_failure') THEN
- UPDATE initiated
+ UPDATE initiated
SET status = in_status, status_msg = in_status_msg
WHERE initiated_id = in_initiated_id;
END IF;
@@ -428,8 +428,8 @@ DECLARE
idempotent BOOLEAN;
BEGIN
--- Check idempotency
-SELECT type = in_type
+-- Check idempotency
+SELECT type = in_type
AND account_pub = in_account_pub
AND recurrent = in_recurrent
INTO idempotent
diff --git a/adapters/taler-magnet-bank/Cargo.toml b/adapters/taler-magnet-bank/Cargo.toml
@@ -18,7 +18,7 @@ rpassword = "7.4"
getrandom = "0.4.1"
sqlx.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
-jiff = { workspace = true, features = ["serde"] }
+jiff.workspace = true
taler-common.workspace = true
taler-api.workspace = true
taler-build.workspace = true
diff --git a/adapters/taler-magnet-bank/src/lib.rs b/adapters/taler-magnet-bank/src/lib.rs
@@ -21,7 +21,7 @@ use taler_api::api::{Router, TalerRouter as _};
use taler_common::{
config::Config,
types::{
- iban::{Country, IBAN, IbanErrorKind, ParseIbanError},
+ iban::{Country, IBAN, IbanErrKind, ParseIbanErr},
payto::{FullPayto, IbanPayto, Payto, PaytoErr, PaytoImpl, PaytoURI, TransferPayto},
},
};
@@ -126,11 +126,11 @@ pub enum HuIbanErr {
#[error("invalid checksum for {0} expected {1} got {2}")]
Checksum(&'static str, u8, u8),
#[error(transparent)]
- Iban(IbanErrorKind),
+ Iban(IbanErrKind),
}
-impl From<ParseIbanError> for HuIbanErr {
- fn from(value: ParseIbanError) -> Self {
+impl From<ParseIbanErr> for HuIbanErr {
+ fn from(value: ParseIbanErr) -> Self {
Self::Iban(value.kind)
}
}
diff --git a/adapters/taler-wise/Cargo.toml b/adapters/taler-wise/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "taler-wise"
+version.workspace = true
+edition.workspace = true
+authors.workspace = true
+homepage.workspace = true
+repository.workspace = true
+license-file.workspace = true
+
+[dependencies]
+clap.workspace = true
+compact_str.workspace = true
+jiff.workspace = true
+owo-colors.workspace = true
+sqlx.workspace = true
+taler-api.workspace = true
+taler-build.workspace = true
+taler-common.workspace = true
+url.workspace = true
+http-client.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+hyper.workspace = true
+aws-lc-rs.workspace = true
+thiserror.workspace = true
+tracing.workspace = true
+anyhow.workspace = true
+tokio.workspace = true
+taler-test-utils.workspace = true
+regex.workspace = true
+uuid = { version = "1.24.0", features = ["serde"] }
diff --git a/adapters/taler-wise/db/wise-0001.sql b/adapters/taler-wise/db/wise-0001.sql
@@ -0,0 +1,80 @@
+--
+-- This file is part of TALER
+-- Copyright (C) 2026 Taler Systems SA
+--
+-- TALER is free software; you can redistribute it and/or modify it under the
+-- terms of the GNU 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 General Public License for more details.
+--
+-- You should have received a copy of the GNU General Public License along with
+-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+SELECT _v.register_patch('wise-0001', NULL, NULL);
+
+CREATE SCHEMA wise;
+SET search_path TO wise;
+
+CREATE TYPE taler_amount AS (val INT8, frac INT4);
+COMMENT ON TYPE taler_amount IS 'Stores an amount, fraction is in units of 1/100000000 of the base value';
+
+CREATE TABLE balances (
+ balance_id INT8 PRIMARY KEY,
+ last_sync_at INT8 NOT NULL,
+ last_sync_success_at INT8 NOT NULL
+);
+COMMENT ON TABLE balances IS 'Synchronized balances accounts';
+
+CREATE TABLE tx_in (
+ tx_in_id INT8 PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+ balance_id INT8 NOT NULL,
+ wise_ref TEXT UNIQUE,
+ amount taler_amount NOT NULL,
+ subject TEXT NOT NULL,
+ debit_name TEXT NOT NULL,
+ debit_payto TEXT,
+ valued_at INT8 NOT NULL,
+ registered_at INT8 NOT NULL
+);
+COMMENT ON TABLE tx_in IS 'Incoming transactions';
+
+CREATE INDEX tx_in_balance_account ON tx_in (balance_id);
+
+CREATE TYPE incoming_type AS ENUM
+ ('reserve' ,'kyc', 'map');
+COMMENT ON TYPE incoming_type IS 'Types of incoming talerable transactions';
+
+CREATE TABLE taler_in (
+ tx_in_id INT8 PRIMARY KEY REFERENCES tx_in(tx_in_id) ON DELETE CASCADE,
+ type incoming_type NOT NULL,
+ metadata BYTEA NOT NULL CHECK (LENGTH(metadata)=32),
+ authorization_pub BYTEA CHECK (LENGTH(authorization_pub)=32),
+ authorization_sig BYTEA CHECK (LENGTH(authorization_sig)=64)
+);
+COMMENT ON TABLE taler_in IS 'Incoming talerable transactions';
+
+CREATE UNIQUE INDEX taler_in_unique_reserve_pub ON taler_in (metadata) WHERE type = 'reserve';
+
+CREATE TABLE prepared_in (
+ type incoming_type NOT NULL,
+ account_pub BYTEA NOT NULL CHECK (LENGTH(account_pub)=32),
+ authorization_pub BYTEA UNIQUE NOT NULL CHECK (LENGTH(authorization_pub)=32),
+ authorization_sig BYTEA NOT NULL CHECK (LENGTH(authorization_sig)=64),
+ recurrent BOOLEAN NOT NULL,
+ registered_at INT8 NOT NULL,
+ tx_in_id INT8 UNIQUE REFERENCES tx_in(tx_in_id) ON DELETE CASCADE
+);
+COMMENT ON TABLE prepared_in IS 'Prepared incoming transaction';
+CREATE UNIQUE INDEX prepared_in_unique_reserve_pub
+ ON prepared_in (account_pub) WHERE type = 'reserve';
+
+CREATE TABLE pending_recurrent_in(
+ tx_in_id INT8 NOT NULL UNIQUE REFERENCES tx_in(tx_in_id) ON DELETE CASCADE,
+ authorization_pub BYTEA NOT NULL REFERENCES prepared_in(authorization_pub)
+);
+CREATE INDEX pending_recurrent_inc_auth_pub
+ ON pending_recurrent_in (authorization_pub);
+COMMENT ON TABLE pending_recurrent_in IS 'Pending recurrent incoming transaction';
+\ No newline at end of file
diff --git a/adapters/taler-wise/db/wise-drop.sql b/adapters/taler-wise/db/wise-drop.sql
@@ -0,0 +1,29 @@
+--
+-- This file is part of TALER
+-- Copyright (C) 2026 Taler Systems SA
+--
+-- TALER is free software; you can redistribute it and/or modify it under the
+-- terms of the GNU 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 General Public License for more details.
+--
+-- You should have received a copy of the GNU General Public License along with
+-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+DO
+$do$
+DECLARE
+ patch text;
+BEGIN
+ IF EXISTS(SELECT FROM information_schema.schemata WHERE schema_name='_v') THEN
+ FOR patch IN SELECT patch_name FROM _v.patches WHERE patch_name LIKE 'wise_%' LOOP
+ PERFORM _v.unregister_patch(patch);
+ END LOOP;
+ END IF;
+END
+$do$;
+
+DROP SCHEMA IF EXISTS wise CASCADE;
+\ No newline at end of file
diff --git a/adapters/taler-wise/db/wise-procedures.sql b/adapters/taler-wise/db/wise-procedures.sql
@@ -0,0 +1,268 @@
+--
+-- This file is part of TALER
+-- Copyright (C) 2026 Taler Systems SA
+--
+-- TALER is free software; you can redistribute it and/or modify it under the
+-- terms of the GNU 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 General Public License for more details.
+--
+-- You should have received a copy of the GNU General Public License along with
+-- TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+
+SET search_path TO wise;
+
+-- Remove all existing functions
+DO
+$do$
+DECLARE
+ _sql text;
+BEGIN
+ SELECT INTO _sql
+ string_agg(format('DROP %s %s CASCADE;'
+ , CASE prokind
+ WHEN 'f' THEN 'FUNCTION'
+ WHEN 'p' THEN 'PROCEDURE'
+ END
+ , oid::regprocedure)
+ , E'\n')
+ FROM pg_proc
+ WHERE pronamespace = 'wise'::regnamespace;
+
+ IF _sql IS NOT NULL THEN
+ EXECUTE _sql;
+ END IF;
+END
+$do$;
+
+
+CREATE FUNCTION register_tx_in(
+ IN in_balance_id INT8,
+ IN in_wise_ref TEXT,
+ IN in_amount taler_amount,
+ IN in_subject TEXT,
+ IN in_debit_payto TEXT,
+ IN in_debit_name TEXT,
+ IN in_valued_at INT8,
+ IN in_type incoming_type,
+ IN in_metadata BYTEA,
+ IN in_now INT8,
+ -- Error status
+ OUT out_reserve_pub_reuse BOOLEAN,
+ OUT out_mapping_reuse BOOLEAN,
+ OUT out_unknown_mapping BOOLEAN,
+ -- Success return
+ OUT out_tx_row_id INT8,
+ OUT out_valued_at INT8,
+ OUT out_new BOOLEAN,
+ OUT out_pending BOOLEAN
+)
+LANGUAGE plpgsql AS $$
+DECLARE
+local_authorization_pub BYTEA;
+local_authorization_sig BYTEA;
+BEGIN
+out_pending=false;
+-- Check for idempotence
+SELECT tx_in_id, valued_at
+INTO out_tx_row_id, out_valued_at
+FROM tx_in
+WHERE wise_ref = in_wise_ref;
+out_new = NOT found;
+IF NOT out_new THEN
+ RETURN;
+END IF;
+
+-- Resolve mapping logic
+IF in_type = 'map' THEN
+ SELECT type, account_pub, authorization_pub, authorization_sig,
+ tx_in_id IS NOT NULL AND NOT recurrent,
+ tx_in_id IS NOT NULL AND recurrent
+ INTO in_type, in_metadata, local_authorization_pub, local_authorization_sig, out_mapping_reuse, out_pending
+ FROM prepared_in
+ WHERE authorization_pub = in_metadata;
+ out_unknown_mapping = NOT FOUND;
+ IF out_unknown_mapping OR out_mapping_reuse THEN
+ RETURN;
+ END IF;
+END IF;
+
+
+-- Check conflict
+out_reserve_pub_reuse=NOT out_pending AND in_type = 'reserve' AND EXISTS(SELECT FROM taler_in WHERE metadata = in_metadata AND type = 'reserve');
+IF out_reserve_pub_reuse THEN
+ RETURN;
+END IF;
+
+-- Insert new incoming transaction
+out_valued_at = in_valued_at;
+INSERT INTO tx_in (
+ balance_id,
+ wise_ref,
+ amount,
+ subject,
+ debit_payto,
+ debit_name,
+ valued_at,
+ registered_at
+) VALUES (
+ in_balance_id,
+ in_wise_ref,
+ in_amount,
+ in_subject,
+ in_debit_payto,
+ in_debit_name,
+ in_valued_at,
+ in_now
+)
+RETURNING tx_in_id INTO out_tx_row_id;
+-- Notify new incoming transaction registration
+PERFORM pg_notify('tx_in', in_balance_id || ' ' || out_tx_row_id);
+
+IF out_pending THEN
+ -- Delay talerable registration until mapping again
+ INSERT INTO pending_recurrent_in (tx_in_id, authorization_pub)
+ VALUES (out_tx_row_id, local_authorization_pub);
+ELSIF in_type IS NOT NULL THEN
+ UPDATE prepared_in
+ SET tx_in_id = out_tx_row_id
+ WHERE (
+ tx_in_id IS NULL AND account_pub = in_metadata AND type='reserve'
+ ) OR authorization_pub = local_authorization_pub;
+ -- Insert new incoming talerable transaction
+ INSERT INTO taler_in (
+ tx_in_id,
+ type,
+ metadata,
+ authorization_pub,
+ authorization_sig
+ ) VALUES (
+ out_tx_row_id,
+ in_type,
+ in_metadata,
+ local_authorization_pub,
+ local_authorization_sig
+ );
+ -- Notify new incoming talerable transaction registration
+ PERFORM pg_notify('taler_in', in_balance_id || ' ' || out_tx_row_id);
+END IF;
+END $$;
+COMMENT ON FUNCTION register_tx_in IS 'Register an incoming transaction idempotently';
+
+
+CREATE FUNCTION register_prepared_transfers (
+ IN in_type incoming_type,
+ IN in_account_pub BYTEA,
+ IN in_authorization_pub BYTEA,
+ IN in_authorization_sig BYTEA,
+ IN in_recurrent BOOLEAN,
+ IN in_timestamp INT8,
+ -- Error status
+ OUT out_reserve_pub_reuse BOOLEAN
+)
+LANGUAGE plpgsql AS $$
+DECLARE
+ talerable_tx INT8;
+ local_balance_id INT8;
+ idempotent BOOLEAN;
+BEGIN
+
+-- Check idempotency
+SELECT type = in_type
+ AND account_pub = in_account_pub
+ AND recurrent = in_recurrent
+INTO idempotent
+FROM prepared_in
+WHERE authorization_pub = in_authorization_pub;
+
+-- Check idempotency and delay garbage collection
+IF FOUND AND idempotent THEN
+ UPDATE prepared_in
+ SET registered_at=in_timestamp
+ WHERE authorization_pub=in_authorization_pub;
+ RETURN;
+END IF;
+
+-- Check reserve pub reuse
+out_reserve_pub_reuse=in_type = 'reserve' AND (
+ EXISTS(SELECT FROM taler_in WHERE metadata = in_account_pub AND type = 'reserve')
+ OR EXISTS(SELECT FROM prepared_in WHERE account_pub = in_account_pub AND type = 'reserve' AND authorization_pub != in_authorization_pub)
+);
+IF out_reserve_pub_reuse THEN
+ RETURN;
+END IF;
+
+IF in_recurrent THEN
+ -- Finalize one pending right now
+ WITH pending AS (
+ SELECT tx_in_id
+ FROM pending_recurrent_in
+ JOIN tx_in USING (tx_in_id)
+ WHERE authorization_pub = in_authorization_pub
+ ORDER BY valued_at ASC
+ LIMIT 1
+ ), moved_tx AS (
+ DELETE FROM pending_recurrent_in
+ USING tx_in, pending
+ WHERE pending_recurrent_in.tx_in_id = tx_in.tx_in_id
+ AND pending_recurrent_in.tx_in_id = pending.tx_in_id
+ RETURNING pending_recurrent_in.tx_in_id, tx_in.balance_id
+ ), inserted AS (
+ INSERT INTO taler_in (tx_in_id, type, metadata, authorization_pub, authorization_sig)
+ SELECT tx_in_id, in_type, in_account_pub, in_authorization_pub, in_authorization_sig
+ FROM moved_tx
+ RETURNING tx_in_id
+ )
+ SELECT inserted.tx_in_id, moved_tx.balance_id
+ INTO talerable_tx, local_balance_id
+ FROM inserted
+ JOIN moved_tx USING (tx_in_id);
+ IF talerable_tx IS NOT NULL THEN
+ PERFORM pg_notify('taler_in', local_balance_id || ' ' || talerable_tx);
+ END IF;
+END IF;
+
+-- Upsert registration
+INSERT INTO prepared_in (
+ type,
+ account_pub,
+ authorization_pub,
+ authorization_sig,
+ recurrent,
+ registered_at,
+ tx_in_id
+) VALUES (
+ in_type,
+ in_account_pub,
+ in_authorization_pub,
+ in_authorization_sig,
+ in_recurrent,
+ in_timestamp,
+ talerable_tx
+) ON CONFLICT (authorization_pub)
+DO UPDATE SET
+ type = EXCLUDED.type,
+ account_pub = EXCLUDED.account_pub,
+ recurrent = EXCLUDED.recurrent,
+ registered_at = EXCLUDED.registered_at,
+ tx_in_id = EXCLUDED.tx_in_id,
+ authorization_sig = EXCLUDED.authorization_sig;
+END $$;
+
+CREATE FUNCTION delete_prepared_transfers (
+ IN in_authorization_pub BYTEA,
+ IN in_timestamp INT8,
+ OUT out_found BOOLEAN
+)
+LANGUAGE plpgsql AS $$
+BEGIN
+
+-- Delete registration
+DELETE FROM prepared_in
+WHERE authorization_pub = in_authorization_pub;
+out_found = FOUND;
+
+END $$;
diff --git a/adapters/taler-wise/src/api.rs b/adapters/taler-wise/src/api.rs
@@ -0,0 +1,463 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 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::{collections::BTreeMap, sync::Arc};
+
+use compact_str::format_compact;
+use jiff::Timestamp;
+use taler_api::{
+ api::{
+ TalerApi, TalerRouter as _, prepared::PreparedTransfer, revenue::Revenue, wire::WireGateway,
+ },
+ config::ApiCfg,
+ error::{ApiResult, failure_code, not_implemented},
+ subject::{IncomingSubject, fmt_in_subject},
+};
+use taler_common::{
+ api::{
+ params::{History, Page},
+ prepared::{
+ RegistrationRequest, RegistrationResponse, SubjectFormat, TransferSubject,
+ Unregistration,
+ },
+ revenue::RevenueIncomingHistory,
+ wire::{
+ AddIncomingRequest, AddIncomingResponse, AddKycauthRequest, AddMappedRequest,
+ IncomingHistory, OutgoingHistory, TransferList, TransferRequest, TransferResponse,
+ TransferState, TransferStatus,
+ },
+ },
+ db::IncomingType,
+ error_code::ErrorCode,
+ types::{amount::Currency, payto::PaytoImpl, timestamp::TalerTimestamp},
+};
+use taler_test_utils::Router;
+use tokio::sync::watch::Sender;
+
+use crate::{
+ config::WiseBalance,
+ db::{self, AddIncomingResult, TxIn},
+ payto::FullWisePayto,
+};
+
+pub async fn start(
+ pool: sqlx::PgPool,
+ name: &str,
+ balances: Vec<WiseBalance>,
+ wire_gateway: &Option<ApiCfg>,
+ revenue: &Option<ApiCfg>,
+) -> Router {
+ let apis: Vec<_> = balances
+ .into_iter()
+ .map(|b| WiseBalanceApi {
+ id: b.id,
+ currency: b.currency,
+ pool: pool.clone(),
+ payto: b.payto.full(name),
+ in_channel: Sender::new(0),
+ taler_in_channel: Sender::new(0),
+ })
+ .collect();
+ let in_channels = Arc::new(BTreeMap::from_iter(
+ apis.iter().map(|b| (b.id, b.in_channel.clone())),
+ ));
+ let taler_in_channels = Arc::new(BTreeMap::from_iter(
+ apis.iter().map(|b| (b.id, b.taler_in_channel.clone())),
+ ));
+ tokio::spawn(db::notification_listener(
+ pool,
+ in_channels,
+ taler_in_channels,
+ ));
+ let mut main_router = Router::new();
+ for api in apis {
+ let api = Arc::new(api);
+ let mut balance_router = Router::new();
+ if let Some(cfg) = wire_gateway {
+ balance_router = balance_router
+ .wire_gateway(api.clone(), cfg.auth.method())
+ .prepared_transfer(api.clone());
+ }
+ if let Some(cfg) = revenue {
+ balance_router = balance_router.revenue(api.clone(), cfg.auth.method());
+ }
+ main_router = main_router.nest(&format!("/balances/{}", api.id), balance_router);
+ }
+ main_router
+}
+
+pub struct WiseBalanceApi {
+ pub id: u32,
+ pub currency: Currency,
+ pub pool: sqlx::PgPool,
+ pub payto: FullWisePayto,
+ pub in_channel: Sender<i64>,
+ pub taler_in_channel: Sender<i64>,
+}
+
+impl TalerApi for WiseBalanceApi {
+ fn currency(&self) -> Currency {
+ self.currency
+ }
+
+ fn implementation(&self) -> &'static str {
+ "urn:net:taler:specs:taler-wise:taler-rust"
+ }
+}
+
+impl WiseBalanceApi {
+ async fn add_incoming(
+ &self,
+ tx: &TxIn,
+ subject: IncomingSubject,
+ ) -> ApiResult<AddIncomingResponse> {
+ let now = Timestamp::now();
+ match db::register_tx_in(&self.pool, tx, &Some(subject), &now).await? {
+ AddIncomingResult::Success {
+ row_id, valued_at, ..
+ } => Ok(AddIncomingResponse {
+ row_id,
+ timestamp: valued_at.into(),
+ }),
+ AddIncomingResult::ReservePubReuse => {
+ Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT))
+ }
+ AddIncomingResult::UnknownMapping => {
+ Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_UNKNOWN))
+ }
+ AddIncomingResult::MappingReuse => {
+ Err(failure_code(ErrorCode::BANK_TRANSFER_MAPPING_REUSED))
+ }
+ }
+ }
+}
+
+impl WireGateway for WiseBalanceApi {
+ async fn transfer(&self, _: TransferRequest) -> ApiResult<TransferResponse> {
+ Err(not_implemented())
+ }
+
+ async fn transfer_page(&self, _: Page, _: Option<TransferState>) -> ApiResult<TransferList> {
+ Ok(TransferList {
+ transfers: Vec::new(),
+ debit_account: self.payto.as_uri(),
+ })
+ }
+
+ async fn transfer_by_id(&self, _: u64) -> ApiResult<Option<TransferStatus>> {
+ Ok(None)
+ }
+
+ async fn outgoing_history(&self, _: History) -> ApiResult<OutgoingHistory> {
+ Ok(OutgoingHistory {
+ outgoing_transactions: Vec::new(),
+ debit_account: self.payto.as_uri(),
+ })
+ }
+
+ async fn incoming_history(&self, params: History) -> ApiResult<IncomingHistory> {
+ Ok(IncomingHistory {
+ incoming_transactions: db::incoming_history(
+ &self.pool,
+ self.id,
+ &self.currency,
+ ¶ms,
+ || self.taler_in_channel.subscribe(),
+ )
+ .await?,
+ credit_account: self.payto.as_uri(),
+ })
+ }
+
+ async fn add_incoming_reserve(
+ &self,
+ req: AddIncomingRequest,
+ ) -> ApiResult<AddIncomingResponse> {
+ let (account, name) = FullWisePayto::try_from(&req.debit_account)?.into_inner();
+ let subject = format_compact!("Admin incoming {}", req.reserve_pub);
+ self.add_incoming(
+ &TxIn {
+ balance_id: self.id,
+ wise_ref: None,
+ amount: req.amount,
+ subject,
+ name,
+ debtor: Some(account),
+ value_at: Timestamp::now(),
+ },
+ IncomingSubject::Reserve(req.reserve_pub),
+ )
+ .await
+ }
+
+ async fn add_incoming_kyc(&self, req: AddKycauthRequest) -> ApiResult<AddIncomingResponse> {
+ let (account, name) = FullWisePayto::try_from(&req.debit_account)?.into_inner();
+ let subject = format_compact!("Admin incoming KYC:{}", req.account_pub);
+ self.add_incoming(
+ &TxIn {
+ balance_id: self.id,
+ wise_ref: None,
+ amount: req.amount,
+ subject,
+ name,
+ debtor: Some(account),
+ value_at: Timestamp::now(),
+ },
+ IncomingSubject::Kyc(req.account_pub),
+ )
+ .await
+ }
+
+ async fn add_incoming_mapped(&self, req: AddMappedRequest) -> ApiResult<AddIncomingResponse> {
+ let (account, name) = FullWisePayto::try_from(&req.debit_account)?.into_inner();
+ let subject = format_compact!("Admin incoming MAP:{}", req.authorization_pub);
+ self.add_incoming(
+ &TxIn {
+ balance_id: self.id,
+ wise_ref: None,
+ amount: req.amount,
+ subject,
+ name,
+ debtor: Some(account),
+ value_at: Timestamp::now(),
+ },
+ IncomingSubject::Map(req.authorization_pub),
+ )
+ .await
+ }
+
+ fn support_account_check(&self) -> bool {
+ false
+ }
+}
+
+impl Revenue for WiseBalanceApi {
+ async fn history(&self, params: History) -> ApiResult<RevenueIncomingHistory> {
+ Ok(RevenueIncomingHistory {
+ incoming_transactions: db::revenue_history(
+ &self.pool,
+ self.id,
+ &self.currency,
+ ¶ms,
+ || self.in_channel.subscribe(),
+ )
+ .await?,
+ credit_account: self.payto.as_uri(),
+ })
+ }
+}
+
+impl PreparedTransfer for WiseBalanceApi {
+ fn supported_formats(&self) -> &[SubjectFormat] {
+ &[SubjectFormat::SIMPLE]
+ }
+
+ async fn registration(&self, req: RegistrationRequest) -> ApiResult<RegistrationResponse> {
+ let creditor = FullWisePayto::try_from(&req.credit_account)?;
+ if creditor != self.payto {
+ return Err(failure_code(ErrorCode::BANK_UNKNOWN_CREDITOR));
+ }
+ match db::transfer_register(&self.pool, &req).await? {
+ db::RegistrationResult::Success => {
+ let simple = TransferSubject::Simple {
+ credit_amount: req.credit_amount,
+ subject: if req.authorization_pub == req.account_pub && !req.recurrent {
+ fmt_in_subject(req.r#type.into(), &req.account_pub).to_string()
+ } else {
+ fmt_in_subject(IncomingType::map, &req.authorization_pub).to_string()
+ },
+ };
+ ApiResult::Ok(RegistrationResponse {
+ subjects: vec![simple],
+ expiration: TalerTimestamp::Never,
+ })
+ }
+ db::RegistrationResult::ReservePubReuse => {
+ ApiResult::Err(failure_code(ErrorCode::BANK_DUPLICATE_RESERVE_PUB_SUBJECT))
+ }
+ }
+ }
+
+ async fn unregistration(&self, req: Unregistration) -> ApiResult<bool> {
+ Ok(db::transfer_unregister(&self.pool, &req).await?)
+ }
+}
+
+#[cfg(test)]
+mod test {
+
+ use std::sync::LazyLock;
+
+ use compact_str::CompactString;
+ use jiff::Timestamp;
+ use sqlx::PgPool;
+ use taler_api::{
+ api::TalerRouter as _,
+ config::{ApiCfg, AuthCfg},
+ subject::IncomingSubject,
+ };
+ use taler_common::{
+ api::{
+ EddsaPublicKey, prepared::PreparedTransferConfig, revenue::RevenueConfig,
+ wire::WireConfig,
+ },
+ types::{
+ amount::{Currency, amount},
+ iban::iban,
+ payto::{BankID, PaytoURI},
+ },
+ };
+ use taler_test_utils::{
+ Router,
+ db::db_test_setup,
+ routine::{admin_add_incoming_routine, in_history_routine, revenue_routine},
+ server::TestServer,
+ tasks,
+ };
+
+ use crate::{
+ CONFIG_SOURCE, api,
+ config::WiseBalance,
+ db::{TxIn, register_tx_in},
+ payto::{FullWisePayto, WiseAccount},
+ };
+
+ static PAYTO: LazyLock<FullWisePayto> = LazyLock::new(|| {
+ "payto://ach/021000021/024030222?receiver-name=Exchange"
+ .parse()
+ .unwrap()
+ });
+ static EXCHANGE: LazyLock<PaytoURI> = LazyLock::new(|| PAYTO.as_uri());
+
+ async fn setup() -> (Router, PgPool) {
+ let (_, pool) = db_test_setup(CONFIG_SOURCE).await;
+ let balances = vec![
+ WiseBalance {
+ id: 42,
+ currency: Currency::TEST,
+ payto: PAYTO.clone().into_inner().0,
+ },
+ WiseBalance {
+ id: 34,
+ currency: Currency::KUDOS,
+ payto: PAYTO.clone().into_inner().0,
+ },
+ ];
+ let server = api::start(
+ pool.clone(),
+ "Exchange",
+ balances,
+ &Some(ApiCfg {
+ auth: AuthCfg::None,
+ }),
+ &Some(ApiCfg {
+ auth: AuthCfg::None,
+ }),
+ )
+ .await
+ .finalize();
+
+ (server, pool)
+ }
+
+ #[tokio::test]
+ async fn config() {
+ let (server, _) = setup().await;
+ for id in [42, 34] {
+ server
+ .get(format!("/balances/{id}/taler-wire-gateway/config"))
+ .await
+ .assert_ok_json::<WireConfig>();
+ server
+ .get(format!("/balances/{id}/taler-prepared-transfer/config"))
+ .await
+ .assert_ok_json::<PreparedTransferConfig>();
+ server
+ .get(format!("/balances/{id}/taler-revenue/config"))
+ .await
+ .assert_ok_json::<RevenueConfig>();
+ }
+ }
+
+ async fn r#in(db: &PgPool, subject: Option<IncomingSubject>) {
+ register_tx_in(
+ db,
+ &TxIn {
+ balance_id: 42,
+ wise_ref: None,
+ amount: amount("EUR:10"),
+ subject: "subject".into(),
+ name: CompactString::const_new("Name"),
+ debtor: Some(WiseAccount::IBAN(BankID {
+ iban: iban("HU30162000031000163100000000"),
+ bic: None,
+ })),
+ value_at: Timestamp::now(),
+ },
+ &subject,
+ &Timestamp::now(),
+ )
+ .await
+ .unwrap();
+ }
+
+ async fn in_malformed(db: &PgPool) {
+ r#in(db, None).await
+ }
+
+ async fn in_talerable(db: &PgPool) {
+ r#in(db, Some(IncomingSubject::Reserve(EddsaPublicKey::rand()))).await
+ }
+
+ #[tokio::test]
+ async fn admin_add_incoming() {
+ let (server, _) = setup().await;
+ admin_add_incoming_routine(
+ &server.prefix("/balances/42/taler-wire-gateway"),
+ &server.prefix("/balances/42/taler-prepared-transfer"),
+ &EXCHANGE,
+ &EXCHANGE,
+ )
+ .await;
+ }
+
+ #[tokio::test]
+ async fn in_history() {
+ let (server, db) = &setup().await;
+ in_history_routine(
+ &server.prefix("/balances/42/taler-wire-gateway"),
+ &server.prefix("/balances/42/taler-prepared-transfer"),
+ &EXCHANGE,
+ &EXCHANGE,
+ tasks!({ in_talerable(db).await }),
+ tasks!({ in_malformed(db).await }),
+ )
+ .await;
+ }
+
+ #[tokio::test]
+ async fn revenue() {
+ let (server, db) = &setup().await;
+ revenue_routine(
+ &server.prefix("/balances/42/taler-wire-gateway"),
+ &server.prefix("/balances/42/taler-revenue"),
+ &EXCHANGE,
+ tasks!({ in_malformed(db).await }, { in_talerable(db).await },),
+ tasks!(),
+ )
+ .await;
+ }
+}
diff --git a/adapters/taler-wise/src/config.rs b/adapters/taler-wise/src/config.rs
@@ -0,0 +1,144 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 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::time::Duration;
+
+use compact_str::CompactString;
+use taler_api::{
+ Serve,
+ config::{ApiCfg, DbCfg},
+};
+use taler_common::{
+ config::{Config, ValueErr},
+ map_config,
+ types::{
+ amount::Currency,
+ payto::{ACH, BankID},
+ },
+};
+
+use crate::payto::WiseAccount;
+
+#[derive(Debug, Clone, Copy)]
+pub enum AccountType {
+ Exchange,
+ Normal,
+}
+
+pub fn parse_db_cfg(cfg: &Config) -> Result<DbCfg, ValueErr> {
+ DbCfg::parse(cfg.section("wisedb-postgres"))
+}
+
+pub struct WiseBalance {
+ pub id: u32,
+ pub currency: Currency,
+ pub payto: WiseAccount,
+}
+
+fn balances(cfg: &Config) -> Result<Vec<WiseBalance>, ValueErr> {
+ cfg.sections()
+ .filter_map(|s| {
+ (|| {
+ Ok(if s.name.starts_with("wise-balance") {
+ Some(WiseBalance {
+ id: s.number("id").require()?,
+ currency: s.currency("currency").require()?,
+ payto: map_config!(s, "payto type", "PAYTO_TYPE",
+ "iban" => {
+ WiseAccount::IBAN(BankID {
+ iban: s.parse("IBAN", "iban").require()?,
+ bic: s.parse("BIC", "bic").opt()?
+ })
+ },
+ "ach" => {
+ WiseAccount::ACH(ACH {
+ routing_number: s.parse("ACH routing number", "routing_number").require()?,
+ account_number: s.parse("ACH account_number", "account_number").require()?
+ })
+ },
+ )
+ .require()?,
+ })
+ } else {
+ None
+ })
+ })()
+ .transpose()
+ })
+ .collect()
+}
+
+/// taler-wise setup config
+pub struct SetupCfg {
+ pub token: String,
+ pub profile_id: Option<u64>,
+ pub name: Option<CompactString>,
+ pub balances: Vec<WiseBalance>,
+}
+
+impl SetupCfg {
+ pub fn parse(cfg: &Config) -> Result<Self, ValueErr> {
+ let s = cfg.section("wise-worker");
+ Ok(Self {
+ profile_id: s.number("PROFILE_ID").opt()?,
+ token: s.str("TOKEN").require()?,
+ name: cfg.section("wise").cstr("NAME").opt()?,
+ balances: balances(cfg)?,
+ })
+ }
+}
+
+/// taler-wise httpd config
+pub struct ServeCfg {
+ pub name: CompactString,
+ pub serve: Serve,
+ pub balances: Vec<WiseBalance>,
+ pub wire_gateway: Option<ApiCfg>,
+ pub revenue: Option<ApiCfg>,
+}
+
+impl ServeCfg {
+ pub fn parse(cfg: &Config) -> Result<Self, ValueErr> {
+ let s = cfg.section("wise");
+ Ok(Self {
+ name: s.cstr("NAME").require()?,
+ serve: Serve::parse(&cfg.section("wise-httpd"))?,
+ wire_gateway: ApiCfg::parse(cfg.section("wise-httpd-wire-gateway-api"))?,
+ revenue: ApiCfg::parse(cfg.section("wise-httpd-revenue-api"))?,
+ balances: balances(cfg)?,
+ })
+ }
+}
+
+/// taler-wise worker config
+pub struct WorkerCfg {
+ pub profile_id: u64,
+ pub token: String,
+ pub frequency: Duration,
+ pub balances: Vec<WiseBalance>,
+}
+
+impl WorkerCfg {
+ pub fn parse(cfg: &Config) -> Result<Self, ValueErr> {
+ let s = cfg.section("wise-worker");
+ Ok(Self {
+ profile_id: s.number("PROFILE_ID").require()?,
+ token: s.str("TOKEN").require()?,
+ frequency: s.duration("FREQUENCY").require()?,
+ balances: balances(cfg)?,
+ })
+ }
+}
diff --git a/adapters/taler-wise/src/db.rs b/adapters/taler-wise/src/db.rs
@@ -0,0 +1,486 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2025, 2026 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::{collections::BTreeMap, fmt::Display, sync::Arc};
+
+use compact_str::CompactString;
+use jiff::Timestamp;
+use sqlx::{PgPool, QueryBuilder, Row, postgres::PgRow};
+use taler_api::{
+ db::{BindHelper, TypeHelper, history},
+ serialized,
+ subject::IncomingSubject,
+};
+use taler_common::{
+ api::{
+ params::History,
+ prepared::{RegistrationRequest, Unregistration},
+ revenue::RevenueIncomingBankTransaction,
+ wire::IncomingBankTransaction,
+ },
+ config::Config,
+ db::IncomingType,
+ types::{
+ amount::{Amount, Currency},
+ payto::PaytoImpl as _,
+ },
+};
+use tokio::sync::watch::{Receiver, Sender};
+
+use crate::{
+ config::parse_db_cfg,
+ payto::{WiseAccount, WisePayto},
+};
+
+const SCHEMA: &str = "wise";
+
+pub async fn pool(cfg: &Config) -> anyhow::Result<PgPool> {
+ let db = parse_db_cfg(cfg)?;
+ let pool = taler_common::db::pool(db.cfg, SCHEMA).await?;
+ Ok(pool)
+}
+
+pub async fn dbinit(cfg: &Config, reset: bool) -> anyhow::Result<PgPool> {
+ let db_cfg = parse_db_cfg(cfg)?;
+ let pool = taler_common::db::pool(db_cfg.cfg, SCHEMA).await?;
+ let mut db = pool.acquire().await?;
+ taler_common::db::dbinit(&mut db, db_cfg.sql_dir.as_ref(), SCHEMA, reset).await?;
+ Ok(pool)
+}
+
+pub async fn notification_listener(
+ pool: PgPool,
+ in_channels: Arc<BTreeMap<u32, Sender<i64>>>,
+ taler_in_channels: Arc<BTreeMap<u32, Sender<i64>>>,
+) {
+ taler_api::notification::notification_listener!(&pool,
+ "tx_in" => (balance_id: u32, row_id: i64) {
+ if let Some(channel) = in_channels.get(&balance_id) {
+ channel.send_replace(row_id);
+ }
+ },
+ "taler_in" => (balance_id: u32, row_id: i64) {
+ if let Some(channel) = taler_in_channels.get(&balance_id) {
+ channel.send_replace(row_id);
+ }
+ }
+ )
+}
+
+#[derive(Debug, Clone)]
+pub struct TxIn {
+ pub balance_id: u32,
+ pub wise_ref: Option<CompactString>,
+ pub amount: Amount,
+ pub subject: CompactString,
+ pub name: CompactString,
+ pub debtor: Option<WiseAccount>,
+ pub value_at: Timestamp,
+}
+
+impl Display for TxIn {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let Self {
+ balance_id,
+ wise_ref,
+ amount,
+ subject,
+ name,
+ debtor,
+ value_at: value_date,
+ } = self;
+
+ write!(
+ f,
+ "{balance_id} {value_date} {} {amount} ({} {name}) '{subject}'",
+ wise_ref.as_deref().unwrap_or("admin"),
+ std::fmt::from_fn(|w| {
+ match debtor {
+ Some(account) => write!(w, "{account}"),
+ None => write!(w, "unsupported"),
+ }
+ })
+ )
+ }
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum AddIncomingResult {
+ Success {
+ new: bool,
+ pending: bool,
+ row_id: u64,
+ valued_at: Timestamp,
+ },
+ ReservePubReuse,
+ UnknownMapping,
+ MappingReuse,
+}
+
+pub async fn register_tx_in(
+ db: &PgPool,
+ tx: &TxIn,
+ subject: &Option<IncomingSubject>,
+ now: &Timestamp,
+) -> sqlx::Result<AddIncomingResult> {
+ let payto = tx.debtor.as_ref().map(|it| it.as_uri());
+ serialized!(
+ sqlx::query(
+ "
+ SELECT out_reserve_pub_reuse, out_mapping_reuse, out_unknown_mapping, out_tx_row_id, out_valued_at, out_new, out_pending
+ FROM register_tx_in($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ ",
+ )
+ .bind(tx.balance_id as i64)
+ .bind(&tx.wise_ref)
+ .bind(tx.amount)
+ .bind(&tx.subject)
+ .bind(payto.as_ref().map(|it| it.as_ref().as_str()))
+ .bind(&tx.name)
+ .bind_timestamp(&tx.value_at)
+ .bind(subject.as_ref().map(|it| it.ty()))
+ .bind(subject.as_ref().map(|it| it.key()))
+ .bind_timestamp(now)
+ .try_map(|r: PgRow| {
+ Ok(if r.try_get_flag(0)? {
+ AddIncomingResult::ReservePubReuse
+ } else if r.try_get_flag(1)? {
+ AddIncomingResult::MappingReuse
+ } else if r.try_get_flag(2)? {
+ AddIncomingResult::UnknownMapping
+ } else {
+ AddIncomingResult::Success {
+ row_id: r.try_get_u64(3)?,
+ valued_at: r.try_get_timestamp(4)?,
+ new: r.try_get(5)?,
+ pending: r.try_get(6)?
+ }
+ })
+ })
+ .fetch_one(db)
+ )
+}
+
+pub async fn incoming_history(
+ db: &PgPool,
+ balance_id: u32,
+ currency: &Currency,
+ params: &History,
+ listen: impl FnOnce() -> Receiver<i64>,
+) -> sqlx::Result<Vec<IncomingBankTransaction>> {
+ history(
+ db,
+ "tx_in_id",
+ params,
+ listen,
+ || {
+ let mut query = QueryBuilder::new(
+ "
+ SELECT
+ type,
+ tx_in_id,
+ amount,
+ debit_payto,
+ debit_name,
+ valued_at,
+ metadata,
+ authorization_pub,
+ authorization_sig
+ FROM taler_in
+ JOIN tx_in USING (tx_in_id)
+ WHERE balance_id =
+ ",
+ );
+ query.push_bind(balance_id as i32).push(" AND ");
+ query
+ },
+ |r: PgRow| {
+ Ok(match r.try_get(0)? {
+ IncomingType::reserve => IncomingBankTransaction::Reserve {
+ row_id: r.try_get_u64(1)?,
+ amount: r.try_get_amount(2, currency)?,
+ credit_fee: None,
+ debit_account: r
+ .try_get_parse::<_, _, WisePayto>(3)?
+ .as_full_uri(r.try_get(4)?),
+ date: r.try_get_timestamp(5)?.into(),
+ reserve_pub: r.try_get(6)?,
+ authorization_pub: r.try_get(7)?,
+ authorization_sig: r.try_get(8)?,
+ },
+ IncomingType::kyc => IncomingBankTransaction::Kyc {
+ row_id: r.try_get_u64(1)?,
+ amount: r.try_get_amount(2, currency)?,
+ credit_fee: None,
+ debit_account: r
+ .try_get_parse::<_, _, WisePayto>(3)?
+ .as_full_uri(r.try_get(4)?),
+ date: r.try_get_timestamp(5)?.into(),
+ account_pub: r.try_get(6)?,
+ authorization_pub: r.try_get(7)?,
+ authorization_sig: r.try_get(8)?,
+ },
+ IncomingType::map => unimplemented!("MAP are never listed in the history"),
+ })
+ },
+ )
+ .await
+}
+
+pub async fn revenue_history(
+ db: &PgPool,
+ balance_id: u32,
+ currency: &Currency,
+ params: &History,
+ listen: impl FnOnce() -> Receiver<i64>,
+) -> sqlx::Result<Vec<RevenueIncomingBankTransaction>> {
+ history(
+ db,
+ "tx_in_id",
+ params,
+ listen,
+ || {
+ let mut query = QueryBuilder::new(
+ "
+ SELECT
+ tx_in_id,
+ valued_at,
+ amount,
+ debit_payto,
+ debit_name,
+ subject
+ FROM tx_in
+ WHERE balance_id =
+ ",
+ );
+ query.push_bind(balance_id as i32).push(" AND ");
+ query
+ },
+ |r: PgRow| {
+ Ok(RevenueIncomingBankTransaction {
+ row_id: r.try_get_u64(0)?,
+ date: r.try_get_timestamp(1)?.into(),
+ amount: r.try_get_amount(2, currency)?,
+ credit_fee: None,
+ debit_account: r
+ .try_get_parse::<_, _, WisePayto>(3)?
+ .as_full_uri(r.try_get(4)?),
+ subject: r.try_get(5)?,
+ })
+ },
+ )
+ .await
+}
+
+pub enum RegistrationResult {
+ Success,
+ ReservePubReuse,
+}
+
+pub async fn transfer_register(
+ db: &PgPool,
+ req: &RegistrationRequest,
+) -> sqlx::Result<RegistrationResult> {
+ let ty: IncomingType = req.r#type.into();
+ serialized!(
+ sqlx::query(
+ "SELECT out_reserve_pub_reuse FROM register_prepared_transfers($1,$2,$3,$4,$5,$6)"
+ )
+ .bind(ty)
+ .bind(req.account_pub)
+ .bind(req.authorization_pub)
+ .bind(req.authorization_sig)
+ .bind(req.recurrent)
+ .bind_timestamp(&Timestamp::now())
+ .try_map(|r: PgRow| {
+ Ok(if r.try_get_flag("out_reserve_pub_reuse")? {
+ RegistrationResult::ReservePubReuse
+ } else {
+ RegistrationResult::Success
+ })
+ })
+ .fetch_one(db)
+ )
+}
+
+pub async fn transfer_unregister(db: &PgPool, req: &Unregistration) -> sqlx::Result<bool> {
+ serialized!(
+ sqlx::query("SELECT out_found FROM delete_prepared_transfers($1,$2)")
+ .bind(req.authorization_pub)
+ .bind_timestamp(&Timestamp::now())
+ .try_map(|r: PgRow| r.try_get_flag("out_found"))
+ .fetch_one(db)
+ )
+}
+
+#[cfg(test)]
+mod test {
+
+ use compact_str::{CompactString, format_compact};
+ use jiff::Span;
+ use sqlx::{PgPool, Postgres, pool::PoolConnection, postgres::PgRow};
+ use taler_api::{db::TypeHelper, notification::dummy_listen, subject::IncomingSubject};
+ use taler_common::{
+ api::{EddsaPublicKey, params::History},
+ types::{
+ amount::{Currency, amount},
+ iban::iban,
+ payto::BankID,
+ utils::now_sql_stable_ts,
+ },
+ };
+
+ use crate::{
+ CONFIG_SOURCE,
+ db::{AddIncomingResult, TxIn, incoming_history, register_tx_in, revenue_history},
+ payto::WiseAccount,
+ };
+
+ async fn setup() -> (PoolConnection<Postgres>, PgPool) {
+ taler_test_utils::db::db_test_setup(CONFIG_SOURCE).await
+ }
+
+ const BALANCE_ID: u32 = 42;
+ const CURR: Currency = Currency::TEST;
+
+ #[tokio::test]
+ async fn tx_in() {
+ let (mut db, pool) = setup().await;
+
+ let mut routine = async |first: &Option<IncomingSubject>,
+ second: &Option<IncomingSubject>| {
+ let id = sqlx::query("SELECT count(*) + 1 FROM tx_in")
+ .try_map(|r: PgRow| r.try_get_u64(0))
+ .fetch_one(&mut *db)
+ .await
+ .unwrap();
+ let now = now_sql_stable_ts();
+ let later = now + Span::new().hours(1);
+ let tx = TxIn {
+ balance_id: 42,
+ name: CompactString::const_new("Name"),
+ wise_ref: Some(format_compact!("TRANSFER-{id}")),
+ amount: amount("EUR:10"),
+ subject: "subject".into(),
+ debtor: Some(WiseAccount::IBAN(BankID {
+ iban: iban("HU30162000031000163100000000"),
+ bic: None,
+ })),
+ value_at: now,
+ };
+ // Insert
+ assert_eq!(
+ register_tx_in(&pool, &tx, first, &now)
+ .await
+ .expect("register tx in"),
+ AddIncomingResult::Success {
+ new: true,
+ pending: false,
+ row_id: id,
+ valued_at: now
+ }
+ );
+ // Idempotent
+ assert_eq!(
+ register_tx_in(
+ &pool,
+ &TxIn {
+ value_at: later,
+ ..tx.clone()
+ },
+ first,
+ &now
+ )
+ .await
+ .expect("register tx in"),
+ AddIncomingResult::Success {
+ new: false,
+ pending: false,
+ row_id: id,
+ valued_at: now
+ }
+ );
+ // Many
+ assert_eq!(
+ register_tx_in(
+ &pool,
+ &TxIn {
+ wise_ref: Some(format_compact!("TRANSFER-{}", id + 1)),
+ value_at: later,
+ ..tx
+ },
+ second,
+ &now
+ )
+ .await
+ .expect("register tx in"),
+ AddIncomingResult::Success {
+ new: true,
+ pending: false,
+ row_id: id + 1,
+ valued_at: later
+ }
+ );
+ };
+
+ // Empty db
+ assert_eq!(
+ revenue_history(&pool, BALANCE_ID, &CURR, &History::default(), dummy_listen)
+ .await
+ .unwrap(),
+ Vec::new()
+ );
+ assert_eq!(
+ incoming_history(&pool, BALANCE_ID, &CURR, &History::default(), dummy_listen)
+ .await
+ .unwrap(),
+ Vec::new()
+ );
+
+ // Regular transaction
+ routine(&None, &None).await;
+
+ // Reserve transaction
+ routine(
+ &Some(IncomingSubject::Reserve(EddsaPublicKey::rand())),
+ &Some(IncomingSubject::Reserve(EddsaPublicKey::rand())),
+ )
+ .await;
+
+ // Kyc transaction
+ routine(
+ &Some(IncomingSubject::Kyc(EddsaPublicKey::rand())),
+ &Some(IncomingSubject::Kyc(EddsaPublicKey::rand())),
+ )
+ .await;
+
+ // History
+ assert_eq!(
+ revenue_history(&pool, BALANCE_ID, &CURR, &History::default(), dummy_listen)
+ .await
+ .unwrap()
+ .len(),
+ 6
+ );
+ assert_eq!(
+ incoming_history(&pool, BALANCE_ID, &CURR, &History::default(), dummy_listen)
+ .await
+ .unwrap()
+ .len(),
+ 4
+ );
+ }
+}
diff --git a/adapters/taler-wise/src/lib.rs b/adapters/taler-wise/src/lib.rs
@@ -0,0 +1,27 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 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 taler_common::config::parser::ConfigSource;
+
+pub mod api;
+pub mod config;
+pub mod db;
+pub mod payto;
+pub mod setup;
+pub mod wise_api;
+pub mod worker;
+
+pub const CONFIG_SOURCE: ConfigSource = ConfigSource::new("taler-wise", "wise", "taler-wise");
diff --git a/adapters/taler-wise/src/main.rs b/adapters/taler-wise/src/main.rs
@@ -0,0 +1,111 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 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 clap::Parser as _;
+use taler_api::api::TalerRouter as _;
+use taler_build::long_version;
+use taler_common::{CommonArgs, cli::ConfigCmd, config::Config, taler_main};
+use taler_wise::{
+ CONFIG_SOURCE, api,
+ config::ServeCfg,
+ db::{dbinit, pool},
+ setup::{self},
+ worker::run_worker,
+};
+
+#[derive(clap::Parser, Debug)]
+#[command(long_version = long_version(), about, long_about = None)]
+struct Args {
+ #[clap(flatten)]
+ common: CommonArgs,
+
+ #[command(subcommand)]
+ cmd: Command,
+}
+
+#[derive(clap::Subcommand, Debug)]
+enum Command {
+ /// Initialize taler-wise database
+ Dbinit {
+ /// Reset database (DANGEROUS: All existing data is lost)
+ #[arg(short, long)]
+ reset: bool,
+ },
+ /// Check taler-wise config
+ Setup,
+ /// Run taler-wise worker
+ Worker {
+ /// Execute once and return
+ #[arg(short, long)]
+ transient: bool,
+ },
+ /// Run taler-wise HTTP server
+ Serve {
+ /// Check whether an API is in use (if it's useful to start the HTTP
+ /// server). Exit with 0 if at least one API is enabled, otherwise 1
+ #[arg(long)]
+ check: bool,
+ },
+ #[command(subcommand)]
+ Config(ConfigCmd),
+}
+
+async fn run(cmd: Command, cfg: &Config) -> anyhow::Result<()> {
+ match cmd {
+ Command::Dbinit { reset } => {
+ dbinit(cfg, reset).await?;
+ }
+ Command::Setup => {
+ let client = http_client::client();
+ setup::setup(cfg, &client).await?
+ }
+ Command::Serve { check } => {
+ if check {
+ let cfg = ServeCfg::parse(cfg)?;
+ if cfg.revenue.is_none() && cfg.wire_gateway.is_none() {
+ std::process::exit(1);
+ }
+ } else {
+ let pool = pool(cfg).await?;
+ let cfg = ServeCfg::parse(cfg)?;
+ api::start(
+ pool,
+ &cfg.name,
+ cfg.balances,
+ &cfg.wire_gateway,
+ &cfg.revenue,
+ )
+ .await
+ .serve(&cfg.serve, None)
+ .await?;
+ }
+ }
+ Command::Worker { transient } => {
+ let pool = pool(cfg).await?;
+ let client = http_client::client();
+ run_worker(cfg, &pool, &client, transient).await?;
+ }
+ Command::Config(cmd) => cmd.run(cfg)?,
+ }
+ Ok(())
+}
+
+fn main() {
+ let args = Args::parse();
+ taler_main(CONFIG_SOURCE, args.common, async |cfg| {
+ run(args.cmd, cfg).await
+ });
+}
diff --git a/adapters/taler-wise/src/payto.rs b/adapters/taler-wise/src/payto.rs
@@ -0,0 +1,65 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 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 taler_common::types::payto::{
+ ACH, BankID, FullPayto, Payto, PaytoErr, PaytoImpl, PaytoURI, TransferPayto,
+};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+/// All kind of wise account we support
+pub enum WiseAccount {
+ IBAN(BankID),
+ ACH(ACH),
+}
+
+pub type WisePayto = Payto<WiseAccount>;
+pub type FullWisePayto = FullPayto<WiseAccount>;
+pub type TransferWisePayto = TransferPayto<WiseAccount>;
+
+impl PaytoImpl for WiseAccount {
+ fn as_uri(&self) -> PaytoURI {
+ match self {
+ WiseAccount::IBAN(bank_id) => bank_id.as_uri(),
+ WiseAccount::ACH(ach) => ach.as_uri(),
+ }
+ }
+
+ fn parse(uri: &PaytoURI) -> Result<Self, PaytoErr> {
+ Ok(if uri.as_ref().domain() == Some("ach") {
+ Self::ACH(ACH::parse(uri)?)
+ } else {
+ Self::IBAN(BankID::parse(uri)?)
+ })
+ }
+}
+
+impl std::fmt::Display for WiseAccount {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ WiseAccount::IBAN(BankID { iban, bic }) => {
+ if let Some(bic) = bic {
+ write!(f, "iban {iban} {bic}")
+ } else {
+ write!(f, "iban {iban}")
+ }
+ }
+ WiseAccount::ACH(ACH {
+ routing_number,
+ account_number,
+ }) => write!(f, "ach {routing_number} {account_number}"),
+ }
+ }
+}
diff --git a/adapters/taler-wise/src/setup.rs b/adapters/taler-wise/src/setup.rs
@@ -0,0 +1,135 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 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 taler_common::config::Config;
+use tracing::{debug, error, info, warn};
+
+use crate::{
+ config::{SetupCfg, WiseBalance},
+ wise_api::client::Client,
+};
+
+pub async fn setup(cfg: &Config, client: &http_client::Client) -> anyhow::Result<()> {
+ let cfg = SetupCfg::parse(cfg)?;
+ let client = Client::new(client, &cfg.token);
+
+ info!(target: "setup", "Check API access and configuration");
+ let mut ready = true;
+
+ // Check profile
+ let profiles = client.profiles().await?;
+ let profiles_fmt = std::fmt::from_fn(|w| {
+ for p in &profiles {
+ write!(w, "\n{} {:?} '{}'", p.id, p.ty, p.full_name)?;
+ }
+ Ok(())
+ });
+ if let Some(id) = cfg.profile_id {
+ if let Some(profile) = profiles.iter().find(|it| it.id == id) {
+ debug!(target: "setup", "PROFILE_ID {id} in config is one of:{profiles_fmt}");
+ match cfg.name {
+ Some(name) => {
+ if name != profile.full_name {
+ warn!(
+ target: "setup",
+ "Expected NAME '{name}' from config got '{}' from server",
+ profile.full_name
+ )
+ } else {
+ debug!(target: "setup", "NAME {name} from config match server")
+ }
+ }
+ None => {
+ error!(target: "setup", "Missing NAME got '{}' from server", profile.full_name);
+ ready = false;
+ }
+ }
+ } else {
+ error!(target: "setup", "Unknown PROFILE_ID {id} in config, must be one of:{profiles_fmt}");
+ ready = false;
+ }
+ } else {
+ error!(target: "setup", "Missing PROFILE_ID in config, must be one of:{profiles_fmt}");
+ ready = false;
+ }
+
+ if let Some(profile_id) = cfg.profile_id {
+ // Check accounts
+ let accounts = client.borderless_accounts(profile_id).await?;
+ let account = accounts
+ .iter()
+ .find(|it| it.profile_id == profile_id)
+ .unwrap();
+
+ let mapped: Vec<_> = account
+ .balances
+ .iter()
+ .map(|it| (it.id, it.currency, it.bank_details.account()))
+ .collect();
+
+ let balances_fmt = std::fmt::from_fn(|w| {
+ for (id, currency, account) in &mapped {
+ if let Some(account) = account {
+ write!(w, "\n{id} {currency} ({account})")?;
+ }
+ }
+ Ok(())
+ });
+ info!(target: "setup", "Supported balances are: {balances_fmt}");
+
+ if cfg.balances.is_empty() {
+ error!(target: "setup", "No balances are configured");
+ ready = false
+ } else {
+ for WiseBalance {
+ id,
+ currency,
+ payto,
+ } in &cfg.balances
+ {
+ match mapped.iter().find(|it| it.0 == *id as u64) {
+ Some((_, curr, acc)) => {
+ if curr != currency {
+ error!(target: "setup", "Balance {id} in config use currency {currency} expected {curr}");
+ ready = false;
+ }
+ if let Some(acc) = acc {
+ if acc != payto {
+ error!(target: "setup", "Balance {id} in config use payto ({payto}) expected ({acc})");
+ ready = false
+ }
+ } else {
+ error!(target: "setup", "Balance {id} in config use payto ({payto}) but it's not supported");
+ ready = false
+ }
+ }
+ None => {
+ error!(target: "setup", "Unknown balance {id} {currency}");
+ ready = false
+ }
+ }
+ }
+ }
+ }
+
+ if ready {
+ info!(target: "setup", "Setup finished");
+ Ok(())
+ } else {
+ error!(target: "setup", "Setup failed, config need editing");
+ std::process::exit(0);
+ }
+}
diff --git a/adapters/taler-wise/src/wise_api/client.rs b/adapters/taler-wise/src/wise_api/client.rs
@@ -0,0 +1,127 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2025, 2026 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::{borrow::Cow, str::FromStr as _, sync::LazyLock};
+
+use http_client::{ApiErr, ClientErr, builder::Req};
+use hyper::{
+ Method, StatusCode,
+ header::{AUTHORIZATION, HeaderValue, RETRY_AFTER},
+};
+use jiff::Timestamp;
+use serde::de::DeserializeOwned;
+use thiserror::Error;
+use url::Url;
+
+use crate::wise_api::types::{Balance, BalanceStatement, MultiCurrencyAccount, Profile};
+
+#[derive(Error, Debug)]
+pub enum WiseErr {
+ #[error("status {0}")]
+ Status(StatusCode),
+ #[error("status {0} '{1}'")]
+ StatusCause(StatusCode, String),
+ #[error("Rate limited for {0}")]
+ TooManyRequest(String),
+ #[error(transparent)]
+ Client(#[from] ClientErr),
+}
+pub type ApiResult<R> = std::result::Result<R, ApiErr<WiseErr>>;
+
+static API_URL: LazyLock<Url> = LazyLock::new(|| Url::from_str("https://api.wise.com").unwrap());
+
+pub struct Client<'a> {
+ client: &'a http_client::Client,
+ token: HeaderValue,
+}
+
+impl<'a> Client<'a> {
+ pub fn new(client: &'a http_client::Client, token: &'a str) -> Self {
+ Self {
+ client,
+ token: HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
+ }
+ }
+
+ fn request(&self, method: Method, path: impl Into<Cow<'static, str>>) -> Req {
+ Req::new(self.client, method, &API_URL, path)
+ }
+
+ async fn send<T: DeserializeOwned>(&self, req: Req) -> ApiResult<T> {
+ let (ctx, res) = req
+ .header(AUTHORIZATION, self.token.clone())
+ .send()
+ .await
+ .map_err(|(ctx, e)| ctx.wrap(e.into()))?;
+ match res.status() {
+ StatusCode::OK => {
+ // Parse json
+ res.json().await.map_err(|e| ctx.wrap(e.into()))
+ }
+ StatusCode::TOO_MANY_REQUESTS => Err(ctx.wrap(WiseErr::TooManyRequest(
+ res.str_header(RETRY_AFTER.as_str()).unwrap_or_default(),
+ ))),
+ other => Err(ctx.wrap(WiseErr::Status(other))),
+ }
+ }
+
+ pub async fn profiles(&self) -> ApiResult<Vec<Profile>> {
+ Self::send(self, self.request(Method::GET, "/v2/profiles")).await
+ }
+
+ pub async fn standard_balances(&self, profile_id: u64) -> ApiResult<Vec<Balance>> {
+ Self::send(
+ self,
+ self.request(
+ Method::GET,
+ format!("/v4/profiles/{profile_id}/balances?types=STANDARD"),
+ ),
+ )
+ .await
+ }
+
+ pub async fn borderless_accounts(
+ &self,
+ profile_id: u64,
+ ) -> ApiResult<Vec<MultiCurrencyAccount>> {
+ Self::send(
+ self,
+ self.request(
+ Method::GET,
+ format!("/v1/borderless-accounts?profileId={profile_id}"),
+ ),
+ )
+ .await
+ }
+
+ pub async fn balance_statement(
+ &self,
+ profile_id: u64,
+ balance_id: u32,
+ ) -> ApiResult<BalanceStatement> {
+ Self::send(
+ self,
+ self.request(
+ Method::GET,
+ format!("/v1/profiles/{profile_id}/balance-statements/{balance_id}/statement.json"),
+ )
+ .query("currency", "EUR")
+ .query("intervalStart", "2026-07-16T00:00:00.000Z")
+ .query("intervalEnd", Timestamp::now().to_string()),
+ )
+ .await
+ }
+}
diff --git a/adapters/taler-wise/src/wise_api/mod.rs b/adapters/taler-wise/src/wise_api/mod.rs
@@ -0,0 +1,18 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 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/>
+*/
+
+pub mod client;
+pub mod types;
diff --git a/adapters/taler-wise/src/wise_api/types.rs b/adapters/taler-wise/src/wise_api/types.rs
@@ -0,0 +1,201 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 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::str::FromStr;
+
+use compact_str::{CompactString, ToCompactString as _};
+use jiff::{Timestamp, civil::DateTime};
+use serde::{Deserialize, Serialize};
+use taler_common::types::{
+ amount::{Amount, Currency, Decimal},
+ iban::{BIC, IBAN},
+ payto::{ACH, BankID},
+};
+use uuid::Uuid;
+
+use crate::payto::WiseAccount;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Profile {
+ pub id: u64,
+ pub public_id: Uuid,
+ pub user_id: u64,
+ pub created_at: DateTime,
+ pub updated_at: DateTime,
+ pub current_state: CurrentState,
+ pub full_name: CompactString,
+ #[serde(rename = "type")]
+ pub ty: Type,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "UPPERCASE")]
+pub enum CurrentState {
+ Visible,
+ Hidden,
+ Deactivated,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "UPPERCASE")]
+pub enum Type {
+ Personal,
+ Business,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Money {
+ pub value: f64,
+ pub currency: Currency,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
+pub enum InvestmentState {
+ NotInvested,
+ Invested,
+ Investing,
+ Divesting,
+ Unknown,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Balance {
+ pub id: u64,
+ pub currency: Currency,
+ #[serde(rename = "type")]
+ pub name: Option<CompactString>,
+ pub investment_state: InvestmentState,
+ pub amount: Money,
+ pub reserved_amount: Money,
+ pub cash_amount: Money,
+ pub total_worth: Money,
+ pub creation_time: Timestamp,
+ pub modification_time: Timestamp,
+ pub visible: bool,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MultiCurrencyAccount {
+ pub id: u64,
+ pub profile_id: u64,
+ pub recipient_id: u64,
+ pub creation_time: Timestamp,
+ pub modification_time: Timestamp,
+ pub active: bool,
+ pub eligible: bool,
+ pub balances: Vec<BalanceV1>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BalanceV1 {
+ pub id: u64,
+ pub currency: Currency,
+ pub amount: Money,
+ pub reserved_amount: Money,
+ pub bank_details: BankDetails,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BankDetails {
+ pub id: u64,
+ pub currency: Currency,
+ pub bank_code: CompactString,
+ pub account_number: CompactString,
+ pub swift: Option<BIC>,
+ pub iban: Option<IBAN>,
+ pub bank_name: Option<CompactString>,
+}
+
+impl BankDetails {
+ pub fn account(&self) -> Option<WiseAccount> {
+ if let Some(iban) = self.iban {
+ return Some(WiseAccount::IBAN(BankID {
+ iban,
+ bic: self.swift,
+ }));
+ } else if self.currency == Currency::USD {
+ // TODO parse error handling ?
+ return Some(WiseAccount::ACH(ACH {
+ routing_number: self.bank_code.parse().unwrap(),
+ account_number: self.account_number.parse().unwrap(),
+ }));
+ }
+
+ // CAD & AUD & AED bank_code + account_number
+ // TODO find a name for each method and implement a payto for each of them
+
+ None
+ }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BalanceStatement {
+ pub transactions: Vec<Tx>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "UPPERCASE")]
+pub enum Direction {
+ Debit,
+ Credit,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct WiseAmount {
+ pub value: f64,
+ pub currency: Currency,
+}
+
+impl From<WiseAmount> for Amount {
+ fn from(val: WiseAmount) -> Self {
+ Amount::new_decimal(
+ &val.currency,
+ Decimal::from_str(&val.value.to_compact_string()).unwrap(),
+ )
+ }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Tx {
+ #[serde(rename = "type")]
+ pub direction: Direction,
+ pub date: Timestamp,
+ pub amount: WiseAmount,
+ pub total_fees: WiseAmount,
+ pub reference_number: CompactString,
+ pub details: Details,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Details {
+ #[serde(rename = "type")]
+ pub ty: CompactString,
+ pub description: CompactString,
+ pub sender_account: CompactString,
+ pub sender_name: CompactString,
+ pub payment_reference: CompactString,
+}
diff --git a/adapters/taler-wise/src/worker.rs b/adapters/taler-wise/src/worker.rs
@@ -0,0 +1,153 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 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::LazyLock;
+
+use http_client::ApiErr;
+use jiff::Timestamp;
+use regex::Regex;
+use sqlx::PgPool;
+use taler_api::subject::parse_incoming_unstructured;
+use taler_common::{ExpoBackoffDecorr, config::Config, types::payto::BankID};
+use tracing::{error, info, trace};
+
+use crate::{
+ config::WorkerCfg,
+ db::{AddIncomingResult, TxIn, register_tx_in},
+ payto::WiseAccount,
+ wise_api::{
+ client::{Client, WiseErr},
+ types::Direction,
+ },
+};
+
+#[derive(Debug, thiserror::Error)]
+pub enum WorkerError {
+ #[error(transparent)]
+ Db(#[from] sqlx::Error),
+ #[error(transparent)]
+ Api(#[from] ApiErr<WiseErr>),
+}
+
+pub type WorkerResult = Result<(), WorkerError>;
+
+fn parse_account(account_str: &str) -> Option<WiseAccount> {
+ static IBAN_BIC_PATTERN: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r"^\(([A-Z0-9]{8,11})\) ([A-Z0-9]+)$").unwrap());
+
+ if let Some(caps) = IBAN_BIC_PATTERN.captures(account_str) {
+ let bic = caps[1].parse().ok()?;
+ let iban = caps[2].parse().ok()?;
+
+ return Some(WiseAccount::IBAN(BankID {
+ iban,
+ bic: Some(bic),
+ }));
+ }
+
+ None
+}
+
+pub async fn run_worker(
+ cfg: &Config,
+ pool: &PgPool,
+ client: &http_client::Client,
+ transient: bool,
+) -> anyhow::Result<()> {
+ let cfg = WorkerCfg::parse(cfg)?;
+ let client = Client::new(client, &cfg.token);
+ let mut jitter = ExpoBackoffDecorr::default();
+ loop {
+ if !transient {
+ info!(target: "worker", "running at initialisation");
+ }
+ let res: WorkerResult = async {
+ loop {
+ // Sync
+ for balance in &cfg.balances {
+ let stmt = client
+ .balance_statement(cfg.profile_id, balance.id)
+ .await
+ .unwrap();
+ let now = Timestamp::now();
+ for tx in stmt.transactions {
+ match tx.direction {
+ Direction::Debit => {
+ // TODO support outgoing transaction
+ }
+ Direction::Credit => {
+ let subject =
+ parse_incoming_unstructured(&tx.details.payment_reference);
+ // Parse sender account
+ let payto = parse_account(&tx.details.sender_account);
+ let tx = TxIn {
+ balance_id: balance.id,
+ wise_ref: Some(tx.reference_number),
+ amount: tx.amount.into(),
+ subject: tx.details.payment_reference,
+ name: tx.details.sender_name,
+ debtor: payto,
+ value_at: tx.date,
+ };
+ let failure =
+ match register_tx_in(pool, &tx, &subject.ok(), &now).await? {
+ AddIncomingResult::Success { new, .. } => {
+ if new {
+ info!(target: "worker", "in {tx}");
+ } else {
+ trace!(target: "worker", "in {tx} already seen");
+ }
+ continue;
+ }
+ AddIncomingResult::ReservePubReuse => "reserve pub reuse",
+ AddIncomingResult::UnknownMapping => "unknown mapping",
+ AddIncomingResult::MappingReuse => "mapping reuse",
+ };
+
+ match register_tx_in(pool, &tx, &None, &now).await? {
+ AddIncomingResult::Success { new, .. } => {
+ if new {
+ info!(target: "worker", "in {tx}: {failure}");
+ } else {
+ trace!(target: "worker", "in {tx} already seen: {failure}");
+ }
+ continue;
+ }
+ AddIncomingResult::ReservePubReuse
+ | AddIncomingResult::UnknownMapping
+ | AddIncomingResult::MappingReuse => unreachable!(),
+ };
+ }
+ }
+ }
+ }
+
+ // then wait
+ jitter.reset();
+ tokio::time::sleep(cfg.frequency).await;
+ info!(target: "worker", "running at frequency");
+ }
+ }
+ .await;
+ if transient {
+ res?;
+ return Ok(());
+ }
+ let err = res.unwrap_err();
+ error!(target: "worker", "{err}");
+ tokio::time::sleep(jitter.backoff()).await;
+ }
+}
diff --git a/adapters/taler-wise/wise.conf b/adapters/taler-wise/wise.conf
@@ -0,0 +1,89 @@
+[wise]
+# Legal entity that is associated with the Wise account
+NAME =
+
+[wise-worker]
+# Adapter Wise profile ID
+PROFILE_ID =
+
+# How often should worker run when no notification is received
+FREQUENCY = 30m
+
+# Account token
+TOKEN =
+
+# Specify the account type and therefore the indexing behavior.
+# This can either can be normal or exchange.
+# Exchange accounts bounce invalid incoming Taler transactions
+ACCOUNT_TYPE = exchange
+
+# [wise-balance-eur]
+# Balance Wise ID
+# ID = 139524631
+
+# Balance currency
+# CURRENCY = EUR
+
+# Balance account type, this can only be iban
+# PAYTO_TYPE = iban
+
+# Balance IBAN, for iban payto type
+# IBAN = GB82WEST12345698765432
+
+# Balance BIC, for iban payto type
+# BIC = TRWIBEB1XXX
+
+[wise-httpd]
+# How "taler-wise serve" serves its API, this can either be tcp or unix
+SERVE = tcp
+
+# Port on which the HTTP server listens, e.g. 9967. Only used if SERVE is tcp.
+PORT = 8080
+
+# Which IP address should we bind to? E.g. ``127.0.0.1`` or ``::1``for loopback. Only used if SERVE is tcp.
+BIND_TO = 0.0.0.0
+
+# Which unix domain path should we bind to? Only used if SERVE is unix.
+# UNIXPATH = taler-wise.sock
+
+# What should be the file access permissions for UNIXPATH? Only used if SERVE is unix.
+# UNIXPATH_MODE = 660
+
+[wise-httpd-wire-gateway-api]
+# Whether to serve the Wire Gateway API
+ENABLED = NO
+
+# Authentication scheme, this can either can be basic, bearer or none.
+AUTH_METHOD = bearer
+
+# User name for basic authentication scheme
+# USERNAME =
+
+# Password for basic authentication scheme
+# PASSWORD =
+
+# Token for bearer authentication scheme
+TOKEN =
+
+[wise-httpd-revenue-api]
+# Whether to serve the Revenue API
+ENABLED = NO
+
+# Authentication scheme, this can either can be basic, bearer or none.
+AUTH_METHOD = bearer
+
+# User name for basic authentication scheme
+# USERNAME =
+
+# Password for basic authentication scheme
+# PASSWORD =
+
+# Token for bearer authentication scheme
+TOKEN =
+
+[wisedb-postgres]
+# DB connection string
+CONFIG = postgres:///taler-wise
+
+# Where are the SQL files to setup our tables?
+SQL_DIR = ${DATADIR}/sql/
+\ No newline at end of file
diff --git a/common/taler-api/db/taler-api-0001.sql b/common/taler-api/db/taler-api-0001.sql
@@ -50,7 +50,7 @@ CREATE TABLE taler_in (
authorization_pub BYTEA CHECK (LENGTH(authorization_pub)=32),
authorization_sig BYTEA CHECK (LENGTH(authorization_sig)=64)
);
-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 (account_pub) WHERE type = 'reserve';
@@ -77,7 +77,7 @@ COMMENT ON TABLE transfer IS 'Wire Gateway transfers';
CREATE TABLE bounced(
tx_in_id INT8 NOT NULL UNIQUE REFERENCES tx_in(tx_in_id) ON DELETE CASCADE
);
-COMMENT ON TABLE tx_in IS 'Bounced transaction';
+COMMENT ON TABLE bounced IS 'Bounced transaction';
CREATE TABLE prepared_in (
type incoming_type NOT NULL,
diff --git a/common/taler-api/db/taler-api-procedures.sql b/common/taler-api/db/taler-api-procedures.sql
@@ -150,8 +150,7 @@ IF in_type = 'map' THEN
END IF;
-- Check conflict
-SELECT NOT local_pending AND in_type = 'reserve' AND EXISTS(SELECT FROM taler_in WHERE account_pub = in_account_pub AND type = 'reserve')
- INTO out_reserve_pub_reuse;
+out_reserve_pub_reuse=NOT local_pending AND in_type = 'reserve' AND EXISTS(SELECT FROM taler_in WHERE account_pub = in_account_pub AND type = 'reserve');
IF out_reserve_pub_reuse THEN
RETURN;
END IF;
diff --git a/common/taler-common/src/log.rs b/common/taler-common/src/log.rs
@@ -133,6 +133,7 @@ pub fn taler_logger(max_level: Option<Level>, verbose: bool) -> impl SubscriberI
|| target.starts_with("h2")
|| target.starts_with("reqwest")
|| target.starts_with("rustls")
- || target.starts_with("hyper_rustls")))
+ || target.starts_with("hyper_rustls")
+ || target.starts_with("mio")))
}))
}
diff --git a/common/taler-common/src/types.rs b/common/taler-common/src/types.rs
@@ -1,6 +1,6 @@
/*
This file is part of TALER
- Copyright (C) 2024, 2025, 2026 Taler Systems SA
+ Copyright (C) 2024-2026 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
@@ -14,6 +14,7 @@
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+pub mod ach;
pub mod amount;
pub mod base32;
pub mod iban;
diff --git a/common/taler-common/src/types/ach.rs b/common/taler-common/src/types/ach.rs
@@ -0,0 +1,179 @@
+/*
+ This file is part of TALER
+ Copyright (C) 2026 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::{fmt::Display, str::FromStr};
+
+use compact_str::CompactString;
+use serde_with::{DeserializeFromStr, SerializeDisplay};
+
+use crate::types::utils::{InlineAscii, InlineStr};
+
+const ROUTING_NB_SIZE: usize = 9;
+const MAX_ACCOUNT_NB_SIZE: usize = 17;
+
+/// ACH routing number
+#[derive(Clone, Copy, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)]
+pub struct RoutingNumber(InlineAscii<ROUTING_NB_SIZE>);
+
+impl RoutingNumber {
+ #[inline]
+ pub fn checksum(s: &[u8; 9]) -> u8 {
+ // Sum raw ASCII values directly using 16-bit registers
+ let sum0 = s[0] as u16 + s[3] as u16 + s[6] as u16;
+ let sum1 = s[1] as u16 + s[4] as u16 + s[7] as u16;
+ let sum2 = s[2] as u16 + s[5] as u16 + s[8] as u16;
+
+ // Fold all 9 `b'0'` (48) ASCII subtractions into a single constant offset:
+ // (3 * 3 + 7 * 3 + 1 * 3) * 48 = 33 * 48 = 1584
+ let total = 3 * sum0 + 7 * sum1 + sum2 - 1584;
+ (total % 10) as u8
+ }
+
+ pub fn federal_reserve_routine_symbol(&self) -> &str {
+ // SAFETY len = 9
+ unsafe { self.as_ref().get_unchecked(0..4) }
+ }
+
+ pub fn financial_institution_identifier(&self) -> &str {
+ // SAFETY len = 8
+ unsafe { self.as_ref().get_unchecked(4..8) }
+ }
+}
+
+impl AsRef<str> for RoutingNumber {
+ fn as_ref(&self) -> &str {
+ self.0.as_ref()
+ }
+}
+
+#[derive(Debug, PartialEq, Eq, thiserror::Error)]
+pub enum RoutineNumberErrKind {
+ #[error("contains illegal characters (only 0-9 allowed)")]
+ Invalid,
+ #[error("invalid check digit")]
+ Checksum,
+ #[error("bad size expected {ROUTING_NB_SIZE} chars got {0}")]
+ Size(usize),
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error("ACH routing number '{routing_number}' {kind}")]
+pub struct ParseRoutingNumberErr {
+ routing_number: CompactString,
+ pub kind: RoutineNumberErrKind,
+}
+
+impl FromStr for RoutingNumber {
+ type Err = ParseRoutingNumberErr;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let bytes: &[u8] = s.as_bytes();
+ if let Ok(array) = <&[u8] as TryInto<[u8; ROUTING_NB_SIZE]>>::try_into(bytes) {
+ if !array.iter().all(u8::is_ascii_digit) {
+ Err(RoutineNumberErrKind::Invalid)
+ } else if Self::checksum(&array) != 0 {
+ Err(RoutineNumberErrKind::Checksum)
+ } else {
+ Ok(Self(InlineAscii::from(array)))
+ }
+ } else {
+ Err(RoutineNumberErrKind::Size(bytes.len()))
+ }
+ .map_err(|kind| ParseRoutingNumberErr {
+ routing_number: s.into(),
+ kind,
+ })
+ }
+}
+
+impl Display for RoutingNumber {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ Display::fmt(&self.as_ref(), f)
+ }
+}
+
+impl std::fmt::Debug for RoutingNumber {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ Display::fmt(&self, f)
+ }
+}
+
+/// ACH account number
+#[derive(Clone, Copy, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)]
+pub struct AccountNumber(InlineStr<MAX_ACCOUNT_NB_SIZE>);
+
+impl AsRef<str> for AccountNumber {
+ fn as_ref(&self) -> &str {
+ self.0.as_ref()
+ }
+}
+
+#[derive(Debug, PartialEq, Eq, thiserror::Error)]
+pub enum AccountNumberErrKind {
+ #[error("contains illegal characters (only 0-9A-Z allowed)")]
+ Invalid,
+ #[error("bad size expected max {MAX_ACCOUNT_NB_SIZE} chars got {0}")]
+ Size(usize),
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error("ACH account number '{account_number}' {kind}")]
+pub struct ParseAccountNumberErr {
+ account_number: CompactString,
+ pub kind: AccountNumberErrKind,
+}
+impl FromStr for AccountNumber {
+ type Err = ParseAccountNumberErr;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let bytes: &[u8] = s.as_bytes();
+ let len = bytes.len();
+ if len > MAX_ACCOUNT_NB_SIZE {
+ Err(AccountNumberErrKind::Size(len))
+ } else if !bytes.iter().all(u8::is_ascii_alphanumeric) {
+ Err(AccountNumberErrKind::Invalid)
+ } else {
+ Ok(Self(
+ InlineStr::try_from_iter(bytes.iter().copied().map(|b| b.to_ascii_uppercase()))
+ .unwrap(),
+ ))
+ }
+ .map_err(|kind| ParseAccountNumberErr {
+ account_number: s.into(),
+ kind,
+ })
+ }
+}
+
+impl Display for AccountNumber {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ Display::fmt(&self.as_ref(), f)
+ }
+}
+
+impl std::fmt::Debug for AccountNumber {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ Display::fmt(&self, f)
+ }
+}
+
+#[test]
+fn ach() {
+ for nb in ["111000038", "021000021"] {
+ let parsed = RoutingNumber::from_str(nb).unwrap();
+ assert_eq!(parsed.to_string(), nb)
+ }
+}
diff --git a/common/taler-common/src/types/amount.rs b/common/taler-common/src/types/amount.rs
@@ -77,6 +77,9 @@ impl Currency {
pub const EUR: Self = Self::const_parse("EUR");
pub const CHF: Self = Self::const_parse("CHF");
pub const HUF: Self = Self::const_parse("HUF");
+ pub const USD: Self = Self::const_parse("USD");
+ pub const GBP: Self = Self::const_parse("GBP");
+ pub const AUD: Self = Self::const_parse("AUD");
pub const fn const_parse(s: &str) -> Currency {
let bytes = s.as_bytes();
diff --git a/common/taler-common/src/types/iban.rs b/common/taler-common/src/types/iban.rs
@@ -20,6 +20,7 @@ use std::{
str::FromStr,
};
+use compact_str::CompactString;
pub use registry::Country;
use registry::{IbanC, PatternErr, check_pattern, rng_pattern};
use serde_with::{DeserializeFromStr, SerializeDisplay};
@@ -50,7 +51,7 @@ pub struct IBAN {
impl IBAN {
/// Compute IBAN checksum
- fn iban_checksum(s: &[u8]) -> u8 {
+ fn checksum(s: &[u8]) -> u8 {
(s.iter().cycle().skip(4).take(s.len()).fold(0u32, |sum, b| {
if b.is_ascii_digit() {
(sum * 10 + (b - b'0') as u32) % 97
@@ -72,7 +73,7 @@ impl IBAN {
)
.unwrap();
// Compute check digit
- let checksum = 98 - Self::iban_checksum(encoded.deref());
+ let checksum = 98 - Self::checksum(encoded.deref());
// And insert it
unsafe {
@@ -121,7 +122,7 @@ impl AsRef<str> for IBAN {
}
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
-pub enum IbanErrorKind {
+pub enum IbanErrKind {
#[error("contains illegal characters (only 0-9A-Z allowed)")]
Invalid,
#[error("contains invalid characters")]
@@ -140,13 +141,13 @@ pub enum IbanErrorKind {
#[derive(Debug, thiserror::Error)]
#[error("iban '{iban}' {kind}")]
-pub struct ParseIbanError {
- iban: String,
- pub kind: IbanErrorKind,
+pub struct ParseIbanErr {
+ iban: CompactString,
+ pub kind: IbanErrKind,
}
impl FromStr for IBAN {
- type Err = ParseIbanError;
+ type Err = ParseIbanErr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bytes: &[u8] = s.as_bytes();
@@ -154,40 +155,40 @@ impl FromStr for IBAN {
.iter()
.all(|b| b.is_ascii_whitespace() || b.is_ascii_alphanumeric())
{
- Err(IbanErrorKind::Invalid)
+ Err(IbanErrKind::Invalid)
} else if let Some(encoded) = InlineStr::try_from_iter(
bytes
.iter()
.filter_map(|b| (!b.is_ascii_whitespace()).then_some(b.to_ascii_uppercase())),
) {
if encoded.len() < 4 {
- Err(IbanErrorKind::Underflow(encoded.len()))
+ Err(IbanErrKind::Underflow(encoded.len()))
} else if !IbanC::A.check(&encoded[0..2]) || !IbanC::N.check(&encoded[2..4]) {
- Err(IbanErrorKind::Malformed)
+ Err(IbanErrKind::Malformed)
} else if let Some(country) = Country::from_iso(&encoded.as_ref()[..2]) {
if let Err(e) = check_pattern(&encoded[4..], country.bban_pattern()) {
Err(match e {
- PatternErr::Len(expected, got) => IbanErrorKind::Size(expected, got),
- PatternErr::Malformed => IbanErrorKind::Malformed,
+ PatternErr::Len(expected, got) => IbanErrKind::Size(expected, got),
+ PatternErr::Malformed => IbanErrKind::Malformed,
})
} else {
- let checksum = Self::iban_checksum(&encoded);
+ let checksum = Self::checksum(&encoded);
if checksum != 1 {
- Err(IbanErrorKind::Checksum(checksum))
+ Err(IbanErrKind::Checksum(checksum))
} else {
Ok(Self { country, encoded })
}
}
} else {
- Err(IbanErrorKind::UnknownCountry(
+ Err(IbanErrKind::UnknownCountry(
encoded.as_ref()[..2].to_owned(),
))
}
} else {
- Err(IbanErrorKind::Overflow(bytes.len()))
+ Err(IbanErrKind::Overflow(bytes.len()))
}
- .map_err(|kind| ParseIbanError {
- iban: s.to_owned(),
+ .map_err(|kind| ParseIbanErr {
+ iban: s.into(),
kind,
})
}
@@ -206,7 +207,7 @@ impl Debug for IBAN {
}
/// Bank Identifier Code (BIC)
-#[derive(Debug, Clone, Copy, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)]
+#[derive(Clone, Copy, PartialEq, Eq, DeserializeFromStr, SerializeDisplay)]
pub struct BIC(InlineStr<MAX_BIC_SIZE>);
impl BIC {
@@ -239,46 +240,46 @@ impl AsRef<str> for BIC {
}
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
-pub enum BicErrorKind {
+pub enum BicErrKind {
#[error("contains illegal characters (only 0-9A-Z allowed)")]
Invalid,
#[error("invalid check digit")]
BankCode,
#[error("invalid country code")]
CountryCode,
- #[error("bad size expected 8 or {MAX_BIC_SIZE} chars for {0}")]
+ #[error("bad size expected 8 or {MAX_BIC_SIZE} chars got {0}")]
Size(usize),
}
#[derive(Debug, thiserror::Error)]
#[error("bic '{bic}' {kind}")]
-pub struct ParseBicError {
- bic: String,
- pub kind: BicErrorKind,
+pub struct ParseBicErr {
+ bic: CompactString,
+ pub kind: BicErrKind,
}
impl FromStr for BIC {
- type Err = ParseBicError;
+ type Err = ParseBicErr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let bytes: &[u8] = s.as_bytes();
let len = bytes.len();
if len != 8 && len != MAX_BIC_SIZE {
- Err(BicErrorKind::Size(len))
+ Err(BicErrKind::Size(len))
} else if !bytes[0..4].iter().all(u8::is_ascii_alphabetic) {
- Err(BicErrorKind::BankCode)
+ Err(BicErrKind::BankCode)
} else if !bytes[4..6].iter().all(u8::is_ascii_alphabetic) {
- Err(BicErrorKind::CountryCode)
+ Err(BicErrKind::CountryCode)
} else if !bytes[6..].iter().all(u8::is_ascii_alphanumeric) {
- Err(BicErrorKind::Invalid)
+ Err(BicErrKind::Invalid)
} else {
Ok(Self(
InlineStr::try_from_iter(bytes.iter().copied().map(|b| b.to_ascii_uppercase()))
.unwrap(),
))
}
- .map_err(|kind| ParseBicError {
- bic: s.to_owned(),
+ .map_err(|kind| ParseBicErr {
+ bic: s.into(),
kind,
})
}
@@ -290,6 +291,12 @@ impl Display for BIC {
}
}
+impl Debug for BIC {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ Display::fmt(&self, f)
+ }
+}
+
#[test]
fn parse_iban() {
use registry::VALID_IBAN;
@@ -313,12 +320,12 @@ fn parse_iban() {
}
for (invalid, err) in [
- ("FR1420041@10050500013M02606", IbanErrorKind::Invalid),
- ("", IbanErrorKind::Underflow(0)),
- ("12345678901234567890123456", IbanErrorKind::Malformed),
- ("FR", IbanErrorKind::Underflow(2)),
- ("FRANCE123456", IbanErrorKind::Malformed),
- ("DE44500105175407324932", IbanErrorKind::Checksum(28)),
+ ("FR1420041@10050500013M02606", IbanErrKind::Invalid),
+ ("", IbanErrKind::Underflow(0)),
+ ("12345678901234567890123456", IbanErrKind::Malformed),
+ ("FR", IbanErrKind::Underflow(2)),
+ ("FRANCE123456", IbanErrKind::Malformed),
+ ("DE44500105175407324932", IbanErrKind::Checksum(28)),
] {
let iban = IBAN::from_str(invalid).unwrap_err();
assert_eq!(iban.kind, err);
@@ -349,11 +356,11 @@ fn parse_bic() {
}
for (invalid, err) in [
- ("DEU", BicErrorKind::Size(3)),
- ("DEUTDEFFA1BC", BicErrorKind::Size(12)),
- ("D3UTDEFF", BicErrorKind::BankCode),
- ("DEUTD3FF", BicErrorKind::CountryCode),
- ("DEUTDEFF@@1", BicErrorKind::Invalid),
+ ("DEU", BicErrKind::Size(3)),
+ ("DEUTDEFFA1BC", BicErrKind::Size(12)),
+ ("D3UTDEFF", BicErrKind::BankCode),
+ ("DEUTD3FF", BicErrKind::CountryCode),
+ ("DEUTDEFF@@1", BicErrKind::Invalid),
] {
let bic = BIC::from_str(invalid).unwrap_err();
assert_eq!(bic.kind, err, "{invalid}");
diff --git a/common/taler-common/src/types/payto.rs b/common/taler-common/src/types/payto.rs
@@ -29,6 +29,7 @@ use super::{
amount::Amount,
iban::{BIC, IBAN},
};
+use crate::types::ach::{AccountNumber, RoutingNumber};
/// Parse a payto URI, panic if malformed
pub fn payto(url: impl AsRef<str>) -> PaytoURI {
@@ -208,10 +209,10 @@ impl PaytoImpl for BankID {
));
}
let Some(mut segments) = url.path_segments() else {
- return Err(PaytoErr::MissingSegment("iban"));
+ return Err(PaytoErr::MissingSegment(IBAN));
};
let Some(first) = segments.next() else {
- return Err(PaytoErr::MissingSegment("iban"));
+ return Err(PaytoErr::MissingSegment(IBAN));
};
let (iban, bic) = match segments.next() {
Some(second) => (second, Some(first)),
@@ -221,7 +222,7 @@ impl PaytoImpl for BankID {
Ok(Self {
iban: iban
.parse()
- .map_err(|e| PaytoErr::malformed_segment("iban", e))?,
+ .map_err(|e| PaytoErr::malformed_segment(IBAN, e))?,
bic: bic
.map(|bic| {
bic.parse()
@@ -234,7 +235,7 @@ impl PaytoImpl for BankID {
impl PaytoImpl for IBAN {
fn as_uri(&self) -> PaytoURI {
- PaytoURI::from_parts("iban", format_args!("/{self}"))
+ PaytoURI::from_parts(IBAN, format_args!("/{self}"))
}
fn parse(raw: &PaytoURI) -> Result<Self, PaytoErr> {
@@ -244,6 +245,54 @@ impl PaytoImpl for IBAN {
}
}
+pub type AchPayto = Payto<ACH>;
+pub type FullAchPayto = FullPayto<ACH>;
+pub type TransferAchPayto = TransferPayto<ACH>;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ACH {
+ pub routing_number: RoutingNumber,
+ pub account_number: AccountNumber,
+}
+
+const ACH: &str = "ach";
+
+impl PaytoImpl for ACH {
+ fn as_uri(&self) -> PaytoURI {
+ PaytoURI::from_parts(
+ ACH,
+ format_args!("/{}/{}", self.routing_number, self.account_number),
+ )
+ }
+
+ fn parse(raw: &PaytoURI) -> Result<Self, PaytoErr> {
+ let url = raw.as_ref();
+ if url.domain() != Some(ACH) {
+ return Err(PaytoErr::UnsupportedKind(
+ ACH,
+ url.domain().unwrap_or_default().into(),
+ ));
+ }
+ let Some(mut segments) = url.path_segments() else {
+ return Err(PaytoErr::MissingSegment("routing number"));
+ };
+ let Some(routing_number) = segments.next() else {
+ return Err(PaytoErr::MissingSegment("routing number"));
+ };
+ let Some(account_number) = segments.next() else {
+ return Err(PaytoErr::MissingSegment("account number"));
+ };
+ Ok(Self {
+ routing_number: routing_number
+ .parse()
+ .map_err(|e| PaytoErr::malformed_segment("routing number", e))?,
+ account_number: account_number
+ .parse()
+ .map_err(|e| PaytoErr::malformed_segment("account number", e))?,
+ })
+ }
+}
+
/// Full payto query
#[derive(Debug, Clone, Deserialize)]
pub struct FullQuery {
@@ -365,8 +414,8 @@ impl<P: PaytoImpl> FullPayto<P> {
self.inner.as_full_uri(&self.name)
}
- pub fn into_inner(self) -> P {
- self.inner
+ pub fn into_inner(self) -> (P, CompactString) {
+ (self.inner, self.name)
}
}
diff --git a/common/taler-common/src/types/utils.rs b/common/taler-common/src/types/utils.rs
@@ -23,28 +23,63 @@ use jiff::{
};
#[derive(Clone, Copy, PartialEq, Eq)]
-pub struct InlineStr<const LEN: usize> {
+/// Fixed sized inlined ASCII string
+pub struct InlineAscii<const LEN: usize> {
+ /// Buffer of ASCII bytes
+ // TODO use std::ascii::Char when stable
+ buf: [u8; LEN],
+}
+
+impl<const LEN: usize> From<[u8; LEN]> for InlineAscii<LEN> {
+ fn from(buf: [u8; LEN]) -> Self {
+ assert!(buf.is_ascii());
+ Self { buf }
+ }
+}
+
+impl<const LEN: usize> AsRef<str> for InlineAscii<LEN> {
+ #[inline]
+ fn as_ref(&self) -> &str {
+ // SAFETY: buf are all ASCII
+ unsafe { std::str::from_utf8_unchecked(&self.buf) }
+ }
+}
+
+impl<const LEN: usize> std::fmt::Display for InlineAscii<LEN> {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.as_ref())
+ }
+}
+
+impl<const LEN: usize> std::fmt::Debug for InlineAscii<LEN> {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.as_ref())
+ }
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+/// Inlined ASCII string
+pub struct InlineStr<const MAX: usize> {
/// Len of ascii string in buf
len: u8,
- /// Buffer of ascii bytes, left adjusted and zero padded
+ /// Buffer of ASCII bytes
// TODO use std::ascii::Char when stable
- buf: [u8; LEN],
+ buf: [u8; MAX],
}
-impl<const LEN: usize> InlineStr<LEN> {
+impl<const MAX: usize> InlineStr<MAX> {
/// Create an inlined string from a slice
#[inline]
pub const fn copy_from_slice(slice: &[u8]) -> Self {
+ assert!(slice.is_ascii());
let len = slice.len();
- let mut buf = [0; LEN];
+ let mut buf = [0; MAX];
// TODO use buf[..len].copy_from_slice(slice); once allowed in const
let mut i = 0;
while i < len {
buf[i] = slice[i];
i += 1;
}
-
- debug_assert!(buf.is_ascii());
Self {
len: len as u8,
buf,
@@ -56,12 +91,12 @@ impl<const LEN: usize> InlineStr<LEN> {
#[inline]
pub fn try_from_iter<T: IntoIterator<Item = u8>>(iter: T) -> Option<Self> {
let mut len = 0;
- let mut buf = [0; LEN];
+ let mut buf = [0; MAX];
for byte in iter {
*buf.get_mut(len)? = byte;
len += 1;
}
- debug_assert!(buf.is_ascii());
+ assert!(buf.is_ascii());
Some(Self {
len: len as u8,
buf,
@@ -72,36 +107,36 @@ impl<const LEN: usize> InlineStr<LEN> {
/// You must only write valid ASCII chars
#[inline]
pub unsafe fn deref_mut(&mut self) -> &mut [u8] {
- // SAFETY: len <= LEN
+ // SAFETY: len <= MAX
unsafe { self.buf.get_unchecked_mut(..self.len as usize) }
}
}
-impl<const LEN: usize> AsRef<str> for InlineStr<LEN> {
+impl<const MAX: usize> AsRef<str> for InlineStr<MAX> {
#[inline]
fn as_ref(&self) -> &str {
- // SAFETY: len <= LEN && buf[..len] are all ASCII uppercase
+ // SAFETY: len <= MAX && buf[..len] are all ASCII
unsafe { std::str::from_utf8_unchecked(self.buf.get_unchecked(..self.len as usize)) }
}
}
-impl<const LEN: usize> Deref for InlineStr<LEN> {
+impl<const MAX: usize> Deref for InlineStr<MAX> {
type Target = [u8];
#[inline]
fn deref(&self) -> &Self::Target {
- // SAFETY: len <= LEN
+ // SAFETY: len <= MAX
unsafe { self.buf.get_unchecked(..self.len as usize) }
}
}
-impl<const LEN: usize> std::fmt::Display for InlineStr<LEN> {
+impl<const MAX: usize> std::fmt::Display for InlineStr<MAX> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_ref())
}
}
-impl<const LEN: usize> std::fmt::Debug for InlineStr<LEN> {
+impl<const MAX: usize> std::fmt::Debug for InlineStr<MAX> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_ref())
}
diff --git a/contrib/ci/ci.sh b/contrib/ci/ci.sh
@@ -6,7 +6,7 @@ set -exvuo pipefail
OCI_RUNTIME=$(which podman)
REPO_NAME=$(basename "${PWD}")
JOB_NAME="${1}"
-NATIVE_ARCH=$(dpkg --print-architecture)
+NATIVE_ARCH=$(uname -m); [[ $NATIVE_ARCH == x86_64 ]] && NATIVE_ARCH=amd64
JOB_ARCH=$((grep CONTAINER_ARCH contrib/ci/jobs/${JOB_NAME}/config.ini | cut -d' ' -f 3) || echo "${2:-$NATIVE_ARCH}")
JOB_CONTAINER=$((grep CONTAINER_NAME contrib/ci/jobs/${JOB_NAME}/config.ini | cut -d' ' -f 3) || echo "localhost/${REPO_NAME}:${JOB_ARCH}")
CONTAINER_BUILD=$((grep CONTAINER_BUILD contrib/ci/jobs/${JOB_NAME}/config.ini | cut -d' ' -f 3) || echo "True")
diff --git a/contrib/ci/deb-test.sh b/contrib/ci/deb-test.sh
@@ -5,8 +5,8 @@ function step {
echo -e "\n$@" >&2
}
-BINS="taler-magnet-bank taler-cyclos taler-apns-relay"
-USERS="taler-magnet-bank-httpd taler-magnet-bank-worker taler-cyclos-httpd taler-cyclos-worker taler-apns-relay-httpd taler-apns-relay-worker"
+BINS="taler-magnet-bank taler-cyclos taler-wise taler-apns-relay"
+USERS="taler-magnet-bank-httpd taler-magnet-bank-worker taler-cyclos-httpd taler-cyclos-worker taler-wise-httpd taler-wise-worker taler-apns-relay-httpd taler-apns-relay-worker"
step "Install"
dpkg -i ../*.deb
@@ -40,6 +40,10 @@ for USER in taler-cyclos-httpd taler-cyclos-worker; do
step "Check $USER db access"
sudo -u $USER psql -d taler-cyclos -c "SELECT 1;"
done
+for USER in taler-wise-httpd taler-wise-worker; do
+ step "Check $USER db access"
+ sudo -u $USER psql -d taler-wise -c "SELECT 1;"
+done
for USER in taler-apns-relay-httpd taler-apns-relay-worker; do
step "Check $USER db access"
sudo -u $USER psql -d taler-apns-relay -c "SELECT 1;"
diff --git a/contrib/taler-wise-dbconfig b/contrib/taler-wise-dbconfig
@@ -0,0 +1,167 @@
+#!/bin/bash
+# This file is part of GNU TALER.
+# Copyright (C) 2026 Taler Systems SA
+#
+# TALER is free software; you can redistribute it and/or modify it under the
+# terms of the GNU Lesser General Public License as published by the Free Software
+# Foundation; either version 2.1, 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 Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License along with
+# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+#
+# @author Antoine d'Aligny
+
+# Error checking on
+set -eu
+
+# 1 is true, 0 is false
+RESET_DB=0
+FORCE_PERMS=0
+SKIP_INIT=0
+DBUSER="taler-wise-httpd"
+DBGROUP="taler-wise-db"
+CFGFILE="/etc/taler-wise/taler-wise.conf"
+
+# Parse command-line options
+while getopts 'c:g:hprsu:' OPTION; do
+ case "$OPTION" in
+ c)
+ CFGFILE="$OPTARG"
+ ;;
+ g)
+ DBGROUP="$OPTARG"
+ ;;
+ h)
+ echo 'Supported options:'
+ echo " -c FILENAME -- use configuration FILENAME (default: $CFGFILE)"
+ echo " -g GROUP -- taler-wise to be run by GROUP (default: $DBGROUP)"
+ echo " -h -- print this help text"
+ echo " -r -- reset database (dangerous)"
+ echo " -p -- force permission setup even without database initialization"
+ echo " -s -- skip database initialization"
+ echo " -u USER -- taler-wise to be run by USER (default: $DBUSER)"
+ exit 0
+ ;;
+ p)
+ FORCE_PERMS="1"
+ ;;
+ r)
+ RESET_DB="1"
+ ;;
+ s)
+ SKIP_INIT="1"
+ ;;
+ u)
+ DBUSER="$OPTARG"
+ ;;
+ ?)
+ echo "Unrecognized command line option '$OPTION'" 1 &>2
+ exit 1
+ ;;
+ esac
+done
+
+function exit_fail() {
+ echo "$@" >&2
+ exit 1
+}
+
+if ! id postgres >/dev/null; then
+ exit_fail "Could not find 'postgres' user. Please install Postgresql first"
+fi
+
+if ! taler-wise --version 2>/dev/null; then
+ exit_fail "Required 'taler-wise' not found. Please fix your installation."
+fi
+
+if [ "$(id -u)" -ne 0 ]; then
+ exit_fail "This script must be run as root"
+fi
+
+# Check OS users exist
+if ! id "$DBUSER" >/dev/null; then
+ exit_fail "Could not find '$DBUSER' user. Please set it up first"
+fi
+
+# Create DB user matching OS user name
+echo "Setting up database user '$DBUSER'." 1>&2
+if ! sudo -i -u postgres createuser "$DBUSER" 2>/dev/null; then
+ echo "Database user '$DBUSER' already existed. Continuing anyway." 1>&2
+fi
+
+# Check database name
+DBPATH=$(taler-wise -c "$CFGFILE" config get wisedb-postgres CONFIG)
+if ! echo "$DBPATH" | grep "postgres://" >/dev/null; then
+ exit_fail "Invalid database configuration value '$DBPATH'." 1>&2
+fi
+DBNAME=$(echo "$DBPATH" | sed -e "s/postgres:\/\/.*\///" -e "s/?.*//")
+
+# Reset database
+if sudo -i -u postgres psql "$DBNAME" </dev/null 2>/dev/null; then
+ if [ 1 = "$RESET_DB" ]; then
+ echo "Deleting existing database '$DBNAME'." 1>&2
+ if ! sudo -i -u postgres dropdb "$DBNAME"; then
+ exit_fail "Failed to delete existing database '$DBNAME'"
+ fi
+ DO_CREATE=1
+ else
+ echo "Database '$DBNAME' already exists, continuing anyway."
+ DO_CREATE=0
+ fi
+else
+ DO_CREATE=1
+fi
+
+# Create database
+if [ 1 = "$DO_CREATE" ]; then
+ echo "Creating database '$DBNAME'." 1>&2
+ if ! sudo -i -u postgres createdb -O "$DBUSER" "$DBNAME"; then
+ exit_fail "Failed to create database '$DBNAME'"
+ fi
+fi
+
+# Run dbinit
+if [ 0 = "$SKIP_INIT" ]; then
+ echo "Initialize database schema"
+ if ! sudo -u "$DBUSER" taler-wise dbinit -c "$CFGFILE"; then
+ exit_fail "Failed to initialize database schema"
+ fi
+fi
+
+# Set permission for group user
+if [ 0 = "$SKIP_INIT" ] || [ 1 = "$FORCE_PERMS" ]; then
+ # Create DB group matching OS group name
+ echo "Setting up database group '$DBGROUP'." 1>&2
+ if ! sudo -i -u postgres createuser "$DBGROUP" 2>/dev/null; then
+ echo "Database group '$DBGROUP' already existed. Continuing anyway." 1>&2
+ fi
+ if ! sudo -i -u postgres psql "$DBNAME" <<-EOF
+ GRANT ALL ON SCHEMA wise TO "$DBGROUP";
+ GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA wise TO "$DBGROUP";
+EOF
+ then
+ exit_fail "Failed to grant access to '$DBGROUP'."
+ fi
+
+ # Update group users rights
+ DB_GRP="$(getent group "$DBGROUP" | sed -e "s/.*://g" -e "s/,/ /g")"
+ echo "Initializing permissions for '$DB_GRP' users." 1>&2
+ for GROUPIE in $DB_GRP; do
+ if [ "$GROUPIE" != "$DBUSER" ]; then
+ if ! sudo -i -u postgres createuser "$GROUPIE" 2>/dev/null; then
+ echo "Database user '$GROUPIE' already existed. Continuing anyway." 1>&2
+ fi
+ fi
+
+ if ! echo "GRANT \"$DBGROUP\" TO \"$GROUPIE\"" |
+ sudo -i -u postgres psql "$DBNAME"; then
+ exit_fail "Failed to make '$GROUPIE' part of '$DBGROUP' db group."
+ fi
+ done
+fi
+
+echo "Database configuration finished." 1>&2
diff --git a/debian/changelog b/debian/changelog
@@ -1,3 +1,145 @@
+taler-rust (1.5.0-67-215aaaef) UNRELEASED; urgency=medium
+
+ [ Antoine A ]
+ * common: support new metadata field in wire gateway API v5
+ * WIP
+ * common: reuse databases for tests
+ * common: improve Base32 debug fmt
+ * common: add Wire Transfer Gateway logic and tests
+ * common: add Wire Transfer Gateway support
+ * common: reduce memory usage and allocations
+ * common: clean tests
+ * common: improve prepared transfer tests, fix bugs and bump API version
+ * common: improve routine tests
+ * common: more async closure
+ * common: rename Taler Wire Transfer Gateway to Taler Prepared Transfer and improve testing
+ * common: add new wire gateway test endpoint and improve routines
+ * apns-relay: init
+ * ci: test new component
+ * apns: fix deb
+ * common: optimize executable size and improve ci
+ * apns: improve worker logic
+ * common: fmt and clean code
+ * apns: fix config
+ * apns: fix config
+ * common: clean code
+ * common: fix APIs
+ * common: make more type copy
+ * common: make more types copy and add enum metadata macro
+ * build: improve build version logic
+ * common: constify amount
+ * common: add bench utils
+ * common: many small improvements
+ * common: fix and improve base32 and add base64 & hex
+ * common: typo
+ * common: many improvements
+ * taler-api: make all tests unit tests
+ * common: many small improvements
+ * common: improve testing and pg notifications
+ * common: improve test routine and db notification
+ * common: improve apis, test routines and error handling
+ * common: us mimalloc and clean code
+ * common: improve and optimize logging
+ * common: small fixes
+ * common: filter more noisy logs
+ * config: add encoded value
+ * common: update Prepared Transfer API and improve routines
+ * common: make test routine more flexible
+ * common: improve TLS configuration
+ * common: simplify dependencies
+ * common: clean code and improve logic
+ * common: improve config parser and add new cli & repl test utils
+ * common: improve cli
+ * common: improve bench logic
+ * common: mode adapters to their own directory
+ * common: improve config parsing and data encoding
+ * common: support ch_qrr
+ * common: all adapters must support kyc at least for testing
+ * test: simplify test repl
+ * test: repl right and left prompt
+ * test: improve repl
+ * test: improve repl
+ * common: improve config parsing error
+ * prepared-transfer-api: new signatures
+ * common: fix some API found bugs
+ * common: fix prepared transfer db procedure
+ * common: clippy fix
+ * common: add CORS rules
+ * common: bug fixes and improvements
+ * wise: add taler-wise adapter
+ * common: support new metadata field in wire gateway API v5
+ * WIP
+ * common: reuse databases for tests
+ * common: improve Base32 debug fmt
+ * common: add Wire Transfer Gateway logic and tests
+ * common: add Wire Transfer Gateway support
+ * common: reduce memory usage and allocations
+ * common: clean tests
+ * common: improve prepared transfer tests, fix bugs and bump API version
+ * common: improve routine tests
+ * common: more async closure
+ * common: rename Taler Wire Transfer Gateway to Taler Prepared Transfer and improve testing
+ * common: add new wire gateway test endpoint and improve routines
+ * apns-relay: init
+ * ci: test new component
+ * apns: fix deb
+ * common: optimize executable size and improve ci
+ * apns: improve worker logic
+ * common: fmt and clean code
+ * apns: fix config
+ * apns: fix config
+ * common: clean code
+ * common: fix APIs
+ * common: make more type copy
+ * common: make more types copy and add enum metadata macro
+ * build: improve build version logic
+ * common: constify amount
+ * common: add bench utils
+ * common: many small improvements
+ * common: fix and improve base32 and add base64 & hex
+ * common: typo
+ * common: many improvements
+ * taler-api: make all tests unit tests
+ * common: many small improvements
+ * common: improve testing and pg notifications
+ * common: improve test routine and db notification
+ * common: improve apis, test routines and error handling
+ * common: us mimalloc and clean code
+ * common: improve and optimize logging
+ * common: small fixes
+ * common: filter more noisy logs
+ * config: add encoded value
+ * common: update Prepared Transfer API and improve routines
+ * common: make test routine more flexible
+ * common: improve TLS configuration
+ * common: simplify dependencies
+ * common: clean code and improve logic
+ * common: improve config parser and add new cli & repl test utils
+ * common: improve cli
+ * common: improve bench logic
+ * common: mode adapters to their own directory
+ * common: improve config parsing and data encoding
+ * common: support ch_qrr
+ * common: all adapters must support kyc at least for testing
+ * test: simplify test repl
+ * test: repl right and left prompt
+ * test: improve repl
+ * test: improve repl
+ * common: improve config parsing error
+ * prepared-transfer-api: new signatures
+ * common: fix some API found bugs
+ * common: fix prepared transfer db procedure
+ * common: clippy fix
+ * common: add CORS rules
+ * common: bug fixes and improvements
+ * wise: add taler-wise adapter
+
+ [ Virgiel ]
+ * common: update dependencies
+ * common: update dependencies
+
+ -- Antoine A <none> Wed, 29 Jul 2026 09:49:53 +0000
+
taler-rust (1.5.0) unstable; urgency=low
* Release version 1.5.0
diff --git a/debian/control b/debian/control
@@ -27,6 +27,14 @@ Recommends:
postgresql (>= 14.0)
Description: GNU Taler adapter for Cyclos
+Package: taler-wise
+Architecture: any
+Depends: ${misc:Depends}, ${shlibs:Depends}
+Recommends:
+ nginx | apache2 | httpd,
+ postgresql (>= 14.0)
+Description: GNU Taler adapter for Wise
+
Package: taler-apns-relay
Architecture: any
Depends: ${misc:Depends}, ${shlibs:Depends}
diff --git a/debian/etc/apache2/sites-available/taler-wise.conf b/debian/etc/apache2/sites-available/taler-wise.conf
@@ -0,0 +1,22 @@
+# Make sure to enable the following Apache modules before
+# integrating this into your configuration:
+#
+# a2enmod proxy
+# a2enmod proxy_http
+# a2enmod headers
+#
+# NOTE:
+# - consider to adjust the location
+# - consider putting all this into a VirtualHost
+# - strongly consider setting up TLS support
+#
+# For all of the above, please read the respective
+# Apache documentation.
+#
+<Location "/taler-wise/">
+ ProxyPass "unix:/var/run/taler-wise/httpd/wise-http.sock|http://example.com/"
+
+ # NOTE:
+ # - Uncomment this line if you use TLS/HTTPS
+ RequestHeader add "X-Forwarded-Proto" "https"
+</Location>
diff --git a/debian/etc/nginx/sites-available/taler-wise b/debian/etc/nginx/sites-available/taler-wise
@@ -0,0 +1,31 @@
+server {
+ # NOTE:
+ # - urgently consider configuring TLS instead
+ # - maybe keep a forwarder from HTTP to HTTPS
+ listen 80;
+
+ # NOTE:
+ # - Comment out this line if you have no IPv6
+ listen [::]:80;
+
+ # NOTE:
+ # - replace with your actual server name
+ server_name localhost;
+
+ access_log /var/log/nginx/taler-wise.log;
+ error_log /var/log/nginx/taler-wise.err;
+
+ location /taler-wise/ {
+ proxy_pass http://unix:/var/run/taler-wise/httpd/wise-http.sock:/;
+ proxy_redirect off;
+ proxy_set_header Host $host;
+
+ # NOTE:
+ # - put your actual DNS name here
+ proxy_set_header X-Forwarded-Host "localhost";
+
+ # NOTE:
+ # - uncomment the following line if you are using HTTPS
+ # proxy_set_header X-Forwarded-Proto "https";
+ }
+}
+\ No newline at end of file
diff --git a/debian/etc/taler-wise/conf.d/wise-httpd.conf b/debian/etc/taler-wise/conf.d/wise-httpd.conf
@@ -0,0 +1,12 @@
+# Configuration the wise adapter worker REST API.
+
+[wise-httpd]
+SERVE = systemd
+
+[wise-httpd-wire-gateway-api]
+# ENABLED = YES
+@inline-secret@ wise-httpd-wire-gateway-api ../secrets/wise-httpd.secret.conf
+
+[wise-httpd-revenue-api]
+# ENABLED = YES
+@inline-secret@ wise-httpd-revenue-api ../secrets/wise-httpd.secret.conf
diff --git a/debian/etc/taler-wise/conf.d/wise-system.conf b/debian/etc/taler-wise/conf.d/wise-system.conf
@@ -0,0 +1,5 @@
+# Configuration for system aspects of the wise adapter.
+
+# Read secret sections into configuration, but only
+# if we have permission to do so.
+@inline-secret@ wisedb-postgres ../secrets/wise-db.secret.conf
diff --git a/debian/etc/taler-wise/conf.d/wise-worker.conf b/debian/etc/taler-wise/conf.d/wise-worker.conf
@@ -0,0 +1,26 @@
+# Configuration the wise adapter worker.
+
+[wise-worker]
+# Adapter Wise profile ID
+PROFILE_ID =
+
+# How often should worker run when no notification is received
+# FREQUENCY =
+
+@inline-secret@ wise-worker ../secrets/wise-worker.secret.conf
+
+# [wise-balance-eur]
+# Balance Wise ID
+# ID = 139524631
+
+# Balance currency
+# CURRENCY = EUR
+
+# Balance account type, this can only be iban
+# PAYTO_TYPE = iban
+
+# Balance IBAN? Only used if PAYTO_TYPE is iban.
+# IBAN = GB82WEST12345698765432
+
+# Balance BIC? Only used if PAYTO_TYPE is bic.
+# BIC = TRWIBEB1XXX
diff --git a/debian/etc/taler-wise/overrides.conf b/debian/etc/taler-wise/overrides.conf
@@ -0,0 +1 @@
+# This configuration will be changed by tooling. Do not touch it manually.
diff --git a/debian/etc/taler-wise/secrets/wise-db.secret.conf b/debian/etc/taler-wise/secrets/wise-db.secret.conf
@@ -0,0 +1,8 @@
+[wisedb-postgres]
+
+# Typically, there should only be a single line here, of the form:
+
+CONFIG=postgres:///taler-wise
+
+# The details of the URI depend on where the database lives and how
+# access control was configured.
diff --git a/debian/etc/taler-wise/secrets/wise-httpd.secret.conf b/debian/etc/taler-wise/secrets/wise-httpd.secret.conf
@@ -0,0 +1,7 @@
+[wise-httpd-wire-gateway-api]
+# AUTH_METHOD = bearer
+# TOKEN =
+
+[wise-httpd-revenue-api]
+# AUTH_METHOD = bearer
+# TOKEN =
diff --git a/debian/etc/taler-wise/secrets/wise-worker.secret.conf b/debian/etc/taler-wise/secrets/wise-worker.secret.conf
@@ -0,0 +1,4 @@
+[wise-worker]
+
+# Account token
+TOKEN =
+\ No newline at end of file
diff --git a/debian/etc/taler-wise/taler-wise.conf b/debian/etc/taler-wise/taler-wise.conf
@@ -0,0 +1,37 @@
+# Main entry point for the Taler Wise Adapter configuration.
+#
+# Structure:
+# - taler-wise.conf is the main configuration entry point
+# used by all Taler Wise Adapter components (the file you are currently
+# looking at.
+# - overrides.conf contains configuration overrides that are
+# set by some tools that help with the configuration,
+# and should not be edited by humans. Comments in this file
+# are not preserved.
+# - conf.d/ contains configuration files for
+# Taler Wise Adapter components, which can be read by all
+# users of the system and are included by the main
+# configuration.
+# - secrets/ contains configuration snippets
+# with secrets for particular services.
+# These files should have restrictive permissions
+# so that only users of the relevant services
+# can read it. All files in it should end with
+# ".secret.conf".
+
+[wise]
+# Legal entity that is associated with the Wise account
+NAME =
+
+# Inline configurations from all Taler Wise Adapter components.
+@inline-matching@ conf.d/*.conf
+
+# Overrides from tools that help with configuration.
+@inline@ overrides.conf
+
+
+[paths]
+
+# Paths for the system-wide installation of the Taler Wise Adapter. Do not remove
+# or change these unless you are very sure of what you are doing.
+
diff --git a/debian/rules b/debian/rules
@@ -7,7 +7,7 @@ override_dh_auto_configure:
rustup default stable
override_dh_auto_build:
- cargo build --release --bin taler-magnet-bank --bin taler-cyclos --bin taler-apns-relay
+ make build
override_dh_auto_test:
true
@@ -22,6 +22,9 @@ override_dh_installsystemd:
dh_installsystemd --no-enable --no-start --no-stop-on-upgrade --name taler-cyclos-httpd
dh_installsystemd --no-enable --no-start --no-stop-on-upgrade --name taler-cyclos-worker
dh_installsystemd --no-enable --no-start --no-stop-on-upgrade --name taler-cyclos
+ dh_installsystemd --no-enable --no-start --no-stop-on-upgrade --name taler-wise-httpd
+ dh_installsystemd --no-enable --no-start --no-stop-on-upgrade --name taler-wise-worker
+ dh_installsystemd --no-enable --no-start --no-stop-on-upgrade --name taler-wise
dh_installsystemd --no-enable --no-start --no-stop-on-upgrade --name taler-apns-relay-httpd
dh_installsystemd --no-enable --no-start --no-stop-on-upgrade --name taler-apns-relay-worker
dh_installsystemd --no-enable --no-start --no-stop-on-upgrade --name taler-apns-relay
diff --git a/debian/taler-wise.conf b/debian/taler-wise.conf
@@ -0,0 +1,8 @@
+# Create services users
+u taler-wise-worker - "Taler Wise Adapter worker" /var/lib/taler-wise
+u taler-wise-httpd - "Taler Wise Adapter server" /var/lib/taler-wise
+
+# Create DB access group
+g taler-wise-db -
+m taler-wise-worker taler-wise-db
+m taler-wise-httpd taler-wise-db
+\ No newline at end of file
diff --git a/debian/taler-wise.install b/debian/taler-wise.install
@@ -0,0 +1,16 @@
+debian/etc/taler-wise etc/
+debian/etc/nginx/sites-available/taler-wise etc/nginx/sites-available/
+debian/etc/apache2/sites-available/taler-wise.conf etc/apache2/sites-available/
+
+target/release/taler-wise /usr/bin
+contrib/taler-wise-dbconfig /usr/bin
+
+common/taler-common/db/versioning.sql /usr/share/taler-wise/sql/
+adapters/taler-wise/db/wise*.sql /usr/share/taler-wise/sql/
+
+adapters/taler-wise/wise.conf /usr/share/taler-wise/config.d/
+
+doc/prebuilt/man/taler-wise.1 /usr/share/man/man1/
+doc/prebuilt/man/taler-wise.conf.5 /usr/share/man/man5/
+
+debian/taler-wise.conf /usr/lib/sysusers.d/
+\ No newline at end of file
diff --git a/debian/taler-wise.postinst b/debian/taler-wise.postinst
@@ -0,0 +1,13 @@
+#!/bin/sh
+
+set -e
+
+if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then
+ if [ -x "$(command -v systemd-sysusers)" ]; then
+ systemd-sysusers
+ fi
+fi
+
+#DEBHELPER#
+
+exit 0
+\ No newline at end of file
diff --git a/debian/taler-wise.taler-wise-httpd.service b/debian/taler-wise.taler-wise-httpd.service
@@ -0,0 +1,41 @@
+[Unit]
+Description=GNU Taler Wise adapter REST API
+Requires=taler-wise-httpd.socket
+After=network.target postgres.service
+PartOf=taler-wise.target
+
+[Service]
+User=taler-wise-httpd
+Type=exec
+
+Restart=always
+RestartMode=direct
+RestartSec=1ms
+RestartPreventExitStatus=9
+
+StartLimitBurst=5
+StartLimitInterval=5s
+
+ExecStart=/usr/bin/taler-wise serve -c /etc/taler-wise/taler-wise.conf
+ExecCondition=/usr/bin/taler-wise serve -c /etc/taler-wise/taler-wise.conf --check
+
+StandardOutput=journal
+StandardError=journal
+
+PrivateTmp=yes
+ProtectSystem=full
+ProtectHome=yes
+ProtectClock=yes
+ProtectHostname=yes
+ProtectControlGroups=yes
+ProtectKernelLogs=yes
+ProtectKernelModules=yes
+ProtectKernelTunables=yes
+ProtectProc=invisible
+PrivateDevices=yes
+NoNewPrivileges=yes
+
+Slice=taler-wise.slice
+
+[Install]
+WantedBy=multi-user.target
diff --git a/debian/taler-wise.taler-wise-httpd.socket b/debian/taler-wise.taler-wise-httpd.socket
@@ -0,0 +1,14 @@
+[Unit]
+Description=GNU Taler Wise adapter socket
+PartOf=taler-wise-httpd.service
+
+[Socket]
+ListenStream=/run/taler-wise/httpd/wise-http.sock
+Accept=no
+Service=taler-wise-httpd.service
+SocketUser=taler-wise-httpd
+SocketGroup=www-data
+SocketMode=0660
+
+[Install]
+WantedBy=sockets.target
+\ No newline at end of file
diff --git a/debian/taler-wise.taler-wise-worker.service b/debian/taler-wise.taler-wise-worker.service
@@ -0,0 +1,39 @@
+[Unit]
+Description=GNU Taler Wise adapter worker
+After=network.target postgres.service
+PartOf=taler-wise.target
+
+[Service]
+User=taler-wise-worker
+Type=exec
+
+Restart=always
+RestartMode=direct
+RestartSec=1ms
+RestartPreventExitStatus=9
+
+StartLimitBurst=5
+StartLimitInterval=5s
+
+ExecStart=/usr/bin/taler-wise worker -c /etc/taler-wise/taler-wise.conf
+
+StandardOutput=journal
+StandardError=journal
+
+PrivateTmp=yes
+ProtectSystem=full
+ProtectHome=yes
+ProtectClock=yes
+ProtectHostname=yes
+ProtectControlGroups=yes
+ProtectKernelLogs=yes
+ProtectKernelModules=yes
+ProtectKernelTunables=yes
+ProtectProc=invisible
+PrivateDevices=yes
+NoNewPrivileges=yes
+
+Slice=taler-wise.slice
+
+[Install]
+WantedBy=multi-user.target
diff --git a/debian/taler-wise.taler-wise.slice b/debian/taler-wise.taler-wise.slice
@@ -0,0 +1,3 @@
+[Unit]
+Description=Slice for GNU Taler Wise adapter processes
+Before=slices.target
+\ No newline at end of file
diff --git a/debian/taler-wise.taler-wise.target b/debian/taler-wise.taler-wise.target
@@ -0,0 +1,9 @@
+[Unit]
+Description=GNU Taler Wise adapter
+After=postgres.service network.target
+
+Wants=taler-wise-httpd.service
+Wants=taler-wise-worker.service
+
+[Install]
+WantedBy=multi-user.target
+\ No newline at end of file
diff --git a/debian/taler-wise.tmpfiles b/debian/taler-wise.tmpfiles
@@ -0,0 +1,7 @@
+# Create home directory
+d$ /var/lib/taler-wise 0700 taler-wise-worker taler-wise-worker - -
+
+# Update secret files permissions
+z /etc/taler-wise/secrets/wise-db.secret.conf 0460 root taler-wise-db - -
+z /etc/taler-wise/secrets/wise-httpd.secret.conf 0640 taler-wise-httpd root - -
+z /etc/taler-wise/secrets/wise-worker.secret.conf 0640 taler-wise-worker root - -