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 $$;