taler-rust

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

commit 4071009a46ea4d0b9f1ed418fe27d4037b3a1254
parent 0b262a884fa9afaf4b718cf7b841f0fa0a9dc62f
Author: Antoine A <>
Date:   Tue, 28 Jul 2026 18:57:39 +0200

wise: add taler-wise adapter

Diffstat:
M.gitignore | 6++++--
MCargo.toml | 2+-
MMakefile | 9++++++++-
Madapters/taler-cyclos/Cargo.toml | 2+-
Madapters/taler-cyclos/db/cyclos-procedures.sql | 12++++++------
Madapters/taler-magnet-bank/Cargo.toml | 2+-
Madapters/taler-magnet-bank/src/lib.rs | 8++++----
Aadapters/taler-wise/Cargo.toml | 31+++++++++++++++++++++++++++++++
Aadapters/taler-wise/db/wise-0001.sql | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aadapters/taler-wise/db/wise-drop.sql | 30++++++++++++++++++++++++++++++
Aadapters/taler-wise/db/wise-procedures.sql | 268+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aadapters/taler-wise/src/api.rs | 463+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aadapters/taler-wise/src/config.rs | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aadapters/taler-wise/src/db.rs | 486+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aadapters/taler-wise/src/lib.rs | 27+++++++++++++++++++++++++++
Aadapters/taler-wise/src/main.rs | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aadapters/taler-wise/src/payto.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aadapters/taler-wise/src/setup.rs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aadapters/taler-wise/src/wise_api/client.rs | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aadapters/taler-wise/src/wise_api/mod.rs | 18++++++++++++++++++
Aadapters/taler-wise/src/wise_api/types.rs | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aadapters/taler-wise/src/worker.rs | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aadapters/taler-wise/wise.conf | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/taler-api/db/taler-api-0001.sql | 4++--
Mcommon/taler-api/db/taler-api-procedures.sql | 3+--
Mcommon/taler-common/src/log.rs | 3++-
Mcommon/taler-common/src/types.rs | 3++-
Acommon/taler-common/src/types/ach.rs | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/taler-common/src/types/amount.rs | 3+++
Mcommon/taler-common/src/types/iban.rs | 91+++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mcommon/taler-common/src/types/payto.rs | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcommon/taler-common/src/types/utils.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcontrib/ci/ci.sh | 2+-
Mcontrib/ci/deb-test.sh | 8++++++--
Acontrib/taler-wise-dbconfig | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdebian/changelog | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdebian/control | 8++++++++
Adebian/etc/apache2/sites-available/taler-wise.conf | 22++++++++++++++++++++++
Adebian/etc/nginx/sites-available/taler-wise | 32++++++++++++++++++++++++++++++++
Adebian/etc/taler-wise/conf.d/wise-httpd.conf | 12++++++++++++
Adebian/etc/taler-wise/conf.d/wise-system.conf | 5+++++
Adebian/etc/taler-wise/conf.d/wise-worker.conf | 26++++++++++++++++++++++++++
Adebian/etc/taler-wise/overrides.conf | 1+
Adebian/etc/taler-wise/secrets/wise-db.secret.conf | 8++++++++
Adebian/etc/taler-wise/secrets/wise-httpd.secret.conf | 7+++++++
Adebian/etc/taler-wise/secrets/wise-worker.secret.conf | 5+++++
Adebian/etc/taler-wise/taler-wise.conf | 37+++++++++++++++++++++++++++++++++++++
Mdebian/rules | 5++++-
Adebian/taler-wise.conf | 9+++++++++
Adebian/taler-wise.install | 17+++++++++++++++++
Adebian/taler-wise.postinst | 14++++++++++++++
Adebian/taler-wise.taler-wise-httpd.service | 41+++++++++++++++++++++++++++++++++++++++++
Adebian/taler-wise.taler-wise-httpd.socket | 15+++++++++++++++
Adebian/taler-wise.taler-wise-worker.service | 39+++++++++++++++++++++++++++++++++++++++
Adebian/taler-wise.taler-wise.slice | 4++++
Adebian/taler-wise.taler-wise.target | 10++++++++++
Adebian/taler-wise.tmpfiles | 7+++++++
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, + &params, + || 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, + &params, + || 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 - -