merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

commit bf9ac326541908d55880759984b5ba1a42e7a62b
parent 58eb6db9ef9030edd1b2b7cac641798fa174ea9d
Author: Florian Dold <dold@taler.net>
Date:   Mon,  7 Sep 2026 21:01:45 +0200

merchantdb: prevent order serial reuse after schema migration

Preserve retained contract serials and the old order sequence position
when migration 0036 creates per-instance sequences.

Add migration 0047 to advance affected order sequences without moving
already-safe sequences backward or renumbering existing orders.

Diffstat:
Msrc/backenddb/sql-schema/merchant-0036-setval.sql.fragment | 34+++++++++++++++++++++++++++++++++-
Asrc/backenddb/sql-schema/merchant-0047.sql | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/backenddb/sql-schema/meson.build | 19+++++++++++++++++--
Asrc/backenddb/test_order_sequence_migrations.py | 363+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 486 insertions(+), 3 deletions(-)

diff --git a/src/backenddb/sql-schema/merchant-0036-setval.sql.fragment b/src/backenddb/sql-schema/merchant-0036-setval.sql.fragment @@ -8,7 +8,39 @@ PERFORM setval(pg_get_serial_sequence(s || '.merchant_inventory', 'product_serial'), COALESCE((SELECT MAX(product_serial) FROM merchant.merchant_inventory WHERE merchant_serial = rec.merchant_serial), 0) + 1, false); PERFORM setval(pg_get_serial_sequence(s || '.merchant_login_tokens', 'serial'), COALESCE((SELECT MAX(serial) FROM merchant.merchant_login_tokens WHERE merchant_serial = rec.merchant_serial), 0) + 1, false); PERFORM setval(pg_get_serial_sequence(s || '.merchant_money_pots', 'money_pot_serial'), COALESCE((SELECT MAX(money_pot_serial) FROM merchant.merchant_money_pots WHERE merchant_serial = rec.merchant_serial), 0) + 1, false); - PERFORM setval(pg_get_serial_sequence(s || '.merchant_orders', 'order_serial'), COALESCE((SELECT MAX(order_serial) FROM merchant.merchant_orders WHERE merchant_serial = rec.merchant_serial), 0) + 1, false); + -- Expired orders are removed from merchant_orders even when their paid + -- contracts remain. Preserve both those serials and the shared sequence's + -- allocation high-water mark when splitting it into per-instance sequences. + DECLARE + old_sequence REGCLASS; + new_sequence REGCLASS; + next_serial NUMERIC; + max_serial INT8; + BEGIN + old_sequence := pg_get_serial_sequence( + 'merchant.merchant_orders', 'order_serial')::REGCLASS; + new_sequence := pg_get_serial_sequence( + format('%I.merchant_orders', s), 'order_serial')::REGCLASS; + EXECUTE format( + 'SELECT last_value::NUMERIC + CASE WHEN is_called THEN 1 ELSE 0 END FROM %s', + old_sequence) + INTO next_serial; + SELECT GREATEST( + next_serial, + COALESCE((SELECT MAX(order_serial)::NUMERIC + FROM merchant.merchant_orders + WHERE merchant_serial = rec.merchant_serial), 0) + 1, + COALESCE((SELECT MAX(order_serial)::NUMERIC + FROM merchant.merchant_contract_terms + WHERE merchant_serial = rec.merchant_serial), 0) + 1) + INTO next_serial; + SELECT seqmax INTO max_serial FROM pg_sequence WHERE seqrelid = new_sequence; + IF next_serial > max_serial THEN + RAISE EXCEPTION 'Order serial sequence exhausted for instance %', s; + END IF; + EXECUTE format('ALTER SEQUENCE %s RESTART WITH %s', + new_sequence, next_serial); + END; PERFORM setval(pg_get_serial_sequence(s || '.merchant_otp_devices', 'otp_serial'), COALESCE((SELECT MAX(otp_serial) FROM merchant.merchant_otp_devices WHERE merchant_serial = rec.merchant_serial), 0) + 1, false); PERFORM setval(pg_get_serial_sequence(s || '.merchant_product_groups', 'product_group_serial'), COALESCE((SELECT MAX(product_group_serial) FROM merchant.merchant_product_groups WHERE merchant_serial = rec.merchant_serial), 0) + 1, false); PERFORM setval(pg_get_serial_sequence(s || '.merchant_reports', 'report_serial'), COALESCE((SELECT MAX(report_serial) FROM merchant.merchant_reports WHERE merchant_serial = rec.merchant_serial), 0) + 1, false); diff --git a/src/backenddb/sql-schema/merchant-0047.sql b/src/backenddb/sql-schema/merchant-0047.sql @@ -0,0 +1,73 @@ +-- +-- 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/> +-- + +-- @file merchant-0047.sql +-- @brief Advance order sequences past retained contracts after migration 0036 +-- +-- Run database upgrades with merchant writers stopped. This prevents further +-- serial reuse; it does not renumber orders already assigned low serials or +-- repair existing collisions. Historical listing order therefore stays as is. + +BEGIN; + +SELECT _v.register_patch('merchant-0047', NULL, NULL); + +SET search_path TO merchant; + +CREATE PROCEDURE merchant.merchant_0047_init(s TEXT) + LANGUAGE plpgsql + AS $OUTER$ +DECLARE + order_sequence REGCLASS; + current_next NUMERIC; + required_next NUMERIC; + max_serial INT8; +BEGIN + EXECUTE format('SET LOCAL search_path TO %I', s); + LOCK TABLE merchant_orders, merchant_contract_terms IN ACCESS EXCLUSIVE MODE; + + order_sequence := pg_get_serial_sequence( + format('%I.merchant_orders', s), 'order_serial')::REGCLASS; + EXECUTE format( + 'SELECT last_value::NUMERIC + CASE WHEN is_called THEN 1 ELSE 0 END FROM %s', + order_sequence) + INTO current_next; + SELECT GREATEST( + COALESCE((SELECT MAX(order_serial)::NUMERIC FROM merchant_orders), 0), + COALESCE((SELECT MAX(order_serial)::NUMERIC FROM merchant_contract_terms), 0)) + 1 + INTO required_next; + SELECT seqmax INTO max_serial FROM pg_sequence WHERE seqrelid = order_sequence; + IF GREATEST(current_next, required_next) > max_serial THEN + RAISE EXCEPTION 'Order serial sequence exhausted for instance %', s; + END IF; + + -- Unlike setval(), RESTART is rolled back if a later instance fixup fails. + -- Leave already-safe sequences alone, including their is_called state. + IF current_next < required_next THEN + EXECUTE format('ALTER SEQUENCE %s RESTART WITH %s', + order_sequence, required_next); + END IF; + + SET LOCAL search_path TO merchant; +END +$OUTER$; + +INSERT INTO merchant.instance_fixups + (migration_name, version) + VALUES ('merchant_0047_init', 47); +CALL merchant.fixup_instance_schema (47::INT8); + +COMMIT; diff --git a/src/backenddb/sql-schema/meson.build b/src/backenddb/sql-schema/meson.build @@ -141,10 +141,12 @@ generated_sql = [ ['merchant-0044.sql'], ['merchant-0045.sql'], ['merchant-0046.sql'], + ['merchant-0047.sql'], ] +migration_sql = [] foreach g : generated_sql - custom_target( + migration_sql += custom_target( 'gen-merchantdb-' + g[0], input: g[0], output: g[0], @@ -156,7 +158,7 @@ foreach g : generated_sql endforeach -custom_target( +migration_sql += custom_target( 'gen-merchantdb-merchant_0036.sql', input: [ 'merchant-0036.sql.in', @@ -174,3 +176,16 @@ custom_target( install: true, install_dir: sqldir, ) + +test( + 'order-sequence-migrations', + find_program('python3'), + args: [ + files('../test_order_sequence_migrations.py'), + meson.current_source_dir(), + meson.current_build_dir(), + ], + depends: migration_sql, + suite: ['backenddb'], + timeout: 120, +) diff --git a/src/backenddb/test_order_sequence_migrations.py b/src/backenddb/test_order_sequence_migrations.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 + +# 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/> + +"""Regression tests for the order ID reset introduced by migration 0036. + +Unclaimed orders live in merchant_orders. Paid contracts can remain in +merchant_contract_terms after the corresponding order rows have expired. +The next order ID must therefore exceed IDs in both tables. + +Each test clones an empty database at version 35 or 46, seeds the relevant +history, and runs the real migration SQL. These are database migration tests; +the contract insertion check does not exercise the HTTP claim endpoint. +""" + +from contextlib import contextmanager +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +MAX_SERIAL = 9223372036854775807 + + +def run(command, **kwargs): + result = subprocess.run(command, text=True, capture_output=True, **kwargs) + if result.returncode: + raise RuntimeError(f"{command[0]} failed:\n{result.stdout}\n{result.stderr}") + return result.stdout.strip() + + +@contextmanager +def postgres_cluster(bindir): + """Keep all test data in a disposable server, accessible only by Unix socket.""" + with tempfile.TemporaryDirectory(prefix="merchant-seq-", dir="/tmp") as tmp: + data = Path(tmp) / "data" + env = {key: value for key, value in os.environ.items() + if not key.startswith("PG")} + env.update(PGHOST=tmp, PGPORT="5432", PGUSER="postgres", + PGOPTIONS="-c client_min_messages=warning") + run([str(bindir / "initdb"), "-D", str(data), "-A", "trust", + "-U", "postgres", "--no-locale"], env=env) + try: + run([str(bindir / "pg_ctl"), "-D", str(data), + "-l", str(Path(tmp) / "server.log"), + "-o", f"-F -k {tmp} -c listen_addresses=''", "-w", "start"], + env=env) + yield env + finally: + if (data / "postmaster.pid").exists(): + run([str(bindir / "pg_ctl"), "-D", str(data), "-m", "immediate", + "-w", "stop"], env=env) + + +class Database: + """A fixed database connection; choosing another database creates a new handle.""" + + def __init__(self, name, bindir, env, sql_dir): + self.name = name + self.command = [str(bindir / "psql"), "-X", "-qAt", "-v", "ON_ERROR_STOP=1"] + self.env = dict(env, PGDATABASE=name) + self.sql_dir = sql_dir + + def sql(self, statement): + return run(self.command, input=statement, env=self.env) + + def apply_migration(self, version): + self.apply_file(self.sql_dir / f"merchant-{version:04}.sql") + + def apply_file(self, path): + return run(self.command + ["-f", str(path)], env=self.env) + + def add_instance(self, number, *, legacy=False): + self.sql(f""" + INSERT INTO merchant.merchant_instances + (merchant_serial, merchant_id, merchant_name, merchant_pub, + address, jurisdiction, default_wire_transfer_delay, default_pay_delay) + VALUES ({number}, 'test-{number}', 'Test', + decode(lpad(to_hex({number}),64,'0'),'hex'), '{{}}', '{{}}', 1, 1) + """) + # Runtime procedure bundles are not loaded in this migration-only fixture. + # Invoke the schema constructor explicitly instead of its runtime trigger. + if not legacy: + self.sql(f"SELECT merchant.create_instance_schema({number})") + return OrderFixture(self, number, legacy=legacy) + + +class OrderFixture: + """Seed only the order columns needed for the sequence migration scenarios.""" + + def __init__(self, db, instance, *, legacy=False): + self.db = db + self.schema = "merchant" if legacy else f"merchant_instance_{instance}" + self.sequence = f"{self.schema}.merchant_orders_order_serial_seq" + self.instance_column = "merchant_serial," if legacy else "" + self.instance_value = f"{instance}," if legacy else "" + # Old statistics triggers need runtime procedures absent from the fixture. + self.seed_setup = "SET session_replication_role=replica;" if legacy else "" + + def add_order(self, serial=None): + """An explicit serial seeds history; omitting it exercises ID allocation.""" + serial_value = "DEFAULT" if serial is None else str(serial) + order_id = "new-order" if serial is None else f"order-{serial}" + return int(self.db.sql(f""" + {self.seed_setup} + INSERT INTO {self.schema}.merchant_orders + ({self.instance_column}order_serial, order_id, claim_token, + h_post_data, pay_deadline, creation_time, contract_terms) + VALUES ({self.instance_value}{serial_value}, '{order_id}', + decode(repeat('01',16),'hex'), decode(repeat('02',64),'hex'), + 2000000000000000, 1788800253000000, '{{}}') + RETURNING order_serial + """)) + + def add_paid_contract(self, serial): + self.db.sql(f""" + {self.seed_setup} + INSERT INTO {self.schema}.merchant_contract_terms + ({self.instance_column}order_serial, order_id, contract_terms, + h_contract_terms, creation_time, pay_deadline, refund_deadline, + claim_token, paid) + VALUES ({self.instance_value}{serial}, 'order-{serial}', '{{}}', + decode(lpad(to_hex({serial}),128,'0'),'hex'), + 1788719948000000, 2000000000000000, 2000000000000000, + decode(repeat('01',16),'hex'), true) + """) + + def set_sequence(self, *, last_value, is_called): + self.db.sql(f"SELECT setval('{self.sequence}', {last_value}, " + f"{str(is_called).lower()})") + + def sequence_state(self): + last_value, is_called = self.db.sql( + f"SELECT last_value, is_called FROM {self.sequence}" + ).split("|") + return int(last_value), is_called == "t" + + def snapshot(self): + return self.db.sql(f""" + SELECT jsonb_agg(to_jsonb(t) ORDER BY order_serial) + FROM {self.schema}.merchant_orders t; + SELECT jsonb_agg(to_jsonb(t) ORDER BY order_serial) + FROM {self.schema}.merchant_contract_terms t; + """) + + def repair_statement(self): + return f"CALL merchant.merchant_0047_init('{self.schema}');" + + +class OrderSequenceMigrations(unittest.TestCase): + """Each test gets its own clone; no scenario depends on an earlier test.""" + + def database_before(self, version): + self.admin.sql(f"CREATE DATABASE {self._testMethodName} " + f"TEMPLATE before_{version}") + return Database(self._testMethodName, self.bindir, self.cluster_env, + self.sql_dir) + + def assert_sequence(self, orders, *, last_value, is_called): + self.assertEqual(orders.sequence_state(), (last_value, is_called), + f"Unexpected sequence state in {orders.schema}") + + def test_0036_keeps_ids_from_both_tables(self): + db = self.database_before(36) + paid = db.add_instance(1, legacy=True) + unpaid = db.add_instance(2, legacy=True) + db.add_instance(3, legacy=True) + paid.add_paid_contract(78) # The corresponding order has expired. + unpaid.add_order(90) + + db.apply_migration(36) + + self.assertEqual(OrderFixture(db, 1).add_order(), 79) + self.assertEqual(OrderFixture(db, 2).add_order(), 91) + self.assertEqual(OrderFixture(db, 3).add_order(), 1) + + def check_0036_preserves_sequence(self, *, is_called, expected_next): + db = self.database_before(36) + paid = db.add_instance(1, legacy=True) + db.add_instance(2, legacy=True) + paid.add_paid_contract(78) + paid.set_sequence(last_value=200, is_called=is_called) + + db.apply_migration(36) + + # Both new sequences inherit the shared sequence's higher position. + self.assertEqual(OrderFixture(db, 1).add_order(), expected_next) + self.assertEqual(OrderFixture(db, 2).add_order(), expected_next) + + def test_0036_preserves_called_sequence(self): + self.check_0036_preserves_sequence(is_called=True, expected_next=201) + + def test_0036_preserves_uncalled_sequence(self): + self.check_0036_preserves_sequence(is_called=False, expected_next=200) + + def test_0036_rejects_exhausted_sequence(self): + db = self.database_before(36) + orders = db.add_instance(1, legacy=True) + orders.set_sequence(last_value=MAX_SERIAL, is_called=True) + + with self.assertRaisesRegex(RuntimeError, "Order serial sequence exhausted"): + db.apply_migration(36) + + self.assertEqual(db.sql("SELECT count(*) FROM _v.patches " + "WHERE patch_name='merchant-0036'"), "0") + + def test_0047_repairs_restarted_ids_without_changing_orders(self): + db = self.database_before(47) + paid = db.add_instance(1) + paid.add_paid_contract(78) + # Reproduce the reported history: new IDs 1-4 follow historical ID 78. + for serial in range(1, 5): + paid.add_order(serial) + paid.add_paid_contract(serial) + paid.set_sequence(last_value=4, is_called=True) + unpaid = db.add_instance(2) + unpaid.add_paid_contract(80) + unpaid.add_order(90) + paid_before, unpaid_before = paid.snapshot(), unpaid.snapshot() + + db.apply_migration(47) + + self.assert_sequence(paid, last_value=79, is_called=False) + self.assert_sequence(unpaid, last_value=91, is_called=False) + self.assertEqual(paid.snapshot(), paid_before) + self.assertEqual(unpaid.snapshot(), unpaid_before) + + # Reapplying the registered fixup must not consume or rewind IDs. + db.sql("CALL merchant.fixup_instance_schema(47::INT8)") + self.assert_sequence(paid, last_value=79, is_called=False) + self.assert_sequence(unpaid, last_value=91, is_called=False) + self.assertEqual(paid.add_order(), 79) + self.assertEqual(unpaid.add_order(), 91) + + # At the database level, claiming can copy the new serial without a + # primary-key collision. This is not an HTTP/backend claim test. + db.sql(f""" + INSERT INTO {paid.schema}.merchant_contract_terms + (order_serial, order_id, contract_terms, h_contract_terms, + creation_time, pay_deadline, refund_deadline, claim_token) + SELECT order_serial, order_id, contract_terms, + decode(repeat('ff',64),'hex'), creation_time, + pay_deadline, pay_deadline, claim_token + FROM {paid.schema}.merchant_orders WHERE order_serial=79 + """) + self.assertEqual(db.sql( + f"SELECT order_serial FROM {paid.schema}.merchant_contract_terms " + "WHERE order_id='new-order'"), "79") + + def test_0047_preserves_safe_sequence_states(self): + db = self.database_before(47) + called = db.add_instance(1) + called.set_sequence(last_value=200, is_called=True) + uncalled = db.add_instance(2) + uncalled.set_sequence(last_value=200, is_called=False) + just_above_history = db.add_instance(3) + just_above_history.add_paid_contract(78) + just_above_history.set_sequence(last_value=79, is_called=False) + + db.apply_migration(47) + + self.assert_sequence(called, last_value=200, is_called=True) + self.assert_sequence(uncalled, last_value=200, is_called=False) + self.assert_sequence(just_above_history, last_value=79, is_called=False) + + def test_0047_keeps_empty_and_new_instances_starting_at_one(self): + db = self.database_before(47) + empty = db.add_instance(1) + + db.apply_migration(47) + new = db.add_instance(2) + + self.assert_sequence(empty, last_value=1, is_called=False) + self.assertEqual(empty.add_order(), 1) + self.assertEqual(new.add_order(), 1) + + def test_0047_sequence_restart_rolls_back(self): + db = self.database_before(47) + orders = db.add_instance(1) + orders.add_paid_contract(78) + db.apply_migration(47) + orders.set_sequence(last_value=4, is_called=True) + + db.sql(f"BEGIN; {orders.repair_statement()} ROLLBACK;") + + self.assert_sequence(orders, last_value=4, is_called=True) + + def test_0047_later_failure_rolls_back_earlier_repair(self): + db = self.database_before(47) + repairable = db.add_instance(1) + exhausted = db.add_instance(2) + repairable.add_paid_contract(78) + db.apply_migration(47) + repairable.set_sequence(last_value=4, is_called=True) + exhausted.set_sequence(last_value=MAX_SERIAL, is_called=True) + + with self.assertRaisesRegex(RuntimeError, "Order serial sequence exhausted"): + db.sql(f"BEGIN; {repairable.repair_statement()} " + f"{exhausted.repair_statement()} COMMIT;") + + self.assert_sequence(repairable, last_value=4, is_called=True) + + def test_0047_rejects_exhausted_stored_ids(self): + db = self.database_before(47) + orders = db.add_instance(1) + orders.add_order(MAX_SERIAL) + + with self.assertRaisesRegex(RuntimeError, "Order serial sequence exhausted"): + db.apply_migration(47) + + self.assert_sequence(orders, last_value=1, is_called=False) + + +def main(): + source, build = (Path(arg).resolve() for arg in sys.argv[1:]) + if not shutil.which("pg_config"): + print("PostgreSQL server tools unavailable") + return 77 + bindir = Path(run(["pg_config", "--bindir"])) + if os.geteuid() == 0 or not all((bindir / tool).exists() for tool in + ("initdb", "pg_ctl", "psql")): + print("Need PostgreSQL server tools and an unprivileged user") + return 77 + + with postgres_cluster(bindir) as env: + admin = Database("template1", bindir, env, build) + admin.sql("CREATE DATABASE before_36") + before_36 = Database("before_36", bindir, env, build) + before_36.apply_file(source / "versioning.sql") + for version in range(1, 36): + before_36.apply_migration(version) + admin.sql("CREATE DATABASE before_47 TEMPLATE before_36") + before_47 = Database("before_47", bindir, env, build) + for version in range(36, 47): + before_47.apply_migration(version) + + OrderSequenceMigrations.admin = admin + OrderSequenceMigrations.bindir = bindir + OrderSequenceMigrations.cluster_env = env + OrderSequenceMigrations.sql_dir = build + suite = unittest.defaultTestLoader.loadTestsFromTestCase(OrderSequenceMigrations) + result = unittest.TextTestRunner(verbosity=2).run(suite) + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + sys.exit(main())