taler-rust

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

cyclos-procedures.sql (14399B)


      1 --
      2 -- This file is part of TALER
      3 -- Copyright (C) 2025-2026 Taler Systems SA
      4 --
      5 -- TALER is free software; you can redistribute it and/or modify it under the
      6 -- terms of the GNU General Public License as published by the Free Software
      7 -- Foundation; either version 3, or (at your option) any later version.
      8 --
      9 -- TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     10 -- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     11 -- A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
     12 --
     13 -- You should have received a copy of the GNU General Public License along with
     14 -- TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     15 
     16 SET search_path TO cyclos;
     17 
     18 -- Remove all existing functions
     19 DO
     20 $do$
     21 DECLARE
     22   _sql text;
     23 BEGIN
     24   SELECT INTO _sql
     25         string_agg(format('DROP %s %s CASCADE;'
     26                         , CASE prokind
     27                             WHEN 'f' THEN 'FUNCTION'
     28                             WHEN 'p' THEN 'PROCEDURE'
     29                           END
     30                         , oid::regprocedure)
     31                   , E'\n')
     32   FROM   pg_proc
     33   WHERE  pronamespace = 'cyclos'::regnamespace;
     34 
     35   IF _sql IS NOT NULL THEN
     36     EXECUTE _sql;
     37   END IF;
     38 END
     39 $do$;
     40 
     41 CREATE FUNCTION register_tx_in(
     42   IN in_transfer_id INT8,
     43   IN in_tx_id INT8,
     44   IN in_amount taler_amount,
     45   IN in_subject TEXT,
     46   IN in_debit_account INT8,
     47   IN in_debit_name TEXT,
     48   IN in_valued_at INT8,
     49   IN in_type incoming_type,
     50   IN in_metadata BYTEA,
     51   IN in_now INT8,
     52   -- Error status
     53   OUT out_reserve_pub_reuse BOOLEAN,
     54   OUT out_mapping_reuse BOOLEAN,
     55   OUT out_unknown_mapping BOOLEAN,
     56   -- Success return
     57   OUT out_tx_row_id INT8,
     58   OUT out_valued_at INT8,
     59   OUT out_new BOOLEAN,
     60   OUT out_pending BOOLEAN
     61 )
     62 LANGUAGE plpgsql AS $$
     63 DECLARE
     64 local_authorization_pub BYTEA;
     65 local_authorization_sig BYTEA;
     66 local_taler_in_id INT8;
     67 BEGIN
     68 out_pending=false;
     69 -- Check for idempotence
     70 SELECT tx_in_id, valued_at
     71 INTO out_tx_row_id, out_valued_at
     72 FROM tx_in
     73 WHERE transfer_id = in_transfer_id;
     74 out_new = NOT found;
     75 IF NOT out_new THEN
     76   RETURN;
     77 END IF;
     78 
     79 -- Resolve mapping logic
     80 IF in_type = 'map' THEN
     81   SELECT type, account_pub, authorization_pub, authorization_sig,
     82       tx_in_id IS NOT NULL AND NOT recurrent,
     83       tx_in_id IS NOT NULL AND recurrent
     84     INTO in_type, in_metadata, local_authorization_pub, local_authorization_sig, out_mapping_reuse, out_pending
     85     FROM prepared_in
     86     WHERE authorization_pub = in_metadata;
     87   out_unknown_mapping = NOT FOUND;
     88   IF out_unknown_mapping OR out_mapping_reuse THEN
     89     RETURN;
     90   END IF;
     91 END IF;
     92 
     93 
     94 -- Check conflict
     95 out_reserve_pub_reuse=NOT out_pending AND in_type = 'reserve' AND EXISTS(SELECT FROM taler_in WHERE metadata = in_metadata AND type = 'reserve');
     96 IF out_reserve_pub_reuse THEN
     97   RETURN;
     98 END IF;
     99 
    100 -- Insert new incoming transaction
    101 out_valued_at = in_valued_at;
    102 -- Lock for revenue id commit ordering
    103 LOCK TABLE tx_in IN SHARE ROW EXCLUSIVE MODE;
    104 INSERT INTO tx_in (
    105   transfer_id,
    106   tx_id,
    107   amount,
    108   subject,
    109   debit_account,
    110   debit_name,
    111   valued_at,
    112   registered_at
    113 ) VALUES (
    114   in_transfer_id,
    115   in_tx_id,
    116   in_amount,
    117   in_subject,
    118   in_debit_account,
    119   in_debit_name,
    120   in_valued_at,
    121   in_now
    122 )
    123 RETURNING tx_in_id INTO out_tx_row_id;
    124 -- Notify new incoming transaction registration
    125 PERFORM pg_notify('tx_in', out_tx_row_id || '');
    126 
    127 IF out_pending THEN
    128   -- Delay talerable registration until mapping again
    129   INSERT INTO pending_recurrent_in (tx_in_id, authorization_pub)
    130     VALUES (out_tx_row_id, local_authorization_pub);
    131 ELSIF in_type IS NOT NULL THEN
    132   UPDATE prepared_in
    133   SET tx_in_id = out_tx_row_id
    134   WHERE (
    135     tx_in_id IS NULL AND account_pub = in_metadata AND in_type=type AND type='reserve'
    136   ) OR authorization_pub = local_authorization_pub;
    137   -- Lock for incoming history id commit ordering
    138   LOCK TABLE taler_in IN SHARE ROW EXCLUSIVE MODE;
    139   -- Insert new incoming talerable transaction
    140   INSERT INTO taler_in (
    141     tx_in_id,
    142     type,
    143     metadata,
    144     authorization_pub,
    145     authorization_sig
    146   ) VALUES (
    147     out_tx_row_id,
    148     in_type,
    149     in_metadata,
    150     local_authorization_pub,
    151     local_authorization_sig
    152   ) RETURNING taler_in_id INTO local_taler_in_id;
    153   -- Notify new incoming talerable transaction registration
    154   PERFORM pg_notify('taler_in', local_taler_in_id::text);
    155 END IF;
    156 END $$;
    157 COMMENT ON FUNCTION register_tx_in IS 'Register an incoming transaction idempotently';
    158 
    159 CREATE FUNCTION register_tx_out(
    160   IN in_transfer_id INT8,
    161   IN in_tx_id INT8,
    162   IN in_amount taler_amount,
    163   IN in_subject TEXT,
    164   IN in_credit_account INT8,
    165   IN in_credit_name TEXT,
    166   IN in_valued_at INT8,
    167   IN in_wtid BYTEA,
    168   IN in_origin_exchange_url TEXT,
    169   IN in_metadata TEXT,
    170   IN in_bounced INT8,
    171   IN in_now INT8,
    172   -- Success return
    173   OUT out_tx_row_id INT8,
    174   OUT out_result register_result
    175 )
    176 LANGUAGE plpgsql AS $$
    177 BEGIN
    178 -- Check for idempotence
    179 SELECT tx_out_id INTO out_tx_row_id
    180 FROM tx_out WHERE transfer_id = in_transfer_id;
    181 
    182 IF FOUND THEN
    183   out_result = 'idempotent';
    184   RETURN;
    185 END IF;
    186 
    187 -- Lock for revenue and incoming history id commit ordering
    188 LOCK TABLE tx_out IN SHARE ROW EXCLUSIVE MODE;
    189 -- Insert new outgoing transaction
    190 INSERT INTO tx_out (
    191   transfer_id,
    192   tx_id,
    193   amount,
    194   subject,
    195   credit_account,
    196   credit_name,
    197   valued_at,
    198   registered_at
    199 ) VALUES (
    200   in_transfer_id,
    201   in_tx_id,
    202   in_amount,
    203   in_subject,
    204   in_credit_account,
    205   in_credit_name,
    206   in_valued_at,
    207   in_now
    208 )
    209 RETURNING tx_out_id INTO out_tx_row_id;
    210 -- Notify new outgoing transaction registration
    211 PERFORM pg_notify('tx_out', out_tx_row_id || '');
    212 
    213 -- Update initiated status
    214 UPDATE initiated
    215 SET
    216   tx_out_id = out_tx_row_id,
    217   status = 'success',
    218   status_msg = NULL
    219 WHERE tx_id = in_tx_id;
    220 IF FOUND OR EXISTS(SELECT FROM bounced WHERE chargeback_id = in_transfer_id) THEN
    221   out_result = 'known';
    222 ELSE
    223   -- Make it idempotent using wtid matching TODO find a idempotent way with cyclos API
    224   UPDATE initiated
    225   SET
    226     tx_out_id = out_tx_row_id,
    227     status = 'success',
    228     status_msg = NULL
    229   FROM transfer
    230   WHERE transfer.initiated_id = initiated.initiated_id AND wtid = in_wtid;
    231   out_result = 'recovered';
    232 END IF;
    233 
    234 IF in_wtid IS NOT NULL THEN
    235   -- Insert new outgoing talerable transaction
    236   INSERT INTO taler_out (
    237     tx_out_id,
    238     wtid,
    239     exchange_base_url,
    240     metadata
    241   ) VALUES (
    242     out_tx_row_id,
    243     in_wtid,
    244     in_origin_exchange_url,
    245     in_metadata
    246   ) ON CONFLICT (wtid) DO NOTHING;
    247   IF FOUND THEN
    248     -- Notify new outgoing talerable transaction registration
    249     PERFORM pg_notify('taler_out', out_tx_row_id || '');
    250   END IF;
    251 ELSIF in_bounced IS NOT NULL THEN
    252   -- TODO Reconstruct bounces ?
    253 END IF;
    254 END $$;
    255 COMMENT ON FUNCTION register_tx_out IS 'Register an outgoing transaction idempotently';
    256 
    257 CREATE FUNCTION register_charge_back_failure(
    258   IN in_transfer_id INT8,
    259   -- Success return
    260   OUT out_initiated_id INT8,
    261   OUT out_new BOOLEAN
    262 )
    263 LANGUAGE plpgsql AS $$
    264 DECLARE
    265 current_status transfer_status;
    266 BEGIN
    267 -- Found existing initiated transaction
    268 SELECT status, initiated_id
    269 INTO current_status, out_initiated_id
    270 FROM initiated
    271 JOIN tx_out USING (tx_out_id)
    272 WHERE transfer_id = in_transfer_id;
    273 IF NOT FOUND THEN
    274   out_initiated_id=0;
    275   RETURN;
    276 END IF;
    277 
    278 -- Update status if new
    279 out_new = current_status != 'late_failure';
    280 IF out_new THEN
    281   UPDATE initiated
    282   SET
    283     status = 'late_failure',
    284     status_msg = 'charged back'
    285   WHERE initiated_id = out_initiated_id;
    286 END IF;
    287 END $$;
    288 COMMENT ON FUNCTION register_charge_back_failure IS 'Register an outgoing transaction chargeback idempotently';
    289 
    290 CREATE FUNCTION taler_transfer(
    291   IN in_request_uid BYTEA,
    292   IN in_wtid BYTEA,
    293   IN in_subject TEXT,
    294   IN in_amount taler_amount,
    295   IN in_exchange_base_url TEXT,
    296   IN in_metadata TEXT,
    297   IN in_credit_account INT8,
    298   IN in_credit_name TEXT,
    299   IN in_now INT8,
    300   -- Error return
    301   OUT out_request_uid_reuse BOOLEAN,
    302   OUT out_wtid_reuse BOOLEAN,
    303   -- Success return
    304   OUT out_initiated_row_id INT8,
    305   OUT out_initiated_at INT8
    306 )
    307 LANGUAGE plpgsql AS $$
    308 BEGIN
    309 -- Check for idempotence and conflict
    310 SELECT (amount != in_amount
    311           OR credit_account != in_credit_account
    312           OR exchange_base_url != in_exchange_base_url
    313           OR wtid != in_wtid
    314           OR metadata IS DISTINCT FROM in_metadata)
    315         ,initiated_id, initiated_at
    316 INTO out_request_uid_reuse, out_initiated_row_id, out_initiated_at
    317 FROM transfer JOIN initiated USING (initiated_id)
    318 WHERE request_uid = in_request_uid;
    319 IF FOUND THEN
    320   RETURN;
    321 END IF;
    322 -- Check for wtid reuse
    323 out_wtid_reuse = EXISTS(SELECT FROM transfer WHERE wtid=in_wtid);
    324 IF out_wtid_reuse THEN
    325   RETURN;
    326 END IF;
    327 -- Insert an initiated outgoing transaction
    328 out_initiated_at = in_now;
    329 LOCK TABLE transfer IN SHARE ROW EXCLUSIVE MODE;
    330 INSERT INTO initiated (
    331   amount,
    332   subject,
    333   credit_account,
    334   credit_name,
    335   initiated_at
    336 ) VALUES (
    337   in_amount,
    338   in_subject,
    339   in_credit_account,
    340   in_credit_name,
    341   in_now
    342 ) RETURNING initiated_id
    343 INTO out_initiated_row_id;
    344 -- Insert a transfer operation
    345 INSERT INTO transfer (
    346   initiated_id,
    347   request_uid,
    348   wtid,
    349   exchange_base_url,
    350   metadata
    351 ) VALUES (
    352   out_initiated_row_id,
    353   in_request_uid,
    354   in_wtid,
    355   in_exchange_base_url,
    356   in_metadata
    357 );
    358 PERFORM pg_notify('transfer', out_initiated_row_id || '');
    359 END $$;
    360 
    361 CREATE FUNCTION initiated_status_update(
    362   IN in_initiated_id INT8,
    363   IN in_status transfer_status,
    364   IN in_status_msg TEXT
    365 )
    366 RETURNS void
    367 LANGUAGE plpgsql AS $$
    368 DECLARE
    369 current_status transfer_status;
    370 BEGIN
    371   -- Check current status
    372   SELECT status INTO current_status FROM initiated
    373     WHERE initiated_id = in_initiated_id;
    374   IF FOUND THEN
    375     -- Update unsettled transaction status
    376     IF current_status = 'success' AND in_status = 'permanent_failure' THEN
    377       UPDATE initiated
    378       SET status = 'late_failure', status_msg = in_status_msg
    379       WHERE initiated_id = in_initiated_id;
    380     ELSIF current_status NOT IN ('success', 'permanent_failure', 'late_failure') THEN
    381       UPDATE initiated
    382       SET status = in_status, status_msg = in_status_msg
    383       WHERE initiated_id = in_initiated_id;
    384     END IF;
    385   END IF;
    386 END $$;
    387 
    388 CREATE FUNCTION register_bounced_tx_in(
    389   IN in_transfer_id INT8,
    390   IN in_tx_id INT8,
    391   IN in_amount taler_amount,
    392   IN in_subject TEXT,
    393   IN in_debit_account INT8,
    394   IN in_debit_name TEXT,
    395   IN in_valued_at INT8,
    396   IN in_chargeback_id INT8,
    397   IN in_reason TEXT,
    398   IN in_now INT8,
    399   -- Success return
    400   OUT out_tx_row_id INT8,
    401   OUT out_tx_new BOOLEAN
    402 )
    403 LANGUAGE plpgsql AS $$
    404 BEGIN
    405 -- Register incoming transaction idempotently
    406 SELECT register_tx_in.out_tx_row_id, register_tx_in.out_new
    407 INTO out_tx_row_id, out_tx_new
    408 FROM register_tx_in(in_transfer_id, in_tx_id, in_amount, in_subject, in_debit_account, in_debit_name, in_valued_at, NULL, NULL, in_now);
    409 
    410 -- Register the bounce
    411 INSERT INTO bounced (
    412   tx_in_id,
    413   chargeback_id,
    414   reason
    415 ) VALUES (
    416   out_tx_row_id,
    417   in_chargeback_id,
    418   in_reason
    419 ) ON CONFLICT (chargeback_id) DO NOTHING;
    420 END $$;
    421 COMMENT ON FUNCTION register_bounced_tx_in IS 'Register a bounced incoming transaction idempotently';
    422 
    423 CREATE FUNCTION register_prepared_transfers (
    424   IN in_type incoming_type,
    425   IN in_account_pub BYTEA,
    426   IN in_authorization_pub BYTEA,
    427   IN in_authorization_sig BYTEA,
    428   IN in_recurrent BOOLEAN,
    429   IN in_timestamp INT8,
    430   -- Error status
    431   OUT out_reserve_pub_reuse BOOLEAN
    432 )
    433 LANGUAGE plpgsql AS $$
    434 DECLARE
    435   talerable_tx INT8;
    436   local_taler_in_id INT8;
    437   idempotent BOOLEAN;
    438 BEGIN
    439 
    440 -- Check idempotency
    441 SELECT type = in_type
    442     AND account_pub = in_account_pub
    443     AND recurrent = in_recurrent
    444 INTO idempotent
    445 FROM prepared_in
    446 WHERE authorization_pub = in_authorization_pub;
    447 
    448 -- Check idempotency and delay garbage collection
    449 IF FOUND AND idempotent THEN
    450   UPDATE prepared_in
    451   SET registered_at=in_timestamp,authorization_sig=in_authorization_sig
    452   WHERE authorization_pub=in_authorization_pub;
    453   RETURN;
    454 END IF;
    455 
    456 -- Check reserve pub reuse
    457 out_reserve_pub_reuse=in_type = 'reserve' AND (
    458   EXISTS(SELECT FROM taler_in WHERE metadata = in_account_pub AND type = 'reserve')
    459   OR EXISTS(SELECT FROM prepared_in WHERE account_pub = in_account_pub AND type = 'reserve' AND authorization_pub != in_authorization_pub)
    460 );
    461 IF out_reserve_pub_reuse THEN
    462   RETURN;
    463 END IF;
    464 
    465 IF in_recurrent THEN
    466   -- Finalize one pending right now. Lock history only if there is a row to publish.
    467   DELETE FROM pending_recurrent_in
    468   WHERE tx_in_id = (
    469     SELECT tx_in_id
    470     FROM pending_recurrent_in
    471     JOIN tx_in USING (tx_in_id)
    472     WHERE authorization_pub = in_authorization_pub
    473     ORDER BY registered_at ASC
    474     LIMIT 1
    475   )
    476   RETURNING tx_in_id INTO talerable_tx;
    477   IF talerable_tx IS NOT NULL THEN
    478     LOCK TABLE taler_in IN SHARE ROW EXCLUSIVE MODE;
    479     INSERT INTO taler_in (tx_in_id, type, metadata, authorization_pub, authorization_sig)
    480     VALUES (talerable_tx, in_type, in_account_pub, in_authorization_pub, in_authorization_sig)
    481     RETURNING taler_in_id INTO local_taler_in_id;
    482     PERFORM pg_notify('taler_in', local_taler_in_id::text);
    483   END IF;
    484 ELSE
    485   -- Bounce all pending
    486   WITH pending AS (
    487     DELETE FROM pending_recurrent_in
    488     WHERE authorization_pub = in_authorization_pub
    489     RETURNING tx_in_id
    490   )
    491   INSERT INTO bounced (
    492     tx_in_id,
    493     chargeback_id,
    494     reason
    495   )
    496   SELECT
    497     tx_in_id,
    498     NULL,
    499     'cancelled mapping'
    500   FROM pending;
    501 END IF;
    502 
    503 -- Upsert registration
    504 INSERT INTO prepared_in (
    505   type,
    506   account_pub,
    507   authorization_pub,
    508   authorization_sig,
    509   recurrent,
    510   registered_at,
    511   tx_in_id
    512 ) VALUES (
    513   in_type,
    514   in_account_pub,
    515   in_authorization_pub,
    516   in_authorization_sig,
    517   in_recurrent,
    518   in_timestamp,
    519   talerable_tx
    520 ) ON CONFLICT (authorization_pub)
    521 DO UPDATE SET
    522   type = EXCLUDED.type,
    523   account_pub = EXCLUDED.account_pub,
    524   recurrent = EXCLUDED.recurrent,
    525   registered_at = EXCLUDED.registered_at,
    526   tx_in_id = EXCLUDED.tx_in_id,
    527   authorization_sig = EXCLUDED.authorization_sig;
    528 END $$;
    529 
    530 CREATE FUNCTION delete_prepared_transfers (
    531   IN in_authorization_pub BYTEA,
    532   OUT out_found BOOLEAN
    533 )
    534 LANGUAGE plpgsql AS $$
    535 BEGIN
    536 
    537 -- Bounce all pending
    538 WITH pending AS (
    539   DELETE FROM pending_recurrent_in
    540   WHERE authorization_pub = in_authorization_pub
    541   RETURNING tx_in_id
    542 )
    543 INSERT INTO bounced (
    544   tx_in_id,
    545   chargeback_id,
    546   reason
    547 )
    548 SELECT
    549   tx_in_id,
    550   NULL,
    551   'cancelled mapping'
    552 FROM pending;
    553 
    554 -- Delete registration
    555 DELETE FROM prepared_in
    556 WHERE authorization_pub = in_authorization_pub;
    557 out_found = FOUND;
    558 
    559 END $$;