From 9e64cb55deeb885b20c718808490d6c4e9856bd1 Mon Sep 17 00:00:00 2001 From: arendsyl Date: Sat, 30 May 2026 18:29:03 +0200 Subject: [PATCH] feat: implement accounting API (accounts, ledger, admin actions, payments, audit) - Accounts CRUD + metadata; filterable listing - Append-only operations ledger (debt-limit checked); balance reconciles to sum(operations) - Payments: method x purpose matrix, state machine, confirm/reject/cancel, event log - Account-management actions (deactivate/reactivate/max-debt) + filterable /audit/actions - sqlx 0.9 compile-time macros with committed offline cache; axum 0.8; OpenAPI via utoipa + Scalar - HOST/PORT configurable; no runtime dotenv --- .env.example | 15 + ...ca5515458ef758dad2c73984f4b008a1e7a08.json | 112 ++ ...576d881c1fa41a54999c4c32fea7920fe769d.json | 181 ++ ...dd730ce2263813bb500daae20e7ff93177d1d.json | 44 + ...11a475da2745687b2a027180adddaa723c9ec.json | 28 + ...06179aa1a25f11ae747b5edf71c0c0afdc9b1.json | 28 + ...d6c1fb693fe3e2f36a918969375779dfa7682.json | 181 ++ ...3472c9387ed0f60a1349efcb32b4115b44052.json | 112 ++ ...4230da37949d9e0a87406203d3abff287ca34.json | 159 ++ ...0ca8ade4d2c892029bd126ebdaaf6a84f25f1.json | 192 ++ ...f89f2edc4a3080bd3fa5cb54a54ebf2e702db.json | 99 ++ ...e11b6407cb77e376838c4dd72964eda46e1b9.json | 28 + ...fdb2e5878768487dba5dd5cabda23f2e046f8.json | 162 ++ ...fa051a330355ce4b19043aff528e7c862b376.json | 161 ++ ...ecfe34613758aa39d9eed4a06765e159f5c1a.json | 21 + ...7f5261c8eccdb9c5d46985dcfb168901c7f99.json | 165 ++ ...4e94746fe357b800e55b459a55ed18ce4012f.json | 126 ++ ...2a07d6395db5962eefc71b989320ad5066d93.json | 196 +++ ...b48f5f99f546f6db04d430a9add4911f867d4.json | 29 + ...ebe7c09ba820e65b99c9db1279c8e9d998b90.json | 40 + ...954306c892a8ab377264e5d34f4d675af07f4.json | 98 ++ ...c5275d9f80e709af25c3e18f02e8b42c9337e.json | 15 + ...2e0a3628ab504f78f285a34ec7c4d65084eae.json | 210 +++ ...38be28279a2abe9dc87f10379924cd068bba5.json | 159 ++ ...75e81b5c06a91d20b870a2a0bdecd16793433.json | 99 ++ ...5f36839a56e3bc416f334b1491d4143bb95ac.json | 183 ++ ...2015b79bdf985972bc4f1f93abb3433083fa3.json | 121 ++ ...b00661573da6b69bb5b6f69b29b3fad85b9a1.json | 23 + Cargo.lock | 1547 ++++++++--------- Cargo.toml | 28 +- README.md | 104 ++ SPECS.md | 34 +- .../20250401202713_initialize_tables.sql | 22 +- migrations/20250402090000_payments.sql | 37 + ...20250403090000_payment_deferred_method.sql | 5 + src/db/account.rs | 217 ++- src/db/admin_actions.rs | 158 +- src/db/errors.rs | 30 +- src/db/mod.rs | 12 +- src/db/operation.rs | 132 ++ src/db/payment.rs | 456 +++++ src/docs.rs | 20 + src/errors.rs | 61 +- src/filters.rs | 214 +++ src/lib.rs | 7 + src/main.rs | 79 +- src/models/account.rs | 107 +- src/models/admin_action.rs | 30 +- src/models/mod.rs | 15 +- src/models/operation.rs | 49 +- src/models/payment.rs | 119 ++ src/payment/mod.rs | 1 + src/payment/state_machine.rs | 144 ++ src/routes/account.rs | 142 +- src/routes/admin_action.rs | 83 + src/routes/audit.rs | 43 + src/routes/mod.rs | 55 +- src/routes/operation.rs | 64 + src/routes/payment.rs | 172 ++ tests/integration.rs | 261 +++ 60 files changed, 6233 insertions(+), 1202 deletions(-) create mode 100644 .env.example create mode 100644 .sqlx/query-00c924675e79c4f0ab432360d01ca5515458ef758dad2c73984f4b008a1e7a08.json create mode 100644 .sqlx/query-01166b38af3519696e19c120a0a576d881c1fa41a54999c4c32fea7920fe769d.json create mode 100644 .sqlx/query-08010a1898da1588f96a48680addd730ce2263813bb500daae20e7ff93177d1d.json create mode 100644 .sqlx/query-0baf27fb87820ef66d97e42f4fb11a475da2745687b2a027180adddaa723c9ec.json create mode 100644 .sqlx/query-1a81794620597eea4bdbf37414406179aa1a25f11ae747b5edf71c0c0afdc9b1.json create mode 100644 .sqlx/query-1d5ad1a33097f0a2a17255743b3d6c1fb693fe3e2f36a918969375779dfa7682.json create mode 100644 .sqlx/query-1f5d81c9157c9b3f37ea69409c73472c9387ed0f60a1349efcb32b4115b44052.json create mode 100644 .sqlx/query-2c3019520ffe83311806d20fda54230da37949d9e0a87406203d3abff287ca34.json create mode 100644 .sqlx/query-45b205bc9091b9456f1e8d50dfe0ca8ade4d2c892029bd126ebdaaf6a84f25f1.json create mode 100644 .sqlx/query-47a04b029dad93ded7021a66b5bf89f2edc4a3080bd3fa5cb54a54ebf2e702db.json create mode 100644 .sqlx/query-53f9016f596103ceb053592e860e11b6407cb77e376838c4dd72964eda46e1b9.json create mode 100644 .sqlx/query-542d72d741b02f0d394060eb8a2fdb2e5878768487dba5dd5cabda23f2e046f8.json create mode 100644 .sqlx/query-54dfc8d409a6153ce29316eddd4fa051a330355ce4b19043aff528e7c862b376.json create mode 100644 .sqlx/query-59e12f2e3c5d16ab69ff3967e18ecfe34613758aa39d9eed4a06765e159f5c1a.json create mode 100644 .sqlx/query-636364219261dc971fd27f3410c7f5261c8eccdb9c5d46985dcfb168901c7f99.json create mode 100644 .sqlx/query-6ebbd7a12d8d6d876a2782e39214e94746fe357b800e55b459a55ed18ce4012f.json create mode 100644 .sqlx/query-71d7792964adf4c7a3205b1a0352a07d6395db5962eefc71b989320ad5066d93.json create mode 100644 .sqlx/query-86e1eaeb4080d5a46ea0878aa45b48f5f99f546f6db04d430a9add4911f867d4.json create mode 100644 .sqlx/query-8ec2b8542675509b5643e286af6ebe7c09ba820e65b99c9db1279c8e9d998b90.json create mode 100644 .sqlx/query-94785251e5bcf671b229083b2f4954306c892a8ab377264e5d34f4d675af07f4.json create mode 100644 .sqlx/query-95296c61285ade5500d94bc456fc5275d9f80e709af25c3e18f02e8b42c9337e.json create mode 100644 .sqlx/query-9e5a6e13048bd8b627f573f6f882e0a3628ab504f78f285a34ec7c4d65084eae.json create mode 100644 .sqlx/query-a2d767d137676710f4b0a5ab4da38be28279a2abe9dc87f10379924cd068bba5.json create mode 100644 .sqlx/query-e32c0a3680cef9865747c53b85175e81b5c06a91d20b870a2a0bdecd16793433.json create mode 100644 .sqlx/query-e59575be1f746753c4d0547b4495f36839a56e3bc416f334b1491d4143bb95ac.json create mode 100644 .sqlx/query-f0f4d0d4b866f1799d88150daac2015b79bdf985972bc4f1f93abb3433083fa3.json create mode 100644 .sqlx/query-ff4855ee869993a827ea2ba1eaab00661573da6b69bb5b6f69b29b3fad85b9a1.json create mode 100644 README.md create mode 100644 migrations/20250402090000_payments.sql create mode 100644 migrations/20250403090000_payment_deferred_method.sql create mode 100644 src/db/operation.rs create mode 100644 src/db/payment.rs create mode 100644 src/docs.rs create mode 100644 src/filters.rs create mode 100644 src/lib.rs create mode 100644 src/models/payment.rs create mode 100644 src/payment/mod.rs create mode 100644 src/payment/state_machine.rs create mode 100644 src/routes/admin_action.rs create mode 100644 src/routes/audit.rs create mode 100644 src/routes/operation.rs create mode 100644 src/routes/payment.rs create mode 100644 tests/integration.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5bfd69a --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# The application reads configuration from real environment variables (it does +# NOT load this file at runtime). This file documents them and is read by the +# sqlx compile-time macros / sqlx-cli at build time (which look up DATABASE_URL). +# For local runs, export these (e.g. `set -a; . ./.env; set +a`) or pass them in. + +# Connection string used by the sqlx macros and sqlx-cli at build time. +DATABASE_URL=postgres://postgres:postgres@localhost:5432/accounting +# Connection string read by the application at runtime (see src/main.rs). +POSTGRESQL_ADDON_URI=postgres://postgres:postgres@localhost:5432/accounting + +# Server bind address (optional; defaults shown). +HOST=0.0.0.0 +PORT=3000 + +RUST_LOG=info,accounting_api=debug diff --git a/.sqlx/query-00c924675e79c4f0ab432360d01ca5515458ef758dad2c73984f4b008a1e7a08.json b/.sqlx/query-00c924675e79c4f0ab432360d01ca5515458ef758dad2c73984f4b008a1e7a08.json new file mode 100644 index 0000000..9d47708 --- /dev/null +++ b/.sqlx/query-00c924675e79c4f0ab432360d01ca5515458ef758dad2c73984f4b008a1e7a08.json @@ -0,0 +1,112 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO operations (id, account_id, amount, timestamp, labels, kind)\n VALUES ($1, $2, $3, now(), $4, $5)\n RETURNING id, account_id, amount, timestamp,\n labels as \"labels: Json>\",\n kind as \"kind: OperationKind\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "operations", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "account_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "operations", + "name": "account_id" + } + } + }, + { + "ordinal": 2, + "name": "amount", + "type_info": "Int4", + "origin": { + "Table": { + "table": "operations", + "name": "amount" + } + } + }, + { + "ordinal": 3, + "name": "timestamp", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operations", + "name": "timestamp" + } + } + }, + { + "ordinal": 4, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operations", + "name": "labels" + } + } + }, + { + "ordinal": 5, + "name": "kind: OperationKind", + "type_info": { + "Custom": { + "name": "operation_kind", + "kind": { + "Enum": [ + "DEBIT", + "CREDIT" + ] + } + } + }, + "origin": { + "Table": { + "table": "operations", + "name": "kind" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Int4", + "Jsonb", + { + "Custom": { + "name": "operation_kind", + "kind": { + "Enum": [ + "DEBIT", + "CREDIT" + ] + } + } + } + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "00c924675e79c4f0ab432360d01ca5515458ef758dad2c73984f4b008a1e7a08" +} diff --git a/.sqlx/query-01166b38af3519696e19c120a0a576d881c1fa41a54999c4c32fea7920fe769d.json b/.sqlx/query-01166b38af3519696e19c120a0a576d881c1fa41a54999c4c32fea7920fe769d.json new file mode 100644 index 0000000..38b7b7b --- /dev/null +++ b/.sqlx/query-01166b38af3519696e19c120a0a576d881c1fa41a54999c4c32fea7920fe769d.json @@ -0,0 +1,181 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE payments\n SET method = NULL, status = 'PENDING', updated_at = now()\n WHERE id = $1\n RETURNING id, account_id, amount,\n method as \"method?: PaymentMethod\",\n purpose as \"purpose: PaymentPurpose\",\n status as \"status: PaymentStatus\",\n operation_id,\n labels as \"labels: Json>\",\n reason, created_at, updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "account_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "account_id" + } + } + }, + { + "ordinal": 2, + "name": "amount", + "type_info": "Int4", + "origin": { + "Table": { + "table": "payments", + "name": "amount" + } + } + }, + { + "ordinal": 3, + "name": "method?: PaymentMethod", + "type_info": { + "Custom": { + "name": "payment_method", + "kind": { + "Enum": [ + "ACCOUNT_BALANCE", + "EXTERNAL" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "method" + } + } + }, + { + "ordinal": 4, + "name": "purpose: PaymentPurpose", + "type_info": { + "Custom": { + "name": "payment_purpose", + "kind": { + "Enum": [ + "PURCHASE", + "TOP_UP" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "purpose" + } + } + }, + { + "ordinal": 5, + "name": "status: PaymentStatus", + "type_info": { + "Custom": { + "name": "payment_status", + "kind": { + "Enum": [ + "PENDING", + "SUCCEEDED", + "FAILED", + "CANCELED", + "PROCESSING" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "status" + } + } + }, + { + "ordinal": 6, + "name": "operation_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "operation_id" + } + } + }, + { + "ordinal": 7, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "payments", + "name": "labels" + } + } + }, + { + "ordinal": 8, + "name": "reason", + "type_info": "Text", + "origin": { + "Table": { + "table": "payments", + "name": "reason" + } + } + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "payments", + "name": "created_at" + } + } + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "payments", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true, + false, + true, + false, + false + ] + }, + "hash": "01166b38af3519696e19c120a0a576d881c1fa41a54999c4c32fea7920fe769d" +} diff --git a/.sqlx/query-08010a1898da1588f96a48680addd730ce2263813bb500daae20e7ff93177d1d.json b/.sqlx/query-08010a1898da1588f96a48680addd730ce2263813bb500daae20e7ff93177d1d.json new file mode 100644 index 0000000..9699dc5 --- /dev/null +++ b/.sqlx/query-08010a1898da1588f96a48680addd730ce2263813bb500daae20e7ff93177d1d.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO payment_events\n (id, payment_id, from_status, to_status, timestamp, reason, actor)\n VALUES ($1, $2, $3, $4, now(), $5, NULL)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + { + "Custom": { + "name": "payment_status", + "kind": { + "Enum": [ + "PENDING", + "SUCCEEDED", + "FAILED", + "CANCELED", + "PROCESSING" + ] + } + } + }, + { + "Custom": { + "name": "payment_status", + "kind": { + "Enum": [ + "PENDING", + "SUCCEEDED", + "FAILED", + "CANCELED", + "PROCESSING" + ] + } + } + }, + "Text" + ] + }, + "nullable": [] + }, + "hash": "08010a1898da1588f96a48680addd730ce2263813bb500daae20e7ff93177d1d" +} diff --git a/.sqlx/query-0baf27fb87820ef66d97e42f4fb11a475da2745687b2a027180adddaa723c9ec.json b/.sqlx/query-0baf27fb87820ef66d97e42f4fb11a475da2745687b2a027180adddaa723c9ec.json new file mode 100644 index 0000000..4b4ec03 --- /dev/null +++ b/.sqlx/query-0baf27fb87820ef66d97e42f4fb11a475da2745687b2a027180adddaa723c9ec.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE accounts\n SET status = 'ACTIVE', updated_at = now()\n WHERE id = $1\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "accounts", + "name": "id" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0baf27fb87820ef66d97e42f4fb11a475da2745687b2a027180adddaa723c9ec" +} diff --git a/.sqlx/query-1a81794620597eea4bdbf37414406179aa1a25f11ae747b5edf71c0c0afdc9b1.json b/.sqlx/query-1a81794620597eea4bdbf37414406179aa1a25f11ae747b5edf71c0c0afdc9b1.json new file mode 100644 index 0000000..f9683b9 --- /dev/null +++ b/.sqlx/query-1a81794620597eea4bdbf37414406179aa1a25f11ae747b5edf71c0c0afdc9b1.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM accounts WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "accounts", + "name": "id" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1a81794620597eea4bdbf37414406179aa1a25f11ae747b5edf71c0c0afdc9b1" +} diff --git a/.sqlx/query-1d5ad1a33097f0a2a17255743b3d6c1fb693fe3e2f36a918969375779dfa7682.json b/.sqlx/query-1d5ad1a33097f0a2a17255743b3d6c1fb693fe3e2f36a918969375779dfa7682.json new file mode 100644 index 0000000..aff6892 --- /dev/null +++ b/.sqlx/query-1d5ad1a33097f0a2a17255743b3d6c1fb693fe3e2f36a918969375779dfa7682.json @@ -0,0 +1,181 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, account_id, amount,\n method as \"method?: PaymentMethod\",\n purpose as \"purpose: PaymentPurpose\",\n status as \"status: PaymentStatus\",\n operation_id,\n labels as \"labels: Json>\",\n reason, created_at, updated_at\n FROM payments\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "account_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "account_id" + } + } + }, + { + "ordinal": 2, + "name": "amount", + "type_info": "Int4", + "origin": { + "Table": { + "table": "payments", + "name": "amount" + } + } + }, + { + "ordinal": 3, + "name": "method?: PaymentMethod", + "type_info": { + "Custom": { + "name": "payment_method", + "kind": { + "Enum": [ + "ACCOUNT_BALANCE", + "EXTERNAL" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "method" + } + } + }, + { + "ordinal": 4, + "name": "purpose: PaymentPurpose", + "type_info": { + "Custom": { + "name": "payment_purpose", + "kind": { + "Enum": [ + "PURCHASE", + "TOP_UP" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "purpose" + } + } + }, + { + "ordinal": 5, + "name": "status: PaymentStatus", + "type_info": { + "Custom": { + "name": "payment_status", + "kind": { + "Enum": [ + "PENDING", + "SUCCEEDED", + "FAILED", + "CANCELED", + "PROCESSING" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "status" + } + } + }, + { + "ordinal": 6, + "name": "operation_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "operation_id" + } + } + }, + { + "ordinal": 7, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "payments", + "name": "labels" + } + } + }, + { + "ordinal": 8, + "name": "reason", + "type_info": "Text", + "origin": { + "Table": { + "table": "payments", + "name": "reason" + } + } + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "payments", + "name": "created_at" + } + } + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "payments", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true, + false, + true, + false, + false + ] + }, + "hash": "1d5ad1a33097f0a2a17255743b3d6c1fb693fe3e2f36a918969375779dfa7682" +} diff --git a/.sqlx/query-1f5d81c9157c9b3f37ea69409c73472c9387ed0f60a1349efcb32b4115b44052.json b/.sqlx/query-1f5d81c9157c9b3f37ea69409c73472c9387ed0f60a1349efcb32b4115b44052.json new file mode 100644 index 0000000..9a99702 --- /dev/null +++ b/.sqlx/query-1f5d81c9157c9b3f37ea69409c73472c9387ed0f60a1349efcb32b4115b44052.json @@ -0,0 +1,112 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, account_id, timestamp,\n labels as \"labels: Json>\",\n reason, kind,\n data as \"data: serde_json::Value\",\n actor\n FROM admin_actions\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "admin_actions", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "account_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "admin_actions", + "name": "account_id" + } + } + }, + { + "ordinal": 2, + "name": "timestamp", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "admin_actions", + "name": "timestamp" + } + } + }, + { + "ordinal": 3, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "admin_actions", + "name": "labels" + } + } + }, + { + "ordinal": 4, + "name": "reason", + "type_info": "Text", + "origin": { + "Table": { + "table": "admin_actions", + "name": "reason" + } + } + }, + { + "ordinal": 5, + "name": "kind", + "type_info": "Text", + "origin": { + "Table": { + "table": "admin_actions", + "name": "kind" + } + } + }, + { + "ordinal": 6, + "name": "data: serde_json::Value", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "admin_actions", + "name": "data" + } + } + }, + { + "ordinal": 7, + "name": "actor", + "type_info": "Text", + "origin": { + "Table": { + "table": "admin_actions", + "name": "actor" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "1f5d81c9157c9b3f37ea69409c73472c9387ed0f60a1349efcb32b4115b44052" +} diff --git a/.sqlx/query-2c3019520ffe83311806d20fda54230da37949d9e0a87406203d3abff287ca34.json b/.sqlx/query-2c3019520ffe83311806d20fda54230da37949d9e0a87406203d3abff287ca34.json new file mode 100644 index 0000000..bfa068b --- /dev/null +++ b/.sqlx/query-2c3019520ffe83311806d20fda54230da37949d9e0a87406203d3abff287ca34.json @@ -0,0 +1,159 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, owner_id, name, description, tags,\n labels as \"labels: Json>\",\n max_allowed_debt, balance,\n status as \"status: AccountStatus\",\n created_at, updated_at\n FROM accounts\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "accounts", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "owner_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "owner_id" + } + } + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "name" + } + } + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "description" + } + } + }, + { + "ordinal": 4, + "name": "tags", + "type_info": "TextArray", + "origin": { + "Table": { + "table": "accounts", + "name": "tags" + } + } + }, + { + "ordinal": 5, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "accounts", + "name": "labels" + } + } + }, + { + "ordinal": 6, + "name": "max_allowed_debt", + "type_info": "Int4", + "origin": { + "Table": { + "table": "accounts", + "name": "max_allowed_debt" + } + } + }, + { + "ordinal": 7, + "name": "balance", + "type_info": "Int4", + "origin": { + "Table": { + "table": "accounts", + "name": "balance" + } + } + }, + { + "ordinal": 8, + "name": "status: AccountStatus", + "type_info": { + "Custom": { + "name": "account_status", + "kind": { + "Enum": [ + "ACTIVE", + "DEACTIVATED", + "DELETED" + ] + } + } + }, + "origin": { + "Table": { + "table": "accounts", + "name": "status" + } + } + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "accounts", + "name": "created_at" + } + } + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "accounts", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "2c3019520ffe83311806d20fda54230da37949d9e0a87406203d3abff287ca34" +} diff --git a/.sqlx/query-45b205bc9091b9456f1e8d50dfe0ca8ade4d2c892029bd126ebdaaf6a84f25f1.json b/.sqlx/query-45b205bc9091b9456f1e8d50dfe0ca8ade4d2c892029bd126ebdaaf6a84f25f1.json new file mode 100644 index 0000000..e1b72ee --- /dev/null +++ b/.sqlx/query-45b205bc9091b9456f1e8d50dfe0ca8ade4d2c892029bd126ebdaaf6a84f25f1.json @@ -0,0 +1,192 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE payments\n SET method = $2, status = 'PROCESSING', updated_at = now()\n WHERE id = $1\n RETURNING id, account_id, amount,\n method as \"method?: PaymentMethod\",\n purpose as \"purpose: PaymentPurpose\",\n status as \"status: PaymentStatus\",\n operation_id,\n labels as \"labels: Json>\",\n reason, created_at, updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "account_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "account_id" + } + } + }, + { + "ordinal": 2, + "name": "amount", + "type_info": "Int4", + "origin": { + "Table": { + "table": "payments", + "name": "amount" + } + } + }, + { + "ordinal": 3, + "name": "method?: PaymentMethod", + "type_info": { + "Custom": { + "name": "payment_method", + "kind": { + "Enum": [ + "ACCOUNT_BALANCE", + "EXTERNAL" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "method" + } + } + }, + { + "ordinal": 4, + "name": "purpose: PaymentPurpose", + "type_info": { + "Custom": { + "name": "payment_purpose", + "kind": { + "Enum": [ + "PURCHASE", + "TOP_UP" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "purpose" + } + } + }, + { + "ordinal": 5, + "name": "status: PaymentStatus", + "type_info": { + "Custom": { + "name": "payment_status", + "kind": { + "Enum": [ + "PENDING", + "SUCCEEDED", + "FAILED", + "CANCELED", + "PROCESSING" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "status" + } + } + }, + { + "ordinal": 6, + "name": "operation_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "operation_id" + } + } + }, + { + "ordinal": 7, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "payments", + "name": "labels" + } + } + }, + { + "ordinal": 8, + "name": "reason", + "type_info": "Text", + "origin": { + "Table": { + "table": "payments", + "name": "reason" + } + } + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "payments", + "name": "created_at" + } + } + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "payments", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "payment_method", + "kind": { + "Enum": [ + "ACCOUNT_BALANCE", + "EXTERNAL" + ] + } + } + } + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true, + false, + true, + false, + false + ] + }, + "hash": "45b205bc9091b9456f1e8d50dfe0ca8ade4d2c892029bd126ebdaaf6a84f25f1" +} diff --git a/.sqlx/query-47a04b029dad93ded7021a66b5bf89f2edc4a3080bd3fa5cb54a54ebf2e702db.json b/.sqlx/query-47a04b029dad93ded7021a66b5bf89f2edc4a3080bd3fa5cb54a54ebf2e702db.json new file mode 100644 index 0000000..767d01a --- /dev/null +++ b/.sqlx/query-47a04b029dad93ded7021a66b5bf89f2edc4a3080bd3fa5cb54a54ebf2e702db.json @@ -0,0 +1,99 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT account_id, amount,\n purpose as \"purpose: PaymentPurpose\",\n status as \"status: PaymentStatus\",\n labels as \"labels: Json>\"\n FROM payments\n WHERE id = $1\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "account_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "account_id" + } + } + }, + { + "ordinal": 1, + "name": "amount", + "type_info": "Int4", + "origin": { + "Table": { + "table": "payments", + "name": "amount" + } + } + }, + { + "ordinal": 2, + "name": "purpose: PaymentPurpose", + "type_info": { + "Custom": { + "name": "payment_purpose", + "kind": { + "Enum": [ + "PURCHASE", + "TOP_UP" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "purpose" + } + } + }, + { + "ordinal": 3, + "name": "status: PaymentStatus", + "type_info": { + "Custom": { + "name": "payment_status", + "kind": { + "Enum": [ + "PENDING", + "SUCCEEDED", + "FAILED", + "CANCELED", + "PROCESSING" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "status" + } + } + }, + { + "ordinal": 4, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "payments", + "name": "labels" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "47a04b029dad93ded7021a66b5bf89f2edc4a3080bd3fa5cb54a54ebf2e702db" +} diff --git a/.sqlx/query-53f9016f596103ceb053592e860e11b6407cb77e376838c4dd72964eda46e1b9.json b/.sqlx/query-53f9016f596103ceb053592e860e11b6407cb77e376838c4dd72964eda46e1b9.json new file mode 100644 index 0000000..5c1a538 --- /dev/null +++ b/.sqlx/query-53f9016f596103ceb053592e860e11b6407cb77e376838c4dd72964eda46e1b9.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE accounts\n SET status = 'DEACTIVATED', updated_at = now()\n WHERE id = $1\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "accounts", + "name": "id" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "53f9016f596103ceb053592e860e11b6407cb77e376838c4dd72964eda46e1b9" +} diff --git a/.sqlx/query-542d72d741b02f0d394060eb8a2fdb2e5878768487dba5dd5cabda23f2e046f8.json b/.sqlx/query-542d72d741b02f0d394060eb8a2fdb2e5878768487dba5dd5cabda23f2e046f8.json new file mode 100644 index 0000000..6ceaccb --- /dev/null +++ b/.sqlx/query-542d72d741b02f0d394060eb8a2fdb2e5878768487dba5dd5cabda23f2e046f8.json @@ -0,0 +1,162 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE accounts\n SET name = COALESCE($2::text, name),\n description = COALESCE($3::text, description),\n tags = COALESCE($4::text[], tags),\n updated_at = now()\n WHERE id = $1\n RETURNING id, owner_id, name, description, tags,\n labels as \"labels: Json>\",\n max_allowed_debt, balance,\n status as \"status: AccountStatus\",\n created_at, updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "accounts", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "owner_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "owner_id" + } + } + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "name" + } + } + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "description" + } + } + }, + { + "ordinal": 4, + "name": "tags", + "type_info": "TextArray", + "origin": { + "Table": { + "table": "accounts", + "name": "tags" + } + } + }, + { + "ordinal": 5, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "accounts", + "name": "labels" + } + } + }, + { + "ordinal": 6, + "name": "max_allowed_debt", + "type_info": "Int4", + "origin": { + "Table": { + "table": "accounts", + "name": "max_allowed_debt" + } + } + }, + { + "ordinal": 7, + "name": "balance", + "type_info": "Int4", + "origin": { + "Table": { + "table": "accounts", + "name": "balance" + } + } + }, + { + "ordinal": 8, + "name": "status: AccountStatus", + "type_info": { + "Custom": { + "name": "account_status", + "kind": { + "Enum": [ + "ACTIVE", + "DEACTIVATED", + "DELETED" + ] + } + } + }, + "origin": { + "Table": { + "table": "accounts", + "name": "status" + } + } + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "accounts", + "name": "created_at" + } + } + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "accounts", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "542d72d741b02f0d394060eb8a2fdb2e5878768487dba5dd5cabda23f2e046f8" +} diff --git a/.sqlx/query-54dfc8d409a6153ce29316eddd4fa051a330355ce4b19043aff528e7c862b376.json b/.sqlx/query-54dfc8d409a6153ce29316eddd4fa051a330355ce4b19043aff528e7c862b376.json new file mode 100644 index 0000000..0721310 --- /dev/null +++ b/.sqlx/query-54dfc8d409a6153ce29316eddd4fa051a330355ce4b19043aff528e7c862b376.json @@ -0,0 +1,161 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE accounts\n SET labels = $2, tags = $3, updated_at = now()\n WHERE id = $1\n RETURNING id, owner_id, name, description, tags,\n labels as \"labels: Json>\",\n max_allowed_debt, balance,\n status as \"status: AccountStatus\",\n created_at, updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "accounts", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "owner_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "owner_id" + } + } + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "name" + } + } + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "description" + } + } + }, + { + "ordinal": 4, + "name": "tags", + "type_info": "TextArray", + "origin": { + "Table": { + "table": "accounts", + "name": "tags" + } + } + }, + { + "ordinal": 5, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "accounts", + "name": "labels" + } + } + }, + { + "ordinal": 6, + "name": "max_allowed_debt", + "type_info": "Int4", + "origin": { + "Table": { + "table": "accounts", + "name": "max_allowed_debt" + } + } + }, + { + "ordinal": 7, + "name": "balance", + "type_info": "Int4", + "origin": { + "Table": { + "table": "accounts", + "name": "balance" + } + } + }, + { + "ordinal": 8, + "name": "status: AccountStatus", + "type_info": { + "Custom": { + "name": "account_status", + "kind": { + "Enum": [ + "ACTIVE", + "DEACTIVATED", + "DELETED" + ] + } + } + }, + "origin": { + "Table": { + "table": "accounts", + "name": "status" + } + } + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "accounts", + "name": "created_at" + } + } + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "accounts", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Jsonb", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "54dfc8d409a6153ce29316eddd4fa051a330355ce4b19043aff528e7c862b376" +} diff --git a/.sqlx/query-59e12f2e3c5d16ab69ff3967e18ecfe34613758aa39d9eed4a06765e159f5c1a.json b/.sqlx/query-59e12f2e3c5d16ab69ff3967e18ecfe34613758aa39d9eed4a06765e159f5c1a.json new file mode 100644 index 0000000..4ff8b9c --- /dev/null +++ b/.sqlx/query-59e12f2e3c5d16ab69ff3967e18ecfe34613758aa39d9eed4a06765e159f5c1a.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO admin_actions (id, account_id, timestamp, labels, reason, kind, data, actor)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Timestamptz", + "Jsonb", + "Text", + "Text", + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "59e12f2e3c5d16ab69ff3967e18ecfe34613758aa39d9eed4a06765e159f5c1a" +} diff --git a/.sqlx/query-636364219261dc971fd27f3410c7f5261c8eccdb9c5d46985dcfb168901c7f99.json b/.sqlx/query-636364219261dc971fd27f3410c7f5261c8eccdb9c5d46985dcfb168901c7f99.json new file mode 100644 index 0000000..78f28e7 --- /dev/null +++ b/.sqlx/query-636364219261dc971fd27f3410c7f5261c8eccdb9c5d46985dcfb168901c7f99.json @@ -0,0 +1,165 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO accounts (id, owner_id, name, description, tags, labels, max_allowed_debt, balance)\n VALUES ($1, $2, $3, $4, $5, $6, $7, 0)\n RETURNING id, owner_id, name, description, tags,\n labels as \"labels: Json>\",\n max_allowed_debt, balance,\n status as \"status: AccountStatus\",\n created_at, updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "accounts", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "owner_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "owner_id" + } + } + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "name" + } + } + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "description" + } + } + }, + { + "ordinal": 4, + "name": "tags", + "type_info": "TextArray", + "origin": { + "Table": { + "table": "accounts", + "name": "tags" + } + } + }, + { + "ordinal": 5, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "accounts", + "name": "labels" + } + } + }, + { + "ordinal": 6, + "name": "max_allowed_debt", + "type_info": "Int4", + "origin": { + "Table": { + "table": "accounts", + "name": "max_allowed_debt" + } + } + }, + { + "ordinal": 7, + "name": "balance", + "type_info": "Int4", + "origin": { + "Table": { + "table": "accounts", + "name": "balance" + } + } + }, + { + "ordinal": 8, + "name": "status: AccountStatus", + "type_info": { + "Custom": { + "name": "account_status", + "kind": { + "Enum": [ + "ACTIVE", + "DEACTIVATED", + "DELETED" + ] + } + } + }, + "origin": { + "Table": { + "table": "accounts", + "name": "status" + } + } + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "accounts", + "name": "created_at" + } + } + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "accounts", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "TextArray", + "Jsonb", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "636364219261dc971fd27f3410c7f5261c8eccdb9c5d46985dcfb168901c7f99" +} diff --git a/.sqlx/query-6ebbd7a12d8d6d876a2782e39214e94746fe357b800e55b459a55ed18ce4012f.json b/.sqlx/query-6ebbd7a12d8d6d876a2782e39214e94746fe357b800e55b459a55ed18ce4012f.json new file mode 100644 index 0000000..331b937 --- /dev/null +++ b/.sqlx/query-6ebbd7a12d8d6d876a2782e39214e94746fe357b800e55b459a55ed18ce4012f.json @@ -0,0 +1,126 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, payment_id,\n from_status as \"from_status?: PaymentStatus\",\n to_status as \"to_status: PaymentStatus\",\n timestamp, reason, actor\n FROM payment_events\n WHERE payment_id = $1\n ORDER BY timestamp ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payment_events", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "payment_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payment_events", + "name": "payment_id" + } + } + }, + { + "ordinal": 2, + "name": "from_status?: PaymentStatus", + "type_info": { + "Custom": { + "name": "payment_status", + "kind": { + "Enum": [ + "PENDING", + "SUCCEEDED", + "FAILED", + "CANCELED", + "PROCESSING" + ] + } + } + }, + "origin": { + "Table": { + "table": "payment_events", + "name": "from_status" + } + } + }, + { + "ordinal": 3, + "name": "to_status: PaymentStatus", + "type_info": { + "Custom": { + "name": "payment_status", + "kind": { + "Enum": [ + "PENDING", + "SUCCEEDED", + "FAILED", + "CANCELED", + "PROCESSING" + ] + } + } + }, + "origin": { + "Table": { + "table": "payment_events", + "name": "to_status" + } + } + }, + { + "ordinal": 4, + "name": "timestamp", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "payment_events", + "name": "timestamp" + } + } + }, + { + "ordinal": 5, + "name": "reason", + "type_info": "Text", + "origin": { + "Table": { + "table": "payment_events", + "name": "reason" + } + } + }, + { + "ordinal": 6, + "name": "actor", + "type_info": "Text", + "origin": { + "Table": { + "table": "payment_events", + "name": "actor" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + true, + true + ] + }, + "hash": "6ebbd7a12d8d6d876a2782e39214e94746fe357b800e55b459a55ed18ce4012f" +} diff --git a/.sqlx/query-71d7792964adf4c7a3205b1a0352a07d6395db5962eefc71b989320ad5066d93.json b/.sqlx/query-71d7792964adf4c7a3205b1a0352a07d6395db5962eefc71b989320ad5066d93.json new file mode 100644 index 0000000..97ac48a --- /dev/null +++ b/.sqlx/query-71d7792964adf4c7a3205b1a0352a07d6395db5962eefc71b989320ad5066d93.json @@ -0,0 +1,196 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE payments\n SET status = $2,\n operation_id = COALESCE($3, operation_id),\n updated_at = now()\n WHERE id = $1\n RETURNING id, account_id, amount,\n method as \"method?: PaymentMethod\",\n purpose as \"purpose: PaymentPurpose\",\n status as \"status: PaymentStatus\",\n operation_id,\n labels as \"labels: Json>\",\n reason, created_at, updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "account_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "account_id" + } + } + }, + { + "ordinal": 2, + "name": "amount", + "type_info": "Int4", + "origin": { + "Table": { + "table": "payments", + "name": "amount" + } + } + }, + { + "ordinal": 3, + "name": "method?: PaymentMethod", + "type_info": { + "Custom": { + "name": "payment_method", + "kind": { + "Enum": [ + "ACCOUNT_BALANCE", + "EXTERNAL" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "method" + } + } + }, + { + "ordinal": 4, + "name": "purpose: PaymentPurpose", + "type_info": { + "Custom": { + "name": "payment_purpose", + "kind": { + "Enum": [ + "PURCHASE", + "TOP_UP" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "purpose" + } + } + }, + { + "ordinal": 5, + "name": "status: PaymentStatus", + "type_info": { + "Custom": { + "name": "payment_status", + "kind": { + "Enum": [ + "PENDING", + "SUCCEEDED", + "FAILED", + "CANCELED", + "PROCESSING" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "status" + } + } + }, + { + "ordinal": 6, + "name": "operation_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "operation_id" + } + } + }, + { + "ordinal": 7, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "payments", + "name": "labels" + } + } + }, + { + "ordinal": 8, + "name": "reason", + "type_info": "Text", + "origin": { + "Table": { + "table": "payments", + "name": "reason" + } + } + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "payments", + "name": "created_at" + } + } + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "payments", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "payment_status", + "kind": { + "Enum": [ + "PENDING", + "SUCCEEDED", + "FAILED", + "CANCELED", + "PROCESSING" + ] + } + } + }, + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true, + false, + true, + false, + false + ] + }, + "hash": "71d7792964adf4c7a3205b1a0352a07d6395db5962eefc71b989320ad5066d93" +} diff --git a/.sqlx/query-86e1eaeb4080d5a46ea0878aa45b48f5f99f546f6db04d430a9add4911f867d4.json b/.sqlx/query-86e1eaeb4080d5a46ea0878aa45b48f5f99f546f6db04d430a9add4911f867d4.json new file mode 100644 index 0000000..967ad1b --- /dev/null +++ b/.sqlx/query-86e1eaeb4080d5a46ea0878aa45b48f5f99f546f6db04d430a9add4911f867d4.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE accounts\n SET max_allowed_debt = $2, updated_at = now()\n WHERE id = $1\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "accounts", + "name": "id" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Int4" + ] + }, + "nullable": [ + false + ] + }, + "hash": "86e1eaeb4080d5a46ea0878aa45b48f5f99f546f6db04d430a9add4911f867d4" +} diff --git a/.sqlx/query-8ec2b8542675509b5643e286af6ebe7c09ba820e65b99c9db1279c8e9d998b90.json b/.sqlx/query-8ec2b8542675509b5643e286af6ebe7c09ba820e65b99c9db1279c8e9d998b90.json new file mode 100644 index 0000000..4e94127 --- /dev/null +++ b/.sqlx/query-8ec2b8542675509b5643e286af6ebe7c09ba820e65b99c9db1279c8e9d998b90.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT balance, max_allowed_debt FROM accounts WHERE id = $1 FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "balance", + "type_info": "Int4", + "origin": { + "Table": { + "table": "accounts", + "name": "balance" + } + } + }, + { + "ordinal": 1, + "name": "max_allowed_debt", + "type_info": "Int4", + "origin": { + "Table": { + "table": "accounts", + "name": "max_allowed_debt" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "8ec2b8542675509b5643e286af6ebe7c09ba820e65b99c9db1279c8e9d998b90" +} diff --git a/.sqlx/query-94785251e5bcf671b229083b2f4954306c892a8ab377264e5d34f4d675af07f4.json b/.sqlx/query-94785251e5bcf671b229083b2f4954306c892a8ab377264e5d34f4d675af07f4.json new file mode 100644 index 0000000..53da446 --- /dev/null +++ b/.sqlx/query-94785251e5bcf671b229083b2f4954306c892a8ab377264e5d34f4d675af07f4.json @@ -0,0 +1,98 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, account_id, amount, timestamp,\n labels as \"labels: Json>\",\n kind as \"kind: OperationKind\"\n FROM operations\n WHERE account_id = $1\n ORDER BY timestamp DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "operations", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "account_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "operations", + "name": "account_id" + } + } + }, + { + "ordinal": 2, + "name": "amount", + "type_info": "Int4", + "origin": { + "Table": { + "table": "operations", + "name": "amount" + } + } + }, + { + "ordinal": 3, + "name": "timestamp", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operations", + "name": "timestamp" + } + } + }, + { + "ordinal": 4, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operations", + "name": "labels" + } + } + }, + { + "ordinal": 5, + "name": "kind: OperationKind", + "type_info": { + "Custom": { + "name": "operation_kind", + "kind": { + "Enum": [ + "DEBIT", + "CREDIT" + ] + } + } + }, + "origin": { + "Table": { + "table": "operations", + "name": "kind" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "94785251e5bcf671b229083b2f4954306c892a8ab377264e5d34f4d675af07f4" +} diff --git a/.sqlx/query-95296c61285ade5500d94bc456fc5275d9f80e709af25c3e18f02e8b42c9337e.json b/.sqlx/query-95296c61285ade5500d94bc456fc5275d9f80e709af25c3e18f02e8b42c9337e.json new file mode 100644 index 0000000..cba96df --- /dev/null +++ b/.sqlx/query-95296c61285ade5500d94bc456fc5275d9f80e709af25c3e18f02e8b42c9337e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE accounts SET balance = balance + $2, updated_at = now() WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "95296c61285ade5500d94bc456fc5275d9f80e709af25c3e18f02e8b42c9337e" +} diff --git a/.sqlx/query-9e5a6e13048bd8b627f573f6f882e0a3628ab504f78f285a34ec7c4d65084eae.json b/.sqlx/query-9e5a6e13048bd8b627f573f6f882e0a3628ab504f78f285a34ec7c4d65084eae.json new file mode 100644 index 0000000..635226e --- /dev/null +++ b/.sqlx/query-9e5a6e13048bd8b627f573f6f882e0a3628ab504f78f285a34ec7c4d65084eae.json @@ -0,0 +1,210 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO payments\n (id, account_id, amount, purpose, status, labels, reason, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())\n RETURNING id, account_id, amount,\n method as \"method?: PaymentMethod\",\n purpose as \"purpose: PaymentPurpose\",\n status as \"status: PaymentStatus\",\n operation_id,\n labels as \"labels: Json>\",\n reason, created_at, updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "account_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "account_id" + } + } + }, + { + "ordinal": 2, + "name": "amount", + "type_info": "Int4", + "origin": { + "Table": { + "table": "payments", + "name": "amount" + } + } + }, + { + "ordinal": 3, + "name": "method?: PaymentMethod", + "type_info": { + "Custom": { + "name": "payment_method", + "kind": { + "Enum": [ + "ACCOUNT_BALANCE", + "EXTERNAL" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "method" + } + } + }, + { + "ordinal": 4, + "name": "purpose: PaymentPurpose", + "type_info": { + "Custom": { + "name": "payment_purpose", + "kind": { + "Enum": [ + "PURCHASE", + "TOP_UP" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "purpose" + } + } + }, + { + "ordinal": 5, + "name": "status: PaymentStatus", + "type_info": { + "Custom": { + "name": "payment_status", + "kind": { + "Enum": [ + "PENDING", + "SUCCEEDED", + "FAILED", + "CANCELED", + "PROCESSING" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "status" + } + } + }, + { + "ordinal": 6, + "name": "operation_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "operation_id" + } + } + }, + { + "ordinal": 7, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "payments", + "name": "labels" + } + } + }, + { + "ordinal": 8, + "name": "reason", + "type_info": "Text", + "origin": { + "Table": { + "table": "payments", + "name": "reason" + } + } + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "payments", + "name": "created_at" + } + } + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "payments", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Int4", + { + "Custom": { + "name": "payment_purpose", + "kind": { + "Enum": [ + "PURCHASE", + "TOP_UP" + ] + } + } + }, + { + "Custom": { + "name": "payment_status", + "kind": { + "Enum": [ + "PENDING", + "SUCCEEDED", + "FAILED", + "CANCELED", + "PROCESSING" + ] + } + } + }, + "Jsonb", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true, + false, + true, + false, + false + ] + }, + "hash": "9e5a6e13048bd8b627f573f6f882e0a3628ab504f78f285a34ec7c4d65084eae" +} diff --git a/.sqlx/query-a2d767d137676710f4b0a5ab4da38be28279a2abe9dc87f10379924cd068bba5.json b/.sqlx/query-a2d767d137676710f4b0a5ab4da38be28279a2abe9dc87f10379924cd068bba5.json new file mode 100644 index 0000000..0c452ee --- /dev/null +++ b/.sqlx/query-a2d767d137676710f4b0a5ab4da38be28279a2abe9dc87f10379924cd068bba5.json @@ -0,0 +1,159 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE accounts\n SET status = 'DELETED', updated_at = now()\n WHERE id = $1\n RETURNING id, owner_id, name, description, tags,\n labels as \"labels: Json>\",\n max_allowed_debt, balance,\n status as \"status: AccountStatus\",\n created_at, updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "accounts", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "owner_id", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "owner_id" + } + } + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "name" + } + } + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text", + "origin": { + "Table": { + "table": "accounts", + "name": "description" + } + } + }, + { + "ordinal": 4, + "name": "tags", + "type_info": "TextArray", + "origin": { + "Table": { + "table": "accounts", + "name": "tags" + } + } + }, + { + "ordinal": 5, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "accounts", + "name": "labels" + } + } + }, + { + "ordinal": 6, + "name": "max_allowed_debt", + "type_info": "Int4", + "origin": { + "Table": { + "table": "accounts", + "name": "max_allowed_debt" + } + } + }, + { + "ordinal": 7, + "name": "balance", + "type_info": "Int4", + "origin": { + "Table": { + "table": "accounts", + "name": "balance" + } + } + }, + { + "ordinal": 8, + "name": "status: AccountStatus", + "type_info": { + "Custom": { + "name": "account_status", + "kind": { + "Enum": [ + "ACTIVE", + "DEACTIVATED", + "DELETED" + ] + } + } + }, + "origin": { + "Table": { + "table": "accounts", + "name": "status" + } + } + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "accounts", + "name": "created_at" + } + } + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "accounts", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "a2d767d137676710f4b0a5ab4da38be28279a2abe9dc87f10379924cd068bba5" +} diff --git a/.sqlx/query-e32c0a3680cef9865747c53b85175e81b5c06a91d20b870a2a0bdecd16793433.json b/.sqlx/query-e32c0a3680cef9865747c53b85175e81b5c06a91d20b870a2a0bdecd16793433.json new file mode 100644 index 0000000..079f7f5 --- /dev/null +++ b/.sqlx/query-e32c0a3680cef9865747c53b85175e81b5c06a91d20b870a2a0bdecd16793433.json @@ -0,0 +1,99 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, account_id, amount, timestamp,\n labels as \"labels: Json>\",\n kind as \"kind: OperationKind\"\n FROM operations\n WHERE account_id = $1 AND id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "operations", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "account_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "operations", + "name": "account_id" + } + } + }, + { + "ordinal": 2, + "name": "amount", + "type_info": "Int4", + "origin": { + "Table": { + "table": "operations", + "name": "amount" + } + } + }, + { + "ordinal": 3, + "name": "timestamp", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "operations", + "name": "timestamp" + } + } + }, + { + "ordinal": 4, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "operations", + "name": "labels" + } + } + }, + { + "ordinal": 5, + "name": "kind: OperationKind", + "type_info": { + "Custom": { + "name": "operation_kind", + "kind": { + "Enum": [ + "DEBIT", + "CREDIT" + ] + } + } + }, + "origin": { + "Table": { + "table": "operations", + "name": "kind" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "e32c0a3680cef9865747c53b85175e81b5c06a91d20b870a2a0bdecd16793433" +} diff --git a/.sqlx/query-e59575be1f746753c4d0547b4495f36839a56e3bc416f334b1491d4143bb95ac.json b/.sqlx/query-e59575be1f746753c4d0547b4495f36839a56e3bc416f334b1491d4143bb95ac.json new file mode 100644 index 0000000..0df4e27 --- /dev/null +++ b/.sqlx/query-e59575be1f746753c4d0547b4495f36839a56e3bc416f334b1491d4143bb95ac.json @@ -0,0 +1,183 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE payments\n SET labels = COALESCE($2, labels),\n reason = COALESCE($3::text, reason),\n updated_at = now()\n WHERE id = $1\n RETURNING id, account_id, amount,\n method as \"method?: PaymentMethod\",\n purpose as \"purpose: PaymentPurpose\",\n status as \"status: PaymentStatus\",\n operation_id,\n labels as \"labels: Json>\",\n reason, created_at, updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "account_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "account_id" + } + } + }, + { + "ordinal": 2, + "name": "amount", + "type_info": "Int4", + "origin": { + "Table": { + "table": "payments", + "name": "amount" + } + } + }, + { + "ordinal": 3, + "name": "method?: PaymentMethod", + "type_info": { + "Custom": { + "name": "payment_method", + "kind": { + "Enum": [ + "ACCOUNT_BALANCE", + "EXTERNAL" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "method" + } + } + }, + { + "ordinal": 4, + "name": "purpose: PaymentPurpose", + "type_info": { + "Custom": { + "name": "payment_purpose", + "kind": { + "Enum": [ + "PURCHASE", + "TOP_UP" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "purpose" + } + } + }, + { + "ordinal": 5, + "name": "status: PaymentStatus", + "type_info": { + "Custom": { + "name": "payment_status", + "kind": { + "Enum": [ + "PENDING", + "SUCCEEDED", + "FAILED", + "CANCELED", + "PROCESSING" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "status" + } + } + }, + { + "ordinal": 6, + "name": "operation_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "operation_id" + } + } + }, + { + "ordinal": 7, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "payments", + "name": "labels" + } + } + }, + { + "ordinal": 8, + "name": "reason", + "type_info": "Text", + "origin": { + "Table": { + "table": "payments", + "name": "reason" + } + } + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "payments", + "name": "created_at" + } + } + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "payments", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Jsonb", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true, + false, + true, + false, + false + ] + }, + "hash": "e59575be1f746753c4d0547b4495f36839a56e3bc416f334b1491d4143bb95ac" +} diff --git a/.sqlx/query-f0f4d0d4b866f1799d88150daac2015b79bdf985972bc4f1f93abb3433083fa3.json b/.sqlx/query-f0f4d0d4b866f1799d88150daac2015b79bdf985972bc4f1f93abb3433083fa3.json new file mode 100644 index 0000000..f298b05 --- /dev/null +++ b/.sqlx/query-f0f4d0d4b866f1799d88150daac2015b79bdf985972bc4f1f93abb3433083fa3.json @@ -0,0 +1,121 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT account_id, amount,\n method as \"method?: PaymentMethod\",\n purpose as \"purpose: PaymentPurpose\",\n status as \"status: PaymentStatus\",\n labels as \"labels: Json>\"\n FROM payments\n WHERE id = $1\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "account_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "payments", + "name": "account_id" + } + } + }, + { + "ordinal": 1, + "name": "amount", + "type_info": "Int4", + "origin": { + "Table": { + "table": "payments", + "name": "amount" + } + } + }, + { + "ordinal": 2, + "name": "method?: PaymentMethod", + "type_info": { + "Custom": { + "name": "payment_method", + "kind": { + "Enum": [ + "ACCOUNT_BALANCE", + "EXTERNAL" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "method" + } + } + }, + { + "ordinal": 3, + "name": "purpose: PaymentPurpose", + "type_info": { + "Custom": { + "name": "payment_purpose", + "kind": { + "Enum": [ + "PURCHASE", + "TOP_UP" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "purpose" + } + } + }, + { + "ordinal": 4, + "name": "status: PaymentStatus", + "type_info": { + "Custom": { + "name": "payment_status", + "kind": { + "Enum": [ + "PENDING", + "SUCCEEDED", + "FAILED", + "CANCELED", + "PROCESSING" + ] + } + } + }, + "origin": { + "Table": { + "table": "payments", + "name": "status" + } + } + }, + { + "ordinal": 5, + "name": "labels: Json>", + "type_info": "Jsonb", + "origin": { + "Table": { + "table": "payments", + "name": "labels" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false + ] + }, + "hash": "f0f4d0d4b866f1799d88150daac2015b79bdf985972bc4f1f93abb3433083fa3" +} diff --git a/.sqlx/query-ff4855ee869993a827ea2ba1eaab00661573da6b69bb5b6f69b29b3fad85b9a1.json b/.sqlx/query-ff4855ee869993a827ea2ba1eaab00661573da6b69bb5b6f69b29b3fad85b9a1.json new file mode 100644 index 0000000..926774d --- /dev/null +++ b/.sqlx/query-ff4855ee869993a827ea2ba1eaab00661573da6b69bb5b6f69b29b3fad85b9a1.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(SUM(CASE kind WHEN 'CREDIT' THEN amount ELSE -amount END), 0)::bigint as \"s!\"\n FROM operations WHERE account_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "s!", + "type_info": "Int8", + "origin": "Expression" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ff4855ee869993a827ea2ba1eaab00661573da6b69bb5b6f69b29b3fad85b9a1" +} diff --git a/Cargo.lock b/Cargo.lock index 0fc1baa..59351cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,39 +7,27 @@ name = "accounting-api" version = "0.1.0" dependencies = [ "axum", - "dotenv", "serde", "serde_json", "sqlx", + "thiserror", "time", "time-macros", "tokio", "tower-http", "tracing", "tracing-subscriber", + "utoipa", + "utoipa-axum", + "utoipa-scalar", "uuid", ] -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" - [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -50,6 +38,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "atoi" version = "2.0.0" @@ -67,15 +61,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" -version = "0.8.3" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de45108900e1f9b9242f7f2e254aa3e2c029c921c258fe9e6b4217eeebd54288" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "axum-macros", @@ -93,8 +87,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -108,9 +101,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.2" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", @@ -119,7 +112,6 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -128,49 +120,28 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", "syn", ] -[[package]] -name = "backtrace" -version = "0.3.74" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" - [[package]] name = "bitflags" -version = "2.9.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -182,6 +153,21 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" @@ -190,15 +176,32 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "cmov" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "concurrent-queue" @@ -210,34 +213,37 @@ dependencies = [ ] [[package]] -name = "const-oid" -version = "0.9.6" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] [[package]] name = "cpufeatures" -version = "0.2.17" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] [[package]] name = "crc" -version = "3.2.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crossbeam-queue" @@ -265,24 +271,31 @@ dependencies = [ ] [[package]] -name = "der" -version = "0.7.9" +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", + "cmov", ] [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -291,29 +304,32 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", + "ctutils", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", "syn", ] -[[package]] -name = "dotenv" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" - [[package]] name = "dotenvy" version = "0.15.7" @@ -322,9 +338,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -335,49 +351,32 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "errno" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "etcetera" -version = "0.8.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "home", - "windows-sys 0.48.0", + "windows-sys", ] [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", "pin-project-lite", ] -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - [[package]] name = "flume" -version = "0.11.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", @@ -396,20 +395,26 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -417,15 +422,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -445,27 +450,27 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", @@ -473,15 +478,14 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -489,38 +493,23 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.3.2" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "rand_core", + "wasip2", + "wasip3", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "h2" -version = "0.4.8" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -537,22 +526,37 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -569,39 +573,29 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ "hmac", ] [[package]] name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.11" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "windows-sys 0.59.0", + "digest 0.11.3", ] [[package]] name = "http" -version = "1.3.1" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -640,15 +634,25 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.6.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", @@ -662,12 +666,11 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.11" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "bytes", - "futures-util", "http", "http-body", "hyper", @@ -678,21 +681,23 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", + "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -701,104 +706,72 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", - "stable_deref_trait", - "tinystr", + "icu_locale_core", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] [[package]] -name = "icu_provider_macros" -version = "1.5.0" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -807,9 +780,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -817,86 +790,90 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.8.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] [[package]] -name = "libc" -version = "0.2.171" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "libm" -version = "0.2.11" +name = "libc" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "pkg-config", "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" - [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.27" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -907,19 +884,19 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mime" @@ -927,78 +904,31 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "miniz_oxide" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" -dependencies = [ - "adler2", -] - [[package]] name = "mio" -version = "1.0.3" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "wasi", + "windows-sys", ] [[package]] name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.4" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "byteorder", - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand", - "smallvec", - "zeroize", + "windows-sys", ] [[package]] name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-traits" @@ -1007,29 +937,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", -] - -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "parking" @@ -1039,9 +953,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -1049,71 +963,50 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] -name = "pem-rfc7468" -version = "0.7.0" +name = "paste" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pkcs1" -version = "0.7.5" +name = "pkg-config" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] -name = "pkcs8" -version = "0.10.2" +name = "potential_utf" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ - "der", - "spki", + "zerovec", ] -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - [[package]] name = "powerfmt" version = "0.2.0" @@ -1121,171 +1014,105 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "zerocopy", + "proc-macro2", + "syn", ] [[package]] name = "proc-macro2" -version = "1.0.94" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.2.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.15", -] - -[[package]] -name = "redox_syscall" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax 0.8.5", + "chacha20", + "getrandom", + "rand_core", ] [[package]] -name = "regex-syntax" -version = "0.6.29" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "regex-syntax" -version = "0.8.5" +name = "redox_syscall" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] [[package]] -name = "rsa" -version = "0.9.8" +name = "regex" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core", - "signature", - "spki", - "subtle", - "zeroize", + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] -name = "rustc-demangle" -version = "0.1.24" +name = "regex-automata" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] [[package]] -name = "rustix" -version = "1.0.5" +name = "regex-syntax" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.59.0", -] +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rustversion" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "scopeguard" @@ -1293,20 +1120,36 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -1315,24 +1158,26 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] name = "serde_path_to_error" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", ] [[package]] @@ -1349,71 +1194,69 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] -name = "sharded-slab" -version = "0.1.7" +name = "sha2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ - "lazy_static", + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] -name = "signature" -version = "2.2.0" +name = "sharded-slab" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ - "digest", - "rand_core", + "lazy_static", ] [[package]] name = "slab" -version = "0.4.9" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] [[package]] name = "socket2" -version = "0.5.9" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys", ] [[package]] @@ -1425,21 +1268,11 @@ dependencies = [ "lock_api", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "sqlx" -version = "0.8.3" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ "sqlx-core", "sqlx-macros", @@ -1450,11 +1283,13 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.3" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ + "base64", "bytes", + "cfg-if", "crc", "crossbeam-queue", "either", @@ -1463,16 +1298,15 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown", + "hashbrown 0.16.1", "hashlink", "indexmap", "log", "memchr", - "once_cell", "percent-encoding", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror", "time", @@ -1485,9 +1319,9 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.3" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" dependencies = [ "proc-macro2", "quote", @@ -1498,79 +1332,63 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.3" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" dependencies = [ + "cfg-if", "dotenvy", "either", "heck", "hex", - "once_cell", "proc-macro2", "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", "syn", - "tempfile", + "thiserror", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.3" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "atoi", - "base64", "bitflags", "byteorder", "bytes", "crc", - "digest", + "digest 0.11.3", "dotenvy", "either", - "futures-channel", "futures-core", - "futures-io", "futures-util", "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", "log", - "md-5", - "memchr", - "once_cell", "percent-encoding", - "rand", - "rsa", "serde", "sha1", - "sha2", - "smallvec", + "sha2 0.11.0", "sqlx-core", - "stringprep", "thiserror", "time", "tracing", "uuid", - "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.8.3" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64", @@ -1585,16 +1403,14 @@ dependencies = [ "hex", "hkdf", "hmac", - "home", "itoa", "log", "md-5", "memchr", - "once_cell", "rand", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "smallvec", "sqlx-core", "stringprep", @@ -1607,12 +1423,13 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.3" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", "flume", + "form_urlencoded", "futures-channel", "futures-core", "futures-executor", @@ -1622,8 +1439,8 @@ dependencies = [ "log", "percent-encoding", "serde", - "serde_urlencoded", "sqlx-core", + "thiserror", "time", "tracing", "url", @@ -1632,9 +1449,9 @@ dependencies = [ [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stringprep" @@ -1647,17 +1464,11 @@ dependencies = [ "unicode-properties", ] -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" -version = "2.0.100" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1672,42 +1483,29 @@ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", "syn", ] -[[package]] -name = "tempfile" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" -dependencies = [ - "fastrand", - "getrandom 0.3.2", - "once_cell", - "rustix", - "windows-sys 0.59.0", -] - [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -1716,40 +1514,39 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", ] [[package]] name = "time" -version = "0.3.41" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -1757,9 +1554,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -1767,9 +1564,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -1782,25 +1579,24 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.44.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ - "backtrace", "bytes", "libc", "mio", "pin-project-lite", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -1809,9 +1605,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -1820,9 +1616,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.14" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -1833,9 +1629,9 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -1849,9 +1645,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", @@ -1875,9 +1671,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -1887,9 +1683,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -1898,9 +1694,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -1919,14 +1715,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -1937,9 +1733,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.18.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-bidi" @@ -1949,42 +1745,43 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] [[package]] name = "unicode-properties" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -1992,13 +1789,65 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "uuid" -version = "1.16.0" +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-axum" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c25bae5bccc842449ec0c5ddc5cbb6a3a1eaeac4503895dc105a1138f8234a0" +dependencies = [ + "axum", + "paste", + "tower-layer", + "tower-service", + "utoipa", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", + "uuid", +] + +[[package]] +name = "utoipa-scalar" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" dependencies = [ - "getrandom 0.3.2", + "axum", "serde", + "serde_json", + "utoipa", +] + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", ] [[package]] @@ -2021,233 +1870,234 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen 0.57.1", ] [[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - -[[package]] -name = "whoami" -version = "1.6.0" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "redox_syscall", - "wasite", + "wit-bindgen 0.51.0", ] [[package]] -name = "winapi" -version = "0.3.9" +name = "wasm-bindgen" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-sys" -version = "0.48.0" +name = "wasm-bindgen-macro" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ - "windows-targets 0.48.5", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "windows-sys" -version = "0.52.0" +name = "wasm-bindgen-macro-support" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ - "windows-targets 0.52.6", + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", ] [[package]] -name = "windows-sys" -version = "0.59.0" +name = "wasm-bindgen-shared" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ - "windows-targets 0.52.6", + "unicode-ident", ] [[package]] -name = "windows-targets" -version = "0.48.5" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", + "leb128fmt", + "wasmparser", ] [[package]] -name = "windows-targets" -version = "0.52.6" +name = "wasm-metadata" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" +name = "wasmparser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] [[package]] -name = "windows_i686_msvc" -version = "0.48.5" +name = "whoami" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" [[package]] -name = "windows_i686_msvc" -version = "0.52.6" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] [[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" +name = "wit-bindgen-rust" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" +name = "wit-bindgen-rust-macro" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ + "anyhow", "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] -name = "write16" -version = "1.0.0" +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -2255,9 +2105,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -2265,40 +2115,20 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -2307,16 +2137,21 @@ dependencies = [ ] [[package]] -name = "zeroize" -version = "1.8.1" +name = "zerotrie" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -2325,11 +2160,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index a3668c9..3c9cd6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,8 @@ version = "0.1.0" edition = "2021" [dependencies] -tokio = { version = "1.44.1", features = ["sync", "rt-multi-thread", "macros"] } -axum = { version = "0.8.1", features = [ +tokio = { version = "1.52", features = ["sync", "rt-multi-thread", "macros"] } +axum = { version = "0.8.9", features = [ "http1", "http2", "json", @@ -13,20 +13,24 @@ axum = { version = "0.8.1", features = [ "tracing", "macros", ] } -sqlx = { version = "0.8.3", features = [ +sqlx = { version = "0.9.0", features = [ "runtime-tokio", "time", "postgres", "uuid", "json", - "macros" + "macros", + "migrate", ] } -tracing = "0.1.41" -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -uuid = { version = "1.16.0", features = ["v4", "serde"] } -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.140" -time = { version = "0.3.41", features = ["serde"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +uuid = { version = "1.23", features = ["v4", "serde"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +time = { version = "0.3.47", features = ["serde", "serde-well-known"] } time-macros = "0.2.22" -dotenv = "0.15.0" -tower-http = { version = "0.6", features = ["cors"] } \ No newline at end of file +tower-http = { version = "0.6.11", features = ["cors"] } +thiserror = "2.0" +utoipa = { version = "5.5", features = ["axum_extras", "uuid", "time"] } +utoipa-axum = "0.2" +utoipa-scalar = { version = "0.3", features = ["axum"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bc8ef6 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# accounting-api + +An HTTP service for tracking customer **accounts**, the money that moves in and +out of them, and the **payments** that drive those movements. + +## Overview + +The service manages prepaid-style accounts and the payments against them: + +- **Accounts** — open an account, give it a name, tags and labels, and set how + far it is allowed to go into debt. Accounts can be listed and searched + (for example: accounts in debt, accounts above or below a given balance, + by status, owner or tag). +- **Balance & history** — every credit or debit is kept as a permanent entry, + so an account always has a complete, auditable history of how its balance + came to be. Spending is automatically refused once it would push the account + past its allowed debt. +- **Payments** — a payment represents either a **purchase** (spending) or a + **top-up** (adding funds). A payment is first created as a pending request, + and is then paid using a chosen method: + - **from the account balance** — settled immediately; + - **by an external method** — held for an administrator to validate before it + completes. + + Payments that can't be settled (e.g. not enough funds) can simply be retried, + and each payment keeps a full timeline of what happened to it. +- **Administration & audit** — administrators can deactivate or reactivate an + account and adjust its allowed debt. Every administrative action is recorded + in a searchable audit log. + +The full list of routes is in [`SPECS.md`](./SPECS.md). + +## API documentation + +The service serves its own OpenAPI documentation: + +- **Interactive docs (Scalar UI):** `GET /api/docs/ui` +- **OpenAPI spec (JSON):** `GET /api/docs/openapi.json` + +With the default configuration these are at + and +. + +## Tech stack + +- Rust (edition 2021), **`rustc` ≥ 1.94**. +- [axum](https://github.com/tokio-rs/axum) for HTTP. +- [sqlx](https://github.com/launchbadge/sqlx) with **compile-time-checked + queries** against PostgreSQL. +- [utoipa](https://github.com/juhaku/utoipa) + Scalar for the OpenAPI docs. + +## Prerequisites + +- Rust toolchain ≥ 1.94. +- A PostgreSQL database (v12+), reachable both to build and to run. + +## Configuration + +Configuration is read from environment variables (the application does **not** +load a `.env` file at runtime — export the variables, or use your process +manager). A template is provided in [`.env.example`](./.env.example). + +| Variable | Used at | Default | Description | +| ----------------------- | -------- | ----------- | ---------------------------------------------------- | +| `DATABASE_URL` | build | — | PostgreSQL connection string the sqlx macros check queries against at compile time. | +| `POSTGRESQL_ADDON_URI` | run | — | PostgreSQL connection string used by the running service. | +| `HOST` | run | `0.0.0.0` | Bind address. | +| `PORT` | run | `3000` | Bind port. | +| `RUST_LOG` | run | `info` | Log filter (e.g. `info,accounting_api=debug`). | + +## Build + +Queries are verified at compile time, so building requires `DATABASE_URL` to +point at a reachable PostgreSQL database: + +```sh +export DATABASE_URL=postgres://:@:5432/ +cargo build +``` + +## Run + +Running requires a reachable PostgreSQL database; pass its connection string via +`POSTGRESQL_ADDON_URI`. Database migrations (in `migrations/`) are applied +automatically on startup. + +```sh +export POSTGRESQL_ADDON_URI=postgres://:@:5432/ +export HOST=0.0.0.0 PORT=3000 RUST_LOG=info +cargo run +``` + +The server then listens on `http://$HOST:$PORT`, with the docs at +`/api/docs/ui`. + +## Tests + +Integration tests use [`#[sqlx::test]`](https://docs.rs/sqlx), which provisions +an isolated database per test, so a reachable PostgreSQL is required: + +```sh +export DATABASE_URL=postgres://:@:5432/ +cargo test +``` diff --git a/SPECS.md b/SPECS.md index 02cd98d..63e61a1 100644 --- a/SPECS.md +++ b/SPECS.md @@ -16,7 +16,7 @@ - `GET /accounts/{id}/operations` - `GET /accounts/{id}/operations/{operation_id}` -- `POST /accounts/{id}/operations` +- `POST /accounts/{id}/operations` — append a CREDIT or DEBIT operation; the only way balance changes. For DEBIT, rejected unless `amount <= balance + max_allowed_debt`. Operations are append-only (no update/delete). - - `GET /accounts/{id}/admin-actions` - `GET /accounts/{id}/admin-actions/{admin_action_id}` @@ -26,26 +26,30 @@ ### Admin actions -- `PUT /accounts/{id}/deactivate` -- `PUT /accounts/{id}/reactivate` -- `PUT /accounts/{id}/max-debt-allowed` - -### Operations - -- `PUT /accounts/{id}/balance` +- `POST /accounts/{id}/deactivate` +- `POST /accounts/{id}/reactivate` +- `POST /accounts/{id}/max-debt-allowed` # Payments ## Base payment routes -- `GET /payments` -- `POST /payments` +A payment is created **without a method** — it is a `PENDING` intent for a given `purpose` (PURCHASE | TOP_UP). The method (ACCOUNT_BALANCE | EXTERNAL) is chosen later, at pay-time. Lifecycle: `PENDING` → `PROCESSING` (paid) → `SUCCEEDED` / `FAILED` / `CANCELED`. + +- `GET /payments` — filterable (account, status, method, purpose, amount range, date range) +- `POST /payments` — create a method-less `PENDING` payment (`account_id`, `amount`, `purpose`, labels, reason). - `GET /payments/{id}` -- `DELETE /payments/{id}` -- `PATCH /payments/{id}` -- `PUT /payments/{id}` +- `PATCH /payments/{id}` — metadata only (labels, reason) +- `GET /payments/{id}/events` — append-only status-transition audit log + +## Pay & transition routes -## Advanced payment routes +- `POST /payments/{id}/pay` — choose the `method` and pay (PENDING → PROCESSING). + - `ACCOUNT_BALANCE` resolves synchronously: PURCHASE debits (funds-checked) → SUCCEEDED; insufficient funds reverts to PENDING (method cleared, retryable, 422); TOP_UP is invalid → 422. + - `EXTERNAL` stays PROCESSING, awaiting an admin confirm/reject. +- `POST /payments/{id}/confirm` — admin: PROCESSING → SUCCEEDED (EXTERNAL+TOP_UP credits the account). +- `POST /payments/{id}/reject` — admin: PROCESSING → FAILED. +- `POST /payments/{id}/cancel` — PENDING or PROCESSING → CANCELED. -- `PUT /payments/{id}/status` +Each transition enforces the state machine (illegal → 409). diff --git a/migrations/20250401202713_initialize_tables.sql b/migrations/20250401202713_initialize_tables.sql index d7cc0de..59c846f 100644 --- a/migrations/20250401202713_initialize_tables.sql +++ b/migrations/20250401202713_initialize_tables.sql @@ -1,4 +1,4 @@ --- Add migration script here +-- Core accounting tables. Labels use jsonb (mapped to HashMap in Rust). CREATE TYPE account_status AS ENUM ('ACTIVE', 'DEACTIVATED', 'DELETED'); CREATE TABLE IF NOT EXISTS accounts @@ -8,32 +8,42 @@ CREATE TABLE IF NOT EXISTS accounts name text not null, description text, tags text[] not null, - labels hstore not null, + labels jsonb not null default '{}'::jsonb, max_allowed_debt int not null, balance int not null, - status account_status not null default 'ACTIVE' + status account_status not null default 'ACTIVE', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() ); +CREATE INDEX idx_accounts_balance ON accounts (balance); +CREATE INDEX idx_accounts_status ON accounts (status); + CREATE TYPE operation_kind AS ENUM ('DEBIT', 'CREDIT'); +-- Append-only ledger. balance on accounts must always equal sum(CREDIT) - sum(DEBIT) here. CREATE TABLE IF NOT EXISTS operations ( id uuid primary key, account_id uuid references accounts (id) not null, amount int not null, timestamp timestamptz not null, - labels hstore not null, + labels jsonb not null default '{}'::jsonb, kind operation_kind not null ); +CREATE INDEX idx_operations_account_id ON operations (account_id); + CREATE TABLE IF NOT EXISTS admin_actions ( id uuid primary key, account_id uuid references accounts (id) not null, timestamp timestamptz not null, - labels hstore not null, + labels jsonb not null default '{}'::jsonb, reason text not null, kind text not null, - data jsonb not null default '{}' + data jsonb not null default '{}'::jsonb, + actor text ); +CREATE INDEX idx_admin_actions_account_id ON admin_actions (account_id); diff --git a/migrations/20250402090000_payments.sql b/migrations/20250402090000_payments.sql new file mode 100644 index 0000000..c5c63ad --- /dev/null +++ b/migrations/20250402090000_payments.sql @@ -0,0 +1,37 @@ +CREATE TYPE payment_method AS ENUM ('ACCOUNT_BALANCE', 'EXTERNAL'); +CREATE TYPE payment_purpose AS ENUM ('PURCHASE', 'TOP_UP'); +CREATE TYPE payment_status AS ENUM ('PENDING', 'SUCCEEDED', 'FAILED', 'CANCELED'); + +CREATE TABLE IF NOT EXISTS payments +( + id uuid primary key, + account_id uuid references accounts (id) not null, + amount int not null, + method payment_method not null, + purpose payment_purpose not null, + status payment_status not null, + operation_id uuid references operations (id), + labels jsonb not null default '{}'::jsonb, + reason text, + created_at timestamptz not null, + updated_at timestamptz not null +); + +CREATE INDEX idx_payments_account_id ON payments (account_id); +CREATE INDEX idx_payments_status ON payments (status); +CREATE INDEX idx_payments_method ON payments (method); +CREATE INDEX idx_payments_created_at ON payments (created_at); + +-- Append-only audit log of every payment status transition. +CREATE TABLE IF NOT EXISTS payment_events +( + id uuid primary key, + payment_id uuid references payments (id) not null, + from_status payment_status, + to_status payment_status not null, + timestamp timestamptz not null, + reason text, + actor text +); + +CREATE INDEX idx_payment_events_payment_id ON payment_events (payment_id); diff --git a/migrations/20250403090000_payment_deferred_method.sql b/migrations/20250403090000_payment_deferred_method.sql new file mode 100644 index 0000000..7c9f3a9 --- /dev/null +++ b/migrations/20250403090000_payment_deferred_method.sql @@ -0,0 +1,5 @@ +-- Payment method is now chosen at pay-time, not at creation: a payment is +-- created as a method-less PENDING intent, then paid (method selected), moving +-- through PROCESSING before reaching a terminal status. +ALTER TYPE payment_status ADD VALUE IF NOT EXISTS 'PROCESSING'; +ALTER TABLE payments ALTER COLUMN method DROP NOT NULL; diff --git a/src/db/account.rs b/src/db/account.rs index 8c2954e..47c364d 100644 --- a/src/db/account.rs +++ b/src/db/account.rs @@ -1,108 +1,155 @@ +use sqlx::types::Json; use uuid::Uuid; + use crate::db::DatabaseClient; -use crate::models::account::Account; +use crate::errors::AccountingError; +use crate::models::account::{ + Account, AccountStatus, CreateAccountRequest, MetadataRequest, PatchAccountRequest, +}; impl DatabaseClient { - pub async fn get_accounts(&self) -> Result, sqlx::Error> { - let accounts = sqlx::query_as::<_, Account>("SELECT * FROM accounts") + /// Filtered listing. Dynamic `WHERE` clauses require the runtime + /// `QueryBuilder` (sqlx's compile-time macros can't express them). + pub async fn list_accounts( + &self, + filter: &crate::filters::AccountFilter, + ) -> Result, AccountingError> { + let accounts = filter + .build() + .build_query_as::() .fetch_all(&self.pool) .await?; Ok(accounts) } - pub async fn get_account_by_id(&self, account_id: Uuid) -> Result, sqlx::Error> { - let account = sqlx::query_as::<_, Account>("SELECT * FROM accounts WHERE id = $1") - .bind(account_id) - .fetch_all(&self.pool) - .await?; + + pub async fn get_account_by_id( + &self, + account_id: Uuid, + ) -> Result, AccountingError> { + let account = sqlx::query_as!( + Account, + r#" + SELECT id, owner_id, name, description, tags, + labels as "labels: Json>", + max_allowed_debt, balance, + status as "status: AccountStatus", + created_at, updated_at + FROM accounts + WHERE id = $1 + "#, + account_id + ) + .fetch_optional(&self.pool) + .await?; Ok(account) } - pub async fn create_account(&self, account: Account) -> Result { - let new_account = sqlx::query_as::<_, Account>( - "INSERT INTO accounts ( - id, - owner_id, - name, - description, - tags, - labels, - max_allowed_debt, - balance, - status - ) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) - RETURNING id, owner_id, name, description, tags, labels, max_allowed_debt, balance, status" + + pub async fn create_account( + &self, + req: CreateAccountRequest, + ) -> Result { + let account = sqlx::query_as!( + Account, + r#" + INSERT INTO accounts (id, owner_id, name, description, tags, labels, max_allowed_debt, balance) + VALUES ($1, $2, $3, $4, $5, $6, $7, 0) + RETURNING id, owner_id, name, description, tags, + labels as "labels: Json>", + max_allowed_debt, balance, + status as "status: AccountStatus", + created_at, updated_at + "#, + Uuid::new_v4(), + req.owner_id, + req.name, + req.description, + &req.tags, + Json(req.labels) as _, + req.max_allowed_debt, ) - .bind(account.id) - .bind(account.owner_id) - .bind(account.name) - .bind(account.description) - .bind(account.tags) - .bind(account.labels) - .bind(account.max_allowed_debt) - .bind(account.balance) - .bind(account.status) .fetch_one(&self.pool) .await?; - Ok(new_account) + Ok(account) } - pub async fn delete_account(&self, account_id: Uuid) -> Result, sqlx::Error> { - let account = sqlx::query_as::<_, Account>( - "UPDATE accounts - SET status = 'DELETED' + + pub async fn patch_account( + &self, + account_id: Uuid, + req: PatchAccountRequest, + ) -> Result, AccountingError> { + // Static query with COALESCE: balance, status and max_allowed_debt are + // intentionally not patchable here (operations / admin-actions own them). + let account = sqlx::query_as!( + Account, + r#" + UPDATE accounts + SET name = COALESCE($2::text, name), + description = COALESCE($3::text, description), + tags = COALESCE($4::text[], tags), + updated_at = now() WHERE id = $1 - RETURNING id, owner_id, name, description, tags, labels, max_allowed_debt, balance, status" + RETURNING id, owner_id, name, description, tags, + labels as "labels: Json>", + max_allowed_debt, balance, + status as "status: AccountStatus", + created_at, updated_at + "#, + account_id, + req.name, + req.description, + req.tags.as_deref(), ) - .bind(account_id) - .fetch_all(&self.pool) + .fetch_optional(&self.pool) .await?; Ok(account) } - pub async fn patch_account(&self, account: Account) -> Result { - let updated_account = sqlx::query_as::<_, Account>( - "UPDATE accounts - SET name = $2, - description = $3, - tags = $4, - labels = $5, - max_allowed_debt = $6, - balance = $7, - status = $8 - WHERE id = $1 RETURNING id, owner_id, name, description, tags, labels, max_allowed_debt, balance, status" + + pub async fn set_account_metadata( + &self, + account_id: Uuid, + req: MetadataRequest, + ) -> Result, AccountingError> { + let account = sqlx::query_as!( + Account, + r#" + UPDATE accounts + SET labels = $2, tags = $3, updated_at = now() + WHERE id = $1 + RETURNING id, owner_id, name, description, tags, + labels as "labels: Json>", + max_allowed_debt, balance, + status as "status: AccountStatus", + created_at, updated_at + "#, + account_id, + Json(req.labels) as _, + &req.tags, ) - .bind(account.id) - .bind(account.name) - .bind(account.description) - .bind(account.tags) - .bind(account.labels) - .bind(account.max_allowed_debt) - .bind(account.balance) - .bind(account.status) - .fetch_one(&self.pool) + .fetch_optional(&self.pool) .await?; - Ok(updated_account) + Ok(account) } - // pub async fn put_account_metadata(Path(id): Path) -> String { - // format!("Put metadata for account {id}") - // } - - // pub async fn get_account_operations(Path(id): Path) -> String { - // format!("Get operations for account {id}") - // } - - // pub async fn put_account_operations(Path(id): Path) -> String { - // format!("Put operations for account {id}") - // } - - // pub async fn post_account_operations(Path(id): Path) -> String { - // format!("Post operations for account {id}") - // } - - // `GET /accounts/{id}/operations` - // - `GET /accounts/{id}/operations/{operation_id}` - // - `POST /accounts/{id}/operations` - // - - // - `GET /accounts/{id}/admin-actions` - // - `GET /accounts/{id}/admin-actions/{admin_action_id}` - // - `POST /accounts/{id}/admin-actions` -} \ No newline at end of file + pub async fn delete_account( + &self, + account_id: Uuid, + ) -> Result, AccountingError> { + let account = sqlx::query_as!( + Account, + r#" + UPDATE accounts + SET status = 'DELETED', updated_at = now() + WHERE id = $1 + RETURNING id, owner_id, name, description, tags, + labels as "labels: Json>", + max_allowed_debt, balance, + status as "status: AccountStatus", + created_at, updated_at + "#, + account_id + ) + .fetch_optional(&self.pool) + .await?; + Ok(account) + } +} diff --git a/src/db/admin_actions.rs b/src/db/admin_actions.rs index b2eda6e..daf4c1d 100644 --- a/src/db/admin_actions.rs +++ b/src/db/admin_actions.rs @@ -1,20 +1,25 @@ -use crate::db::errors::RepositoryError; -use crate::errors::AccountingError; -use crate::models::admin_action::{AdminAction, AdminActionKind}; use serde::{Deserialize, Serialize}; +use sqlx::types::Json; use std::collections::HashMap; use time::OffsetDateTime; use uuid::Uuid; -#[derive(Debug, Serialize, Deserialize)] +use crate::db::errors::RepositoryError; +use crate::db::DatabaseClient; +use crate::errors::AccountingError; +use crate::models::admin_action::{AdminAction, AdminActionKind}; +use crate::models::Labels; + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct AdminActionEntity { pub id: Uuid, pub account_id: Uuid, pub timestamp: OffsetDateTime, - pub labels: HashMap, + pub labels: Labels, pub reason: String, pub kind: String, pub data: serde_json::Value, + pub actor: Option, } impl From for AdminActionEntity { @@ -36,10 +41,11 @@ impl From for AdminActionEntity { id: action.id, account_id: action.account_id, timestamp: action.timestamp, - labels: action.labels, + labels: Json(action.labels), reason: action.reason, kind, data, + actor: action.actor, } } } @@ -56,7 +62,7 @@ impl TryFrom for AdminAction { .data .get("value") .and_then(|v| v.as_u64()) - .ok_or_else(|| RepositoryError::InvalidMaxDebtValue.into())?; + .ok_or(RepositoryError::InvalidMaxDebtValue)?; AdminActionKind::MaxDebtUpdated { value } } kind => return Err(RepositoryError::InvalidActionKind(kind.to_string()).into()), @@ -66,9 +72,145 @@ impl TryFrom for AdminAction { id: entity.id, account_id: entity.account_id, timestamp: entity.timestamp, - labels: entity.labels, + labels: entity.labels.0, reason: entity.reason, action, + actor: entity.actor, }) } } + +impl DatabaseClient { + /// Apply an admin action's side effect to the account and record the action, + /// atomically in a single transaction. + pub async fn apply_admin_action( + &self, + account_id: Uuid, + action: AdminActionKind, + reason: String, + labels: HashMap, + ) -> Result { + let mut tx = self.pool.begin().await?; + + // 1) Apply the side effect to the account, then build the typed action. + let admin_action = match action { + AdminActionKind::AccountDeactivated => { + sqlx::query!( + r#" + UPDATE accounts + SET status = 'DEACTIVATED', updated_at = now() + WHERE id = $1 + RETURNING id + "#, + account_id + ) + .fetch_optional(&mut *tx) + .await? + .ok_or(AccountingError::NotFound)?; + + AdminAction::new_account_deactivated(account_id, labels.clone(), reason.clone()) + } + AdminActionKind::AccountReactivated => { + sqlx::query!( + r#" + UPDATE accounts + SET status = 'ACTIVE', updated_at = now() + WHERE id = $1 + RETURNING id + "#, + account_id + ) + .fetch_optional(&mut *tx) + .await? + .ok_or(AccountingError::NotFound)?; + + AdminAction::new_account_reactivated(account_id, labels.clone(), reason.clone()) + } + AdminActionKind::MaxDebtUpdated { value } => { + let max_debt = i32::try_from(value).map_err(|_| { + AccountingError::Validation(format!( + "max_allowed_debt value {value} does not fit in i32" + )) + })?; + + sqlx::query!( + r#" + UPDATE accounts + SET max_allowed_debt = $2, updated_at = now() + WHERE id = $1 + RETURNING id + "#, + account_id, + max_debt + ) + .fetch_optional(&mut *tx) + .await? + .ok_or(AccountingError::NotFound)?; + + AdminAction::new_max_debt_updated(account_id, value, labels.clone(), reason.clone()) + } + }; + + // 2) Record the admin action. Convert to the entity (the storage shape), + // insert it, then convert back so we return exactly what was persisted. + let entity = AdminActionEntity::from(admin_action); + sqlx::query!( + r#" + INSERT INTO admin_actions (id, account_id, timestamp, labels, reason, kind, data, actor) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + "#, + entity.id, + entity.account_id, + entity.timestamp, + Json(&entity.labels.0) as _, + entity.reason, + entity.kind, + Json(&entity.data) as _, + entity.actor, + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + AdminAction::try_from(entity) + } + + /// Filtered audit listing across all accounts. Dynamic `WHERE` → runtime + /// `QueryBuilder` (the entity derives `FromRow` for `build_query_as`). + pub async fn list_audit_actions( + &self, + filter: &crate::filters::AdminActionFilter, + ) -> Result, AccountingError> { + let entities = filter + .build() + .build_query_as::() + .fetch_all(&self.pool) + .await?; + + entities + .into_iter() + .map(AdminAction::try_from) + .collect::, _>>() + } + + pub async fn get_audit_action(&self, id: Uuid) -> Result, AccountingError> { + let entity = sqlx::query_as!( + AdminActionEntity, + r#" + SELECT id, account_id, timestamp, + labels as "labels: Json>", + reason, kind, + data as "data: serde_json::Value", + actor + FROM admin_actions + WHERE id = $1 + "#, + id + ) + .fetch_optional(&self.pool) + .await?; + + entity.map(AdminAction::try_from).transpose() + } +} diff --git a/src/db/errors.rs b/src/db/errors.rs index 301470b..155bbfa 100644 --- a/src/db/errors.rs +++ b/src/db/errors.rs @@ -1,29 +1,11 @@ -use crate::errors::AccountingError; -use serde::{Deserialize, Serialize}; -use std::error::Error; -use std::fmt::{Display, Formatter}; +use thiserror::Error; -#[derive(Debug, Serialize, Deserialize)] +/// Errors raised by the repository/conversion layer (e.g. decoding a stored +/// admin-action row back into its typed form). +#[derive(Debug, Error)] pub enum RepositoryError { + #[error("invalid action kind: {0}")] InvalidActionKind(String), + #[error("invalid max debt value")] InvalidMaxDebtValue, } - -impl Display for RepositoryError { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - RepositoryError::InvalidActionKind(kind) => { - write!(f, "Invalid action kind: {}", kind) - } - RepositoryError::InvalidMaxDebtValue => write!(f, "Invalid max debt value"), - } - } -} - -impl Error for RepositoryError {} - -impl Into for RepositoryError { - fn into(self) -> AccountingError { - AccountingError::RepositoryError(self) - } -} diff --git a/src/db/mod.rs b/src/db/mod.rs index f71d8fa..3c41832 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,8 +1,10 @@ +pub mod account; pub mod admin_actions; pub mod errors; -pub mod account; +pub mod operation; +pub mod payment; + use sqlx::PgPool; -use sqlx::postgres::PgPoolOptions; #[derive(Clone, Debug)] pub struct DatabaseClient { @@ -10,11 +12,7 @@ pub struct DatabaseClient { } impl DatabaseClient { - fn new(pool: PgPool) -> Self { + pub fn new(pool: PgPool) -> Self { Self { pool } } - - pub async fn connect(uri: &str) -> Option { - PgPoolOptions::new().connect(uri).await.map(Self::new).ok() - } } diff --git a/src/db/operation.rs b/src/db/operation.rs new file mode 100644 index 0000000..4020433 --- /dev/null +++ b/src/db/operation.rs @@ -0,0 +1,132 @@ +use std::collections::HashMap; + +use sqlx::types::Json; +use uuid::Uuid; + +use crate::db::DatabaseClient; +use crate::errors::AccountingError; +use crate::models::operation::{Operation, OperationKind}; + +/// Append a ledger operation **within an existing transaction**, enforcing the +/// debt limit on DEBIT and updating the denormalized account balance so that +/// `balance == sum(CREDIT) - sum(DEBIT)` always holds. Reused by the payment +/// lanes so the ledger move and the payment update commit atomically together. +pub async fn create_operation_tx( + conn: &mut sqlx::PgConnection, + account_id: Uuid, + kind: OperationKind, + amount: i32, + labels: HashMap, +) -> Result { + if amount < 0 { + return Err(AccountingError::Validation( + "operation amount must be non-negative".to_string(), + )); + } + + // Lock the account row to serialize concurrent balance changes. + let account = sqlx::query!( + r#"SELECT balance, max_allowed_debt FROM accounts WHERE id = $1 FOR UPDATE"#, + account_id + ) + .fetch_optional(&mut *conn) + .await? + .ok_or(AccountingError::NotFound)?; + + if matches!(kind, OperationKind::Debit) + && (amount as i64) > account.balance as i64 + account.max_allowed_debt as i64 + { + return Err(AccountingError::InsufficientFunds { amount }); + } + + let operation = sqlx::query_as!( + Operation, + r#" + INSERT INTO operations (id, account_id, amount, timestamp, labels, kind) + VALUES ($1, $2, $3, now(), $4, $5) + RETURNING id, account_id, amount, timestamp, + labels as "labels: Json>", + kind as "kind: OperationKind" + "#, + Uuid::new_v4(), + account_id, + amount, + Json(labels) as _, + kind as OperationKind, + ) + .fetch_one(&mut *conn) + .await?; + + let delta = match kind { + OperationKind::Credit => amount, + OperationKind::Debit => -amount, + }; + sqlx::query!( + r#"UPDATE accounts SET balance = balance + $2, updated_at = now() WHERE id = $1"#, + account_id, + delta + ) + .execute(&mut *conn) + .await?; + + Ok(operation) +} + +impl DatabaseClient { + /// Standalone operation creation (its own transaction). + pub async fn create_operation( + &self, + account_id: Uuid, + kind: OperationKind, + amount: i32, + labels: HashMap, + ) -> Result { + let mut tx = self.pool.begin().await?; + let operation = create_operation_tx(&mut tx, account_id, kind, amount, labels).await?; + tx.commit().await?; + Ok(operation) + } + + pub async fn list_operations( + &self, + account_id: Uuid, + ) -> Result, AccountingError> { + let operations = sqlx::query_as!( + Operation, + r#" + SELECT id, account_id, amount, timestamp, + labels as "labels: Json>", + kind as "kind: OperationKind" + FROM operations + WHERE account_id = $1 + ORDER BY timestamp DESC + "#, + account_id + ) + .fetch_all(&self.pool) + .await?; + Ok(operations) + } + + pub async fn get_operation( + &self, + account_id: Uuid, + operation_id: Uuid, + ) -> Result, AccountingError> { + let operation = sqlx::query_as!( + Operation, + r#" + SELECT id, account_id, amount, timestamp, + labels as "labels: Json>", + kind as "kind: OperationKind" + FROM operations + WHERE account_id = $1 AND id = $2 + "#, + account_id, + operation_id + ) + .fetch_optional(&self.pool) + .await?; + Ok(operation) + } +} diff --git a/src/db/payment.rs b/src/db/payment.rs new file mode 100644 index 0000000..21fa58e --- /dev/null +++ b/src/db/payment.rs @@ -0,0 +1,456 @@ +use std::collections::HashMap; + +use sqlx::types::Json; +use uuid::Uuid; + +use crate::db::operation::create_operation_tx; +use crate::db::DatabaseClient; +use crate::errors::AccountingError; +use crate::models::payment::{ + CreatePaymentRequest, PatchPaymentRequest, Payment, PaymentEvent, PaymentMethod, + PaymentPurpose, PaymentStatus, +}; +use crate::payment::state_machine::{can_transition, ledger_effect, valid_combo}; + +/// Insert a method-less payment row in the given status plus its initiating +/// event, inside an existing transaction. `method`/`operation_id` are left NULL. +async fn insert_payment( + conn: &mut sqlx::PgConnection, + account_id: Uuid, + amount: i32, + purpose: PaymentPurpose, + status: PaymentStatus, + labels: HashMap, + reason: Option, + from: Option, +) -> Result { + let payment = sqlx::query_as!( + Payment, + r#" + INSERT INTO payments + (id, account_id, amount, purpose, status, labels, reason, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now()) + RETURNING id, account_id, amount, + method as "method?: PaymentMethod", + purpose as "purpose: PaymentPurpose", + status as "status: PaymentStatus", + operation_id, + labels as "labels: Json>", + reason, created_at, updated_at + "#, + Uuid::new_v4(), + account_id, + amount, + purpose as PaymentPurpose, + status as PaymentStatus, + Json(labels) as _, + reason, + ) + .fetch_one(&mut *conn) + .await?; + + insert_payment_event(&mut *conn, payment.id, from, status, payment.reason.clone()).await?; + Ok(payment) +} + +/// Append an immutable status-transition event for a payment. +async fn insert_payment_event( + conn: &mut sqlx::PgConnection, + payment_id: Uuid, + from: Option, + to: PaymentStatus, + reason: Option, +) -> Result<(), AccountingError> { + sqlx::query!( + r#" + INSERT INTO payment_events + (id, payment_id, from_status, to_status, timestamp, reason, actor) + VALUES ($1, $2, $3, $4, now(), $5, NULL) + "#, + Uuid::new_v4(), + payment_id, + from as Option, + to as PaymentStatus, + reason, + ) + .execute(&mut *conn) + .await?; + Ok(()) +} + +/// PENDING → PROCESSING: record the chosen method. +async fn set_processing_tx( + conn: &mut sqlx::PgConnection, + payment_id: Uuid, + method: PaymentMethod, +) -> Result { + let payment = sqlx::query_as!( + Payment, + r#" + UPDATE payments + SET method = $2, status = 'PROCESSING', updated_at = now() + WHERE id = $1 + RETURNING id, account_id, amount, + method as "method?: PaymentMethod", + purpose as "purpose: PaymentPurpose", + status as "status: PaymentStatus", + operation_id, + labels as "labels: Json>", + reason, created_at, updated_at + "#, + payment_id, + method as PaymentMethod, + ) + .fetch_one(&mut *conn) + .await?; + Ok(payment) +} + +/// Set a terminal status (and optionally the settling operation), keeping method. +async fn finalize_status_tx( + conn: &mut sqlx::PgConnection, + payment_id: Uuid, + status: PaymentStatus, + operation_id: Option, +) -> Result { + let payment = sqlx::query_as!( + Payment, + r#" + UPDATE payments + SET status = $2, + operation_id = COALESCE($3, operation_id), + updated_at = now() + WHERE id = $1 + RETURNING id, account_id, amount, + method as "method?: PaymentMethod", + purpose as "purpose: PaymentPurpose", + status as "status: PaymentStatus", + operation_id, + labels as "labels: Json>", + reason, created_at, updated_at + "#, + payment_id, + status as PaymentStatus, + operation_id, + ) + .fetch_one(&mut *conn) + .await?; + Ok(payment) +} + +/// PROCESSING → PENDING revert: clear the method so the payment is re-payable. +async fn revert_pending_tx( + conn: &mut sqlx::PgConnection, + payment_id: Uuid, +) -> Result { + let payment = sqlx::query_as!( + Payment, + r#" + UPDATE payments + SET method = NULL, status = 'PENDING', updated_at = now() + WHERE id = $1 + RETURNING id, account_id, amount, + method as "method?: PaymentMethod", + purpose as "purpose: PaymentPurpose", + status as "status: PaymentStatus", + operation_id, + labels as "labels: Json>", + reason, created_at, updated_at + "#, + payment_id, + ) + .fetch_one(&mut *conn) + .await?; + Ok(payment) +} + +impl DatabaseClient { + /// Create a payment as a method-less PENDING intent. No ledger effect yet. + pub async fn create_payment( + &self, + req: CreatePaymentRequest, + ) -> Result { + if req.amount < 0 { + return Err(AccountingError::Validation( + "payment amount must be non-negative".into(), + )); + } + + sqlx::query!(r#"SELECT id FROM accounts WHERE id = $1"#, req.account_id) + .fetch_optional(&self.pool) + .await? + .ok_or(AccountingError::NotFound)?; + + let mut tx = self.pool.begin().await?; + let payment = insert_payment( + &mut tx, + req.account_id, + req.amount, + req.purpose, + PaymentStatus::Pending, + req.labels, + req.reason, + None, + ) + .await?; + tx.commit().await?; + Ok(payment) + } + + /// Pay a PENDING payment by choosing a method. ACCOUNT_BALANCE resolves + /// synchronously (PENDING→PROCESSING→SUCCEEDED, or revert to PENDING on + /// insufficient funds); EXTERNAL is left PROCESSING for admin confirmation. + pub async fn pay_payment( + &self, + payment_id: Uuid, + method: PaymentMethod, + ) -> Result { + let mut tx = self.pool.begin().await?; + + let current = sqlx::query!( + r#" + SELECT account_id, amount, + purpose as "purpose: PaymentPurpose", + status as "status: PaymentStatus", + labels as "labels: Json>" + FROM payments + WHERE id = $1 + FOR UPDATE + "#, + payment_id + ) + .fetch_optional(&mut *tx) + .await? + .ok_or(AccountingError::NotFound)?; + + if current.status != PaymentStatus::Pending { + return Err(AccountingError::InvalidTransition { + from: current.status.to_string(), + to: PaymentStatus::Processing.to_string(), + }); + } + if !valid_combo(method, current.purpose) { + return Err(AccountingError::InvalidCombination( + "ACCOUNT_BALANCE payments cannot be TOP_UP".into(), + )); + } + + // PENDING -> PROCESSING (record the chosen method). + let processing = set_processing_tx(&mut tx, payment_id, method).await?; + insert_payment_event( + &mut tx, + payment_id, + Some(PaymentStatus::Pending), + PaymentStatus::Processing, + None, + ) + .await?; + + match method { + // Synchronous: apply the ledger move now and finalize. + PaymentMethod::AccountBalance => { + let kind = ledger_effect(method, current.purpose) + .expect("ACCOUNT_BALANCE+PURCHASE always has a ledger effect"); + match create_operation_tx( + &mut tx, + current.account_id, + kind, + current.amount, + current.labels.0.clone(), + ) + .await + { + Ok(op) => { + let payment = finalize_status_tx( + &mut tx, + payment_id, + PaymentStatus::Succeeded, + Some(op.id), + ) + .await?; + insert_payment_event( + &mut tx, + payment_id, + Some(PaymentStatus::Processing), + PaymentStatus::Succeeded, + None, + ) + .await?; + tx.commit().await?; + Ok(payment) + } + // The funds check fails before any ledger write, so the tx + // stays valid: revert to PENDING + clear method (retryable). + Err(AccountingError::InsufficientFunds { amount }) => { + revert_pending_tx(&mut tx, payment_id).await?; + insert_payment_event( + &mut tx, + payment_id, + Some(PaymentStatus::Processing), + PaymentStatus::Pending, + Some("insufficient funds (ACCOUNT_BALANCE)".into()), + ) + .await?; + tx.commit().await?; + Err(AccountingError::InsufficientFunds { amount }) + } + Err(e) => Err(e), + } + } + // Asynchronous: remains PROCESSING until an admin confirm/reject. + PaymentMethod::External => { + tx.commit().await?; + Ok(processing) + } + } + } + + /// Generic confirm/reject/cancel transition. On reaching SUCCEEDED the + /// ledger effect runs (external top-up credits the account). + pub async fn transition_payment( + &self, + payment_id: Uuid, + to: PaymentStatus, + reason: Option, + ) -> Result { + let mut tx = self.pool.begin().await?; + + let current = sqlx::query!( + r#" + SELECT account_id, amount, + method as "method?: PaymentMethod", + purpose as "purpose: PaymentPurpose", + status as "status: PaymentStatus", + labels as "labels: Json>" + FROM payments + WHERE id = $1 + FOR UPDATE + "#, + payment_id + ) + .fetch_optional(&mut *tx) + .await? + .ok_or(AccountingError::NotFound)?; + + if !can_transition(current.status, to) { + return Err(AccountingError::InvalidTransition { + from: current.status.to_string(), + to: to.to_string(), + }); + } + + let operation_id: Option = if to == PaymentStatus::Succeeded { + let method = current.method.ok_or_else(|| { + AccountingError::Validation("payment has no method to settle".into()) + })?; + match ledger_effect(method, current.purpose) { + Some(kind) => { + let op = create_operation_tx( + &mut tx, + current.account_id, + kind, + current.amount, + current.labels.0.clone(), + ) + .await?; + Some(op.id) + } + None => None, + } + } else { + None + }; + + let payment = finalize_status_tx(&mut tx, payment_id, to, operation_id).await?; + insert_payment_event(&mut tx, payment_id, Some(current.status), to, reason).await?; + + tx.commit().await?; + Ok(payment) + } + + /// Filtered listing via the runtime `QueryBuilder` (dynamic `WHERE`). + pub async fn list_payments( + &self, + filter: &crate::filters::PaymentFilter, + ) -> Result, AccountingError> { + let payments = filter + .build() + .build_query_as::() + .fetch_all(&self.pool) + .await?; + Ok(payments) + } + + pub async fn get_payment(&self, id: Uuid) -> Result, AccountingError> { + let payment = sqlx::query_as!( + Payment, + r#" + SELECT id, account_id, amount, + method as "method?: PaymentMethod", + purpose as "purpose: PaymentPurpose", + status as "status: PaymentStatus", + operation_id, + labels as "labels: Json>", + reason, created_at, updated_at + FROM payments + WHERE id = $1 + "#, + id + ) + .fetch_optional(&self.pool) + .await?; + Ok(payment) + } + + pub async fn list_payment_events( + &self, + payment_id: Uuid, + ) -> Result, AccountingError> { + let events = sqlx::query_as!( + PaymentEvent, + r#" + SELECT id, payment_id, + from_status as "from_status?: PaymentStatus", + to_status as "to_status: PaymentStatus", + timestamp, reason, actor + FROM payment_events + WHERE payment_id = $1 + ORDER BY timestamp ASC + "#, + payment_id + ) + .fetch_all(&self.pool) + .await?; + Ok(events) + } + + pub async fn patch_payment( + &self, + id: Uuid, + req: PatchPaymentRequest, + ) -> Result, AccountingError> { + let payment = sqlx::query_as!( + Payment, + r#" + UPDATE payments + SET labels = COALESCE($2, labels), + reason = COALESCE($3::text, reason), + updated_at = now() + WHERE id = $1 + RETURNING id, account_id, amount, + method as "method?: PaymentMethod", + purpose as "purpose: PaymentPurpose", + status as "status: PaymentStatus", + operation_id, + labels as "labels: Json>", + reason, created_at, updated_at + "#, + id, + req.labels.map(Json) as _, + req.reason, + ) + .fetch_optional(&self.pool) + .await?; + Ok(payment) + } +} diff --git a/src/docs.rs b/src/docs.rs new file mode 100644 index 0000000..be93832 --- /dev/null +++ b/src/docs.rs @@ -0,0 +1,20 @@ +use utoipa::OpenApi; + +/// Root OpenAPI document. Path items and their component schemas are collected +/// automatically from the per-slice `OpenApiRouter`s in `routes::api`. +#[derive(OpenApi)] +#[openapi( + info( + title = "Accounting API", + version = "0.1.0", + description = "Accounts, append-only ledger operations, admin actions and payments." + ), + tags( + (name = "accounts", description = "Account lifecycle and metadata"), + (name = "operations", description = "Append-only ledger (CREDIT/DEBIT)"), + (name = "account management", description = "Account administrative actions and shortcuts"), + (name = "audit", description = "Audit log of admin actions, filterable"), + (name = "payments", description = "Payments, their state machine and audit log") + ) +)] +pub struct ApiDoc; diff --git a/src/errors.rs b/src/errors.rs index 63e77a4..4c78f7d 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,18 +1,63 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; +use thiserror::Error; +use tracing::error; + use crate::db::errors::RepositoryError; -use std::error::Error; -use std::fmt::Display; -#[derive(Debug)] +/// Application-wide error type. Implements `IntoResponse` so handlers can simply +/// return `Result` and get a consistent JSON error body. +#[derive(Debug, Error)] pub enum AccountingError { - RepositoryError(RepositoryError), + #[error("resource not found")] + NotFound, + + #[error("insufficient funds: balance plus allowed debt cannot cover an amount of {amount}")] + InsufficientFunds { amount: i32 }, + + #[error("invalid status transition from {from} to {to}")] + InvalidTransition { from: String, to: String }, + + #[error("invalid payment combination: {0}")] + InvalidCombination(String), + + #[error("validation error: {0}")] + Validation(String), + + #[error(transparent)] + Repository(#[from] RepositoryError), + + #[error("database error")] + Db(#[from] sqlx::Error), } -impl Display for AccountingError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl AccountingError { + fn status(&self) -> StatusCode { match self { - AccountingError::RepositoryError(err) => err.fmt(f), + AccountingError::NotFound => StatusCode::NOT_FOUND, + AccountingError::InsufficientFunds { .. } => StatusCode::UNPROCESSABLE_ENTITY, + AccountingError::InvalidTransition { .. } => StatusCode::CONFLICT, + AccountingError::InvalidCombination(_) => StatusCode::UNPROCESSABLE_ENTITY, + AccountingError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY, + AccountingError::Repository(_) => StatusCode::UNPROCESSABLE_ENTITY, + AccountingError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, } } } -impl Error for AccountingError {} +impl IntoResponse for AccountingError { + fn into_response(self) -> Response { + let status = self.status(); + // Log full detail server-side; never leak internal DB errors to clients. + if status == StatusCode::INTERNAL_SERVER_ERROR { + error!("internal error: {self:?}"); + } + let message = match status { + StatusCode::INTERNAL_SERVER_ERROR => "internal server error".to_string(), + _ => self.to_string(), + }; + (status, Json(json!({ "error": message }))).into_response() + } +} diff --git a/src/filters.rs b/src/filters.rs new file mode 100644 index 0000000..97cc972 --- /dev/null +++ b/src/filters.rs @@ -0,0 +1,214 @@ +//! Query-parameter filters for the list endpoints. These build dynamic `WHERE` +//! clauses, which sqlx's compile-time macros cannot express, so they are the one +//! place we use the runtime `QueryBuilder` + `push_bind` (values are always +//! bound, never interpolated; sort columns come from a fixed allow-list). + +use serde::Deserialize; +use sqlx::{Postgres, QueryBuilder}; +use utoipa::IntoParams; + +use uuid::Uuid; + +use crate::models::account::AccountStatus; +use crate::models::payment::{PaymentMethod, PaymentPurpose, PaymentStatus}; + +const DEFAULT_LIMIT: i64 = 100; +const MAX_LIMIT: i64 = 500; + +fn clamp_limit(limit: Option) -> i64 { + limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT) +} + +fn offset(offset: Option) -> i64 { + offset.unwrap_or(0).max(0) +} + +/// Resolve a user-supplied sort direction to `ASC`/`DESC` (defaults to `ASC`). +fn direction(order: Option<&str>) -> &'static str { + match order { + Some(o) if o.eq_ignore_ascii_case("desc") => "DESC", + _ => "ASC", + } +} + +#[derive(Debug, Default, Clone, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] +pub struct AccountFilter { + /// Only accounts with `balance >= min_balance`. + pub min_balance: Option, + /// Only accounts with `balance <= max_balance`. + pub max_balance: Option, + /// Only accounts in debt (`balance < 0`). + pub in_debt: Option, + pub status: Option, + pub owner_id: Option, + /// Case-insensitive substring match on the account name. + pub name: Option, + /// Match accounts carrying this tag. + pub tag: Option, + /// Sort column: one of `balance`, `name`, `created_at` (default `created_at`). + pub sort_by: Option, + /// `asc` or `desc` (default `asc`). + pub order: Option, + pub limit: Option, + pub offset: Option, +} + +impl AccountFilter { + pub fn build(&self) -> QueryBuilder { + let mut qb = QueryBuilder::new("SELECT * FROM accounts WHERE TRUE"); + if let Some(v) = self.min_balance { + qb.push(" AND balance >= ").push_bind(v); + } + if let Some(v) = self.max_balance { + qb.push(" AND balance <= ").push_bind(v); + } + if self.in_debt == Some(true) { + qb.push(" AND balance < 0"); + } + if let Some(status) = &self.status { + qb.push(" AND status = ").push_bind(status.clone()); + } + if let Some(owner) = &self.owner_id { + qb.push(" AND owner_id = ").push_bind(owner.clone()); + } + if let Some(name) = &self.name { + qb.push(" AND name ILIKE ").push_bind(format!("%{name}%")); + } + if let Some(tag) = &self.tag { + qb.push(" AND ").push_bind(tag.clone()).push(" = ANY(tags)"); + } + + let sort_col = match self.sort_by.as_deref() { + Some("balance") => "balance", + Some("name") => "name", + _ => "created_at", + }; + qb.push(format!( + " ORDER BY {sort_col} {}", + direction(self.order.as_deref()) + )); + qb.push(" LIMIT ").push_bind(clamp_limit(self.limit)); + qb.push(" OFFSET ").push_bind(offset(self.offset)); + qb + } +} + +#[derive(Debug, Default, Clone, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] +pub struct PaymentFilter { + pub account_id: Option, + pub status: Option, + pub method: Option, + pub purpose: Option, + pub min_amount: Option, + pub max_amount: Option, + /// RFC3339 timestamp lower bound (inclusive) on `created_at`. + #[serde(default, with = "time::serde::rfc3339::option")] + #[param(value_type = Option, format = DateTime)] + pub created_after: Option, + /// RFC3339 timestamp upper bound (inclusive) on `created_at`. + #[serde(default, with = "time::serde::rfc3339::option")] + #[param(value_type = Option, format = DateTime)] + pub created_before: Option, + /// Sort column: one of `created_at`, `amount` (default `created_at`). + pub sort_by: Option, + /// `asc` or `desc` (default `desc`). + pub order: Option, + pub limit: Option, + pub offset: Option, +} + +impl PaymentFilter { + pub fn build(&self) -> QueryBuilder { + let mut qb = QueryBuilder::new("SELECT * FROM payments WHERE TRUE"); + if let Some(id) = self.account_id { + qb.push(" AND account_id = ").push_bind(id); + } + if let Some(status) = self.status { + qb.push(" AND status = ").push_bind(status); + } + if let Some(method) = self.method { + qb.push(" AND method = ").push_bind(method); + } + if let Some(purpose) = self.purpose { + qb.push(" AND purpose = ").push_bind(purpose); + } + if let Some(v) = self.min_amount { + qb.push(" AND amount >= ").push_bind(v); + } + if let Some(v) = self.max_amount { + qb.push(" AND amount <= ").push_bind(v); + } + if let Some(ts) = self.created_after { + qb.push(" AND created_at >= ").push_bind(ts); + } + if let Some(ts) = self.created_before { + qb.push(" AND created_at <= ").push_bind(ts); + } + + let sort_col = match self.sort_by.as_deref() { + Some("amount") => "amount", + _ => "created_at", + }; + // Payments default to most-recent-first. + let dir = match self.order.as_deref() { + Some(o) if o.eq_ignore_ascii_case("asc") => "ASC", + _ => "DESC", + }; + qb.push(format!(" ORDER BY {sort_col} {dir}")); + qb.push(" LIMIT ").push_bind(clamp_limit(self.limit)); + qb.push(" OFFSET ").push_bind(offset(self.offset)); + qb + } +} + +#[derive(Debug, Default, Clone, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] +pub struct AdminActionFilter { + pub account_id: Option, + /// Action kind, e.g. `AccountDeactivated`, `AccountReactivated`, `MaxDebtUpdated`. + pub kind: Option, + pub actor: Option, + /// RFC3339 timestamp lower bound (inclusive). + #[serde(default, with = "time::serde::rfc3339::option")] + #[param(value_type = Option, format = DateTime)] + pub after: Option, + /// RFC3339 timestamp upper bound (inclusive). + #[serde(default, with = "time::serde::rfc3339::option")] + #[param(value_type = Option, format = DateTime)] + pub before: Option, + /// `asc` or `desc` (default `desc`, i.e. newest first). + pub order: Option, + pub limit: Option, + pub offset: Option, +} + +impl AdminActionFilter { + pub fn build(&self) -> QueryBuilder { + let mut qb = QueryBuilder::new("SELECT * FROM admin_actions WHERE TRUE"); + if let Some(id) = self.account_id { + qb.push(" AND account_id = ").push_bind(id); + } + if let Some(kind) = &self.kind { + qb.push(" AND kind = ").push_bind(kind.clone()); + } + if let Some(actor) = &self.actor { + qb.push(" AND actor = ").push_bind(actor.clone()); + } + if let Some(ts) = self.after { + qb.push(" AND timestamp >= ").push_bind(ts); + } + if let Some(ts) = self.before { + qb.push(" AND timestamp <= ").push_bind(ts); + } + let dir = match self.order.as_deref() { + Some(o) if o.eq_ignore_ascii_case("asc") => "ASC", + _ => "DESC", + }; + qb.push(format!(" ORDER BY timestamp {dir}")); + qb.push(" LIMIT ").push_bind(clamp_limit(self.limit)); + qb.push(" OFFSET ").push_bind(offset(self.offset)); + qb + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..afc9dcc --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +pub mod db; +pub mod docs; +pub mod errors; +pub mod filters; +pub mod models; +pub mod payment; +pub mod routes; diff --git a/src/main.rs b/src/main.rs index d86b6c0..366a726 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,62 +1,53 @@ -pub mod models; -pub mod routes; -pub mod db; -pub mod errors; -use std::path::Path; -use sqlx::migrate::Migrator; +use accounting_api::db::DatabaseClient; +use accounting_api::routes; use sqlx::postgres::PgPoolOptions; use tracing::{error, info}; -use crate::db::DatabaseClient; -use dotenv::dotenv; -pub fn env_get(env: &'static str) -> String { - let env_panic = |e| { - error!("{env} is not set ({})", e); +/// Read a required environment variable, exiting with a clear message if unset. +fn env_get(env: &'static str) -> String { + std::env::var(env).unwrap_or_else(|e| { + error!("{env} is not set ({e})"); std::process::exit(1); - }; - - std::env::var(env).map_err(env_panic).unwrap() + }) } +/// Read an optional environment variable, falling back to `default`. +fn env_or(env: &str, default: &str) -> String { + std::env::var(env).unwrap_or_else(|_| default.to_string()) +} #[tokio::main] async fn main() { - dotenv().ok(); + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + let postgresql_uri = env_get("POSTGRESQL_ADDON_URI"); - info!("Connecting to database"); + let host = env_or("HOST", "0.0.0.0"); + let port = env_or("PORT", "3000"); + let addr = format!("{host}:{port}"); - // Create a connection pool + info!("Connecting to database"); let pool = PgPoolOptions::new() .max_connections(5) .connect(&postgresql_uri) .await - .expect("Failed to create pool"); + .expect("Failed to create database pool"); - // Run migrations - let migrator = Migrator::new(Path::new("migrations")).await.unwrap(); - migrator.run(&pool).await.unwrap(); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); info!("Database migrations applied"); - - let db = match DatabaseClient::connect(&postgresql_uri).await { - Some(db) => { - info!("Connected to database!"); - db - } - None => { - error!("Failed to connect to database"); - std::process::exit(1); - } - }; - let app = routes::api(axum::extract::State(db)).await.into_make_service(); - // let app = api::app( - // ApiHandlerState::new(ApiHandler { - // db, - // sessions: Arc::new(RwLock::new(HashMap::new())), - // }) - // ); - // _ = db.clean_invitations().await; - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); - println!("Server running on http://127.0.0.1:3000"); - axum::serve(listener, app).await.unwrap(); -} \ No newline at end of file + let db = DatabaseClient::new(pool); + let app = routes::api(db); + + let listener = tokio::net::TcpListener::bind(&addr) + .await + .unwrap_or_else(|e| panic!("Failed to bind {addr}: {e}")); + info!("Server running on http://{addr}"); + axum::serve(listener, app).await.expect("Server error"); +} diff --git a/src/models/account.rs b/src/models/account.rs index d5978c2..b1b5558 100644 --- a/src/models/account.rs +++ b/src/models/account.rs @@ -1,75 +1,76 @@ +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; -use sqlx::{Type}; -// use std::collections::HashMap; +use time::OffsetDateTime; +use utoipa::ToSchema; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] -#[sqlx(type_name = "account_status")] -#[sqlx(rename_all = "SCREAMING_SNAKE_CASE")] +use crate::models::Labels; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type, ToSchema)] +#[sqlx(type_name = "account_status", rename_all = "SCREAMING_SNAKE_CASE")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum AccountStatus { Active, Deactivated, Deleted, } -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +/// An account row as returned by the API. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, sqlx::FromRow)] pub struct Account { pub id: Uuid, pub owner_id: String, pub name: String, pub description: Option, pub tags: Vec, - pub labels: Option, // HashMap, + #[schema(value_type = HashMap)] + pub labels: Labels, pub max_allowed_debt: i32, pub balance: i32, pub status: AccountStatus, + #[serde(with = "time::serde::rfc3339")] + #[schema(value_type = String, format = DateTime)] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + #[schema(value_type = String, format = DateTime)] + pub updated_at: OffsetDateTime, +} + +/// Body for `POST /accounts`. The server owns `id`, `balance` (starts at 0 and +/// only ever moves via operations) and `status` (starts `ACTIVE`). +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct CreateAccountRequest { + pub owner_id: String, + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub labels: HashMap, + #[serde(default)] + pub max_allowed_debt: i32, } -impl Account { - pub fn new( - id: Uuid, - owner_id: String, - name: String, - description: Option, - labels: Option, // HashMap, - tags: Vec, - max_allowed_debt: i32, - balance: i32, - ) -> Self { - Self { - id, - owner_id, - name, - description, - tags, - labels, - max_allowed_debt, - balance, - status: AccountStatus::Active, - } - } +/// Body for `PATCH /accounts/{id}`. All fields optional; balance, status and +/// max_allowed_debt are intentionally not editable here (operations and +/// admin-actions — e.g. PUT /max-debt-allowed — own those). +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct PatchAccountRequest { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub tags: Option>, +} - fn new_with_status( - id: Uuid, - owner_id: String, - name: String, - description: Option, - labels: Option, // HashMap, - tags: Vec, - max_allowed_debt: i32, - balance: i32, - status: AccountStatus, - ) -> Self { - Self { - id, - owner_id, - name, - description, - tags, - labels, - max_allowed_debt, - balance, - status, - } - } +/// Body for `PUT /accounts/{id}/metadata`. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct MetadataRequest { + #[serde(default)] + pub labels: HashMap, + #[serde(default)] + pub tags: Vec, } diff --git a/src/models/admin_action.rs b/src/models/admin_action.rs index 51d21e9..ccebbc9 100644 --- a/src/models/admin_action.rs +++ b/src/models/admin_action.rs @@ -1,19 +1,23 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use time::OffsetDateTime; +use utoipa::ToSchema; use uuid::Uuid; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct AdminAction { pub id: Uuid, pub account_id: Uuid, + #[serde(with = "time::serde::rfc3339")] + #[schema(value_type = String, format = DateTime)] pub timestamp: OffsetDateTime, pub labels: HashMap, pub reason: String, pub action: AdminActionKind, + pub actor: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub enum AdminActionKind { AccountDeactivated, AccountReactivated, @@ -33,6 +37,7 @@ impl AdminAction { labels, reason, action: AdminActionKind::AccountDeactivated, + actor: None, } } @@ -48,6 +53,7 @@ impl AdminAction { labels, reason, action: AdminActionKind::AccountReactivated, + actor: None, } } @@ -64,6 +70,26 @@ impl AdminAction { labels, reason, action: AdminActionKind::MaxDebtUpdated { value }, + actor: None, } } } + +/// Body for the deactivate / reactivate shortcut endpoints. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct ShortcutRequest { + #[serde(default)] + pub reason: String, + #[serde(default)] + pub labels: HashMap, +} + +/// Body for `PUT /accounts/{account_id}/max-debt-allowed`. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct MaxDebtRequest { + pub value: u64, + #[serde(default)] + pub reason: String, + #[serde(default)] + pub labels: HashMap, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index bb74398..613eaac 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,16 @@ pub mod account; -pub mod operation; pub mod admin_action; +pub mod operation; +pub mod payment; + +use std::collections::HashMap; + +/// Labels are stored as a `jsonb` column and mapped to a string→string map. +/// sqlx requires the `Json` wrapper to decode/encode `jsonb`; it (de)serializes +/// transparently as the inner map, so API bodies see a plain object. +pub type Labels = sqlx::types::Json>; + +/// Convenience constructor for an empty label set. +pub fn empty_labels() -> Labels { + sqlx::types::Json(HashMap::new()) +} diff --git a/src/models/operation.rs b/src/models/operation.rs index e6a8788..b2a9240 100644 --- a/src/models/operation.rs +++ b/src/models/operation.rs @@ -1,44 +1,39 @@ -use serde::{Deserialize, Serialize}; -use sqlx::Type; use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; use time::OffsetDateTime; +use utoipa::ToSchema; use uuid::Uuid; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] -#[sqlx(type_name = "operation_kind")] +use crate::models::Labels; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type, ToSchema)] +#[sqlx(type_name = "operation_kind", rename_all = "SCREAMING_SNAKE_CASE")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum OperationKind { - #[sqlx(rename = "DEBIT")] Debit, - #[sqlx(rename = "CREDIT")] Credit, } -#[derive(Debug, Clone, Serialize, Deserialize)] +/// An append-only ledger entry. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct Operation { pub id: Uuid, pub account_id: Uuid, pub amount: i32, + #[serde(with = "time::serde::rfc3339")] + #[schema(value_type = String, format = DateTime)] pub timestamp: OffsetDateTime, - pub labels: HashMap, + #[schema(value_type = HashMap)] + pub labels: Labels, pub kind: OperationKind, } -impl Operation { - pub fn new( - id: Uuid, - account_id: Uuid, - amount: i32, - timestamp: OffsetDateTime, - labels: HashMap, - kind: OperationKind, - ) -> Self { - Self { - id, - account_id, - amount, - timestamp, - labels, - kind, - } - } -} \ No newline at end of file +/// Body for `POST /accounts/{id}/operations`. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct CreateOperationRequest { + pub kind: OperationKind, + pub amount: i32, + #[serde(default)] + pub labels: HashMap, +} diff --git a/src/models/payment.rs b/src/models/payment.rs new file mode 100644 index 0000000..2eed1b4 --- /dev/null +++ b/src/models/payment.rs @@ -0,0 +1,119 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::models::Labels; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type, ToSchema)] +#[sqlx(type_name = "payment_method", rename_all = "SCREAMING_SNAKE_CASE")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PaymentMethod { + AccountBalance, + External, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type, ToSchema)] +#[sqlx(type_name = "payment_purpose", rename_all = "SCREAMING_SNAKE_CASE")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PaymentPurpose { + Purchase, + TopUp, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type, ToSchema)] +#[sqlx(type_name = "payment_status", rename_all = "SCREAMING_SNAKE_CASE")] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PaymentStatus { + Pending, + Processing, + Succeeded, + Failed, + Canceled, +} + +impl std::fmt::Display for PaymentStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + PaymentStatus::Pending => "PENDING", + PaymentStatus::Processing => "PROCESSING", + PaymentStatus::Succeeded => "SUCCEEDED", + PaymentStatus::Failed => "FAILED", + PaymentStatus::Canceled => "CANCELED", + }; + f.write_str(s) + } +} + +/// A payment row as returned by the API. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, sqlx::FromRow)] +pub struct Payment { + pub id: Uuid, + pub account_id: Uuid, + pub amount: i32, + /// Chosen at pay-time; `None` while the payment is an unpaid PENDING intent. + pub method: Option, + pub purpose: PaymentPurpose, + pub status: PaymentStatus, + pub operation_id: Option, + #[schema(value_type = HashMap)] + pub labels: Labels, + pub reason: Option, + #[serde(with = "time::serde::rfc3339")] + #[schema(value_type = String, format = DateTime)] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + #[schema(value_type = String, format = DateTime)] + pub updated_at: OffsetDateTime, +} + +/// An immutable record of a single payment status transition. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct PaymentEvent { + pub id: Uuid, + pub payment_id: Uuid, + pub from_status: Option, + pub to_status: PaymentStatus, + #[serde(with = "time::serde::rfc3339")] + #[schema(value_type = String, format = DateTime)] + pub timestamp: OffsetDateTime, + pub reason: Option, + pub actor: Option, +} + +/// Body for `POST /payments`. A payment is created without a method — it is a +/// pending intent until paid. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct CreatePaymentRequest { + pub account_id: Uuid, + pub amount: i32, + pub purpose: PaymentPurpose, + #[serde(default)] + pub labels: HashMap, + #[serde(default)] + pub reason: Option, +} + +/// Body for `POST /payments/{id}/pay` — choose the method and pay. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct PayPaymentRequest { + pub method: PaymentMethod, +} + +/// Body for the confirm / reject / cancel transition endpoints (all optional). +#[derive(Debug, Clone, Default, Deserialize, ToSchema)] +pub struct TransitionRequest { + #[serde(default)] + pub reason: Option, +} + +/// Body for `PATCH /payments/{id}` (metadata only). +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct PatchPaymentRequest { + #[serde(default)] + pub labels: Option>, + #[serde(default)] + pub reason: Option, +} diff --git a/src/payment/mod.rs b/src/payment/mod.rs new file mode 100644 index 0000000..69ab14e --- /dev/null +++ b/src/payment/mod.rs @@ -0,0 +1 @@ +pub mod state_machine; diff --git a/src/payment/state_machine.rs b/src/payment/state_machine.rs new file mode 100644 index 0000000..b259432 --- /dev/null +++ b/src/payment/state_machine.rs @@ -0,0 +1,144 @@ +use crate::models::operation::OperationKind; +use crate::models::payment::{PaymentMethod, PaymentPurpose, PaymentStatus}; + +/// A payment method/purpose pairing is valid for every combination except +/// `ACCOUNT_BALANCE` + `TOP_UP` (you cannot top up a balance from itself). +pub fn valid_combo(method: PaymentMethod, purpose: PaymentPurpose) -> bool { + !matches!( + (method, purpose), + (PaymentMethod::AccountBalance, PaymentPurpose::TopUp) + ) +} + +/// Transitions allowed via the generic confirm/reject/cancel endpoints. A paid +/// (PROCESSING) payment may reach a terminal state; an unpaid (PENDING) payment +/// may only be canceled. (PENDING→PROCESSING and the PROCESSING→PENDING revert +/// are driven directly by `pay`, not through this guard.) +pub fn can_transition(from: PaymentStatus, to: PaymentStatus) -> bool { + matches!( + (from, to), + (PaymentStatus::Processing, PaymentStatus::Succeeded) + | (PaymentStatus::Processing, PaymentStatus::Failed) + | (PaymentStatus::Processing, PaymentStatus::Canceled) + | (PaymentStatus::Pending, PaymentStatus::Canceled) + ) +} + +/// The ledger move applied when a payment reaches `SUCCEEDED`: +/// `ACCOUNT_BALANCE` + `PURCHASE` debits the account, `EXTERNAL` + `TOP_UP` +/// credits it, and every other valid combination has no ledger effect. +pub fn ledger_effect(method: PaymentMethod, purpose: PaymentPurpose) -> Option { + match (method, purpose) { + (PaymentMethod::AccountBalance, PaymentPurpose::Purchase) => Some(OperationKind::Debit), + (PaymentMethod::External, PaymentPurpose::TopUp) => Some(OperationKind::Credit), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_combo_matrix() { + assert!(valid_combo( + PaymentMethod::AccountBalance, + PaymentPurpose::Purchase + )); + assert!(valid_combo( + PaymentMethod::External, + PaymentPurpose::Purchase + )); + assert!(valid_combo(PaymentMethod::External, PaymentPurpose::TopUp)); + // The only invalid combination. + assert!(!valid_combo( + PaymentMethod::AccountBalance, + PaymentPurpose::TopUp + )); + } + + #[test] + fn ledger_effect_matrix() { + assert_eq!( + ledger_effect(PaymentMethod::AccountBalance, PaymentPurpose::Purchase), + Some(OperationKind::Debit) + ); + assert_eq!( + ledger_effect(PaymentMethod::External, PaymentPurpose::TopUp), + Some(OperationKind::Credit) + ); + assert_eq!( + ledger_effect(PaymentMethod::External, PaymentPurpose::Purchase), + None + ); + // Invalid combination has no ledger effect either. + assert_eq!( + ledger_effect(PaymentMethod::AccountBalance, PaymentPurpose::TopUp), + None + ); + } + + #[test] + fn confirm_reject_cancel_transitions() { + // PROCESSING (paid) → terminal, and PENDING → CANCELED. + assert!(can_transition( + PaymentStatus::Processing, + PaymentStatus::Succeeded + )); + assert!(can_transition( + PaymentStatus::Processing, + PaymentStatus::Failed + )); + assert!(can_transition( + PaymentStatus::Processing, + PaymentStatus::Canceled + )); + assert!(can_transition( + PaymentStatus::Pending, + PaymentStatus::Canceled + )); + } + + #[test] + fn pending_cannot_be_finalized_via_guard() { + // A PENDING payment must be paid first; it can't be confirmed/rejected, + // and pay()/revert transitions are not exposed through this guard. + assert!(!can_transition( + PaymentStatus::Pending, + PaymentStatus::Succeeded + )); + assert!(!can_transition( + PaymentStatus::Pending, + PaymentStatus::Failed + )); + assert!(!can_transition( + PaymentStatus::Pending, + PaymentStatus::Processing + )); + assert!(!can_transition( + PaymentStatus::Processing, + PaymentStatus::Pending + )); + } + + #[test] + fn no_transition_from_terminal() { + let terminals = [ + PaymentStatus::Succeeded, + PaymentStatus::Failed, + PaymentStatus::Canceled, + ]; + let targets = [ + PaymentStatus::Pending, + PaymentStatus::Processing, + PaymentStatus::Succeeded, + PaymentStatus::Failed, + PaymentStatus::Canceled, + ]; + for from in terminals { + for to in targets { + assert!(!can_transition(from, to), "{from} -> {to} must be illegal"); + } + } + } +} diff --git a/src/routes/account.rs b/src/routes/account.rs index 5ade8f3..a548dde 100644 --- a/src/routes/account.rs +++ b/src/routes/account.rs @@ -1,51 +1,109 @@ -// temporary file to hold the code for the accounts module - -use axum::Router; -use axum::routing::{get, post, delete, patch, put}; -use crate::db::{self}; -use axum::extract::{State}; -use axum::{Json}; -use crate::models::account::Account; +use axum::extract::{Path, Query, State}; use axum::http::StatusCode; -use axum::extract::rejection::JsonRejection; -use axum::response::IntoResponse; - - -pub async fn get_accounts(State(db): State) -> Result>, axum::http::StatusCode> { - match db.get_accounts().await { - Ok(accounts) => { - println!("Fetched accounts: {:?}", accounts); - Ok(Json(accounts)) - }, - Err(_e) => Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR), - } +use axum::Json; +use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::routes; +use uuid::Uuid; + +use crate::db::DatabaseClient; +use crate::errors::AccountingError; +use crate::filters::AccountFilter; +use crate::models::account::{Account, CreateAccountRequest, MetadataRequest, PatchAccountRequest}; + +#[utoipa::path( + get, path = "/accounts", tag = "accounts", + params(AccountFilter), + responses((status = 200, description = "Matching accounts", body = Vec)) +)] +pub async fn list_accounts( + State(db): State, + Query(filter): Query, +) -> Result>, AccountingError> { + Ok(Json(db.list_accounts(&filter).await?)) } +#[utoipa::path( + post, path = "/accounts", tag = "accounts", + request_body = CreateAccountRequest, + responses((status = 201, description = "Account created", body = Account)) +)] pub async fn create_account( - State(db): State, - payload: Result, JsonRejection> -) -> impl IntoResponse { - match payload { - Ok(Json(account)) => match db.create_account(account).await { - Ok(new_account) => Json(new_account).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {}", e)).into_response(), - }, - Err(rejection) => { - println!("Deserialization error: {:?}", rejection); - (StatusCode::UNPROCESSABLE_ENTITY, format!("Invalid payload: {:?}", rejection)).into_response() - } - } + State(db): State, + Json(req): Json, +) -> Result<(StatusCode, Json), AccountingError> { + let account = db.create_account(req).await?; + Ok((StatusCode::CREATED, Json(account))) +} + +#[utoipa::path( + get, path = "/accounts/{id}", tag = "accounts", + params(("id" = Uuid, Path, description = "Account id")), + responses((status = 200, body = Account), (status = 404, description = "Not found")) +)] +pub async fn get_account( + State(db): State, + Path(id): Path, +) -> Result, AccountingError> { + db.get_account_by_id(id) + .await? + .map(Json) + .ok_or(AccountingError::NotFound) } -pub fn account_router(db: db::DatabaseClient) -> Router<()> { - Router::new() - .route("/", get(get_accounts)) - .route("/", post(create_account)) - // .route("/{id}", get(get_account_by_id)) - // .route("/{id}", delete(delete_account)) - // .route("/{id}", patch(patch_account)) - // .route("/{id}/metadata", put(put_account_metadata)) - .with_state(db) +#[utoipa::path( + patch, path = "/accounts/{id}", tag = "accounts", + params(("id" = Uuid, Path, description = "Account id")), + request_body = PatchAccountRequest, + responses((status = 200, body = Account), (status = 404, description = "Not found")) +)] +pub async fn patch_account( + State(db): State, + Path(id): Path, + Json(req): Json, +) -> Result, AccountingError> { + db.patch_account(id, req) + .await? + .map(Json) + .ok_or(AccountingError::NotFound) +} + +#[utoipa::path( + delete, path = "/accounts/{id}", tag = "accounts", + params(("id" = Uuid, Path, description = "Account id")), + responses((status = 200, description = "Soft-deleted account", body = Account), (status = 404, description = "Not found")) +)] +pub async fn delete_account( + State(db): State, + Path(id): Path, +) -> Result, AccountingError> { + db.delete_account(id) + .await? + .map(Json) + .ok_or(AccountingError::NotFound) +} +#[utoipa::path( + put, path = "/accounts/{id}/metadata", tag = "accounts", + params(("id" = Uuid, Path, description = "Account id")), + request_body = MetadataRequest, + responses((status = 200, body = Account), (status = 404, description = "Not found")) +)] +pub async fn put_metadata( + State(db): State, + Path(id): Path, + Json(req): Json, +) -> Result, AccountingError> { + db.set_account_metadata(id, req) + .await? + .map(Json) + .ok_or(AccountingError::NotFound) } +/// Absolute-path routes for this slice, generic over `DatabaseClient` state. +/// State is applied centrally in `routes::api`. +pub fn routes() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(list_accounts, create_account)) + .routes(routes!(get_account, patch_account, delete_account)) + .routes(routes!(put_metadata)) +} diff --git a/src/routes/admin_action.rs b/src/routes/admin_action.rs new file mode 100644 index 0000000..3fd70b3 --- /dev/null +++ b/src/routes/admin_action.rs @@ -0,0 +1,83 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::routes; +use uuid::Uuid; + +use crate::db::DatabaseClient; +use crate::errors::AccountingError; +use crate::models::admin_action::{AdminAction, AdminActionKind, MaxDebtRequest, ShortcutRequest}; + +#[utoipa::path( + post, path = "/accounts/{account_id}/deactivate", tag = "account management", + params(("account_id" = Uuid, Path)), + request_body = ShortcutRequest, + responses((status = 201, body = AdminAction), (status = 404)) +)] +pub async fn deactivate_account( + State(db): State, + Path(account_id): Path, + Json(req): Json, +) -> Result<(StatusCode, Json), AccountingError> { + let action = db + .apply_admin_action( + account_id, + AdminActionKind::AccountDeactivated, + req.reason, + req.labels, + ) + .await?; + Ok((StatusCode::CREATED, Json(action))) +} + +#[utoipa::path( + post, path = "/accounts/{account_id}/reactivate", tag = "account management", + params(("account_id" = Uuid, Path)), + request_body = ShortcutRequest, + responses((status = 201, body = AdminAction), (status = 404)) +)] +pub async fn reactivate_account( + State(db): State, + Path(account_id): Path, + Json(req): Json, +) -> Result<(StatusCode, Json), AccountingError> { + let action = db + .apply_admin_action( + account_id, + AdminActionKind::AccountReactivated, + req.reason, + req.labels, + ) + .await?; + Ok((StatusCode::CREATED, Json(action))) +} + +#[utoipa::path( + post, path = "/accounts/{account_id}/max-debt-allowed", tag = "account management", + params(("account_id" = Uuid, Path)), + request_body = MaxDebtRequest, + responses((status = 201, body = AdminAction), (status = 404)) +)] +pub async fn set_max_debt_allowed( + State(db): State, + Path(account_id): Path, + Json(req): Json, +) -> Result<(StatusCode, Json), AccountingError> { + let action = db + .apply_admin_action( + account_id, + AdminActionKind::MaxDebtUpdated { value: req.value }, + req.reason, + req.labels, + ) + .await?; + Ok((StatusCode::CREATED, Json(action))) +} + +pub fn routes() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(deactivate_account)) + .routes(routes!(reactivate_account)) + .routes(routes!(set_max_debt_allowed)) +} diff --git a/src/routes/audit.rs b/src/routes/audit.rs new file mode 100644 index 0000000..26e0ca1 --- /dev/null +++ b/src/routes/audit.rs @@ -0,0 +1,43 @@ +use axum::extract::{Path, Query, State}; +use axum::Json; +use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::routes; +use uuid::Uuid; + +use crate::db::DatabaseClient; +use crate::errors::AccountingError; +use crate::filters::AdminActionFilter; +use crate::models::admin_action::AdminAction; + +#[utoipa::path( + get, path = "/audit/actions", tag = "audit", + params(AdminActionFilter), + responses((status = 200, description = "Matching admin actions", body = Vec)) +)] +pub async fn list_actions( + State(db): State, + Query(filter): Query, +) -> Result>, AccountingError> { + Ok(Json(db.list_audit_actions(&filter).await?)) +} + +#[utoipa::path( + get, path = "/audit/actions/{id}", tag = "audit", + params(("id" = Uuid, Path, description = "Admin action id")), + responses((status = 200, body = AdminAction), (status = 404, description = "Not found")) +)] +pub async fn get_action( + State(db): State, + Path(id): Path, +) -> Result, AccountingError> { + db.get_audit_action(id) + .await? + .map(Json) + .ok_or(AccountingError::NotFound) +} + +pub fn routes() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(list_actions)) + .routes(routes!(get_action)) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index ff4870d..320a4dd 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,21 +1,46 @@ pub mod account; +pub mod admin_action; +pub mod audit; +pub mod operation; +pub mod payment; -use axum::extract::State; -use axum::routing::get; -use axum::Router; -use tower_http::cors::{CorsLayer, Any}; use axum::http::header; +use axum::routing::get; +use axum::{Json, Router}; +use tower_http::cors::{Any, CorsLayer}; +use utoipa::OpenApi; +use utoipa_axum::router::OpenApiRouter; +use utoipa_scalar::{Scalar, Servable as _}; + +use crate::db::DatabaseClient; +use crate::docs::ApiDoc; +pub fn api(db: DatabaseClient) -> Router { + // Every slice contributes its paths + schemas to the generated spec. + let (router, openapi) = OpenApiRouter::with_openapi(ApiDoc::openapi()) + .merge(account::routes()) + .merge(operation::routes()) + .merge(admin_action::routes()) + .merge(audit::routes()) + .merge(payment::routes()) + .split_for_parts(); -use crate::db; + let openapi_json = openapi.clone(); -pub async fn api(State(db): State) -> Router<()> { - Router::new() - .nest("/accounts", account::account_router(db)) - .layer( - CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers([header::CONTENT_TYPE]) - ) -} \ No newline at end of file + router + .merge(Scalar::with_url("/api/docs/ui", openapi)) + .route( + "/api/docs/openapi.json", + get(move || { + let spec = openapi_json.clone(); + async move { Json(spec) } + }), + ) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers([header::CONTENT_TYPE]), + ) + .with_state(db) +} diff --git a/src/routes/operation.rs b/src/routes/operation.rs new file mode 100644 index 0000000..4741724 --- /dev/null +++ b/src/routes/operation.rs @@ -0,0 +1,64 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::Json; +use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::routes; +use uuid::Uuid; + +use crate::db::DatabaseClient; +use crate::errors::AccountingError; +use crate::models::operation::{CreateOperationRequest, Operation}; + +#[utoipa::path( + get, path = "/accounts/{account_id}/operations", tag = "operations", + params(("account_id" = Uuid, Path)), + responses((status = 200, body = Vec)) +)] +pub async fn list_operations( + State(db): State, + Path(account_id): Path, +) -> Result>, AccountingError> { + Ok(Json(db.list_operations(account_id).await?)) +} + +#[utoipa::path( + post, path = "/accounts/{account_id}/operations", tag = "operations", + params(("account_id" = Uuid, Path)), + request_body = CreateOperationRequest, + responses( + (status = 201, body = Operation), + (status = 422, description = "Insufficient funds"), + (status = 404, description = "Account not found") + ) +)] +pub async fn create_operation( + State(db): State, + Path(account_id): Path, + Json(req): Json, +) -> Result<(StatusCode, Json), AccountingError> { + let operation = db + .create_operation(account_id, req.kind, req.amount, req.labels) + .await?; + Ok((StatusCode::CREATED, Json(operation))) +} + +#[utoipa::path( + get, path = "/accounts/{account_id}/operations/{operation_id}", tag = "operations", + params(("account_id" = Uuid, Path), ("operation_id" = Uuid, Path)), + responses((status = 200, body = Operation), (status = 404)) +)] +pub async fn get_operation( + State(db): State, + Path((account_id, operation_id)): Path<(Uuid, Uuid)>, +) -> Result, AccountingError> { + db.get_operation(account_id, operation_id) + .await? + .map(Json) + .ok_or(AccountingError::NotFound) +} + +pub fn routes() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(list_operations, create_operation)) + .routes(routes!(get_operation)) +} diff --git a/src/routes/payment.rs b/src/routes/payment.rs new file mode 100644 index 0000000..63a4a89 --- /dev/null +++ b/src/routes/payment.rs @@ -0,0 +1,172 @@ +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::Json; +use utoipa_axum::router::OpenApiRouter; +use utoipa_axum::routes; +use uuid::Uuid; + +use crate::db::DatabaseClient; +use crate::errors::AccountingError; +use crate::filters::PaymentFilter; +use crate::models::payment::{ + CreatePaymentRequest, PatchPaymentRequest, PayPaymentRequest, Payment, PaymentEvent, + PaymentStatus, TransitionRequest, +}; + +#[utoipa::path( + get, path = "/payments", tag = "payments", + params(PaymentFilter), + responses((status = 200, body = Vec)) +)] +pub async fn list_payments( + State(db): State, + Query(filter): Query, +) -> Result>, AccountingError> { + Ok(Json(db.list_payments(&filter).await?)) +} + +#[utoipa::path( + post, path = "/payments", tag = "payments", + request_body = CreatePaymentRequest, + responses( + (status = 201, body = Payment), + (status = 422, description = "Invalid combination / insufficient funds"), + (status = 404, description = "Account not found") + ) +)] +pub async fn create_payment( + State(db): State, + Json(req): Json, +) -> Result<(StatusCode, Json), AccountingError> { + let payment = db.create_payment(req).await?; + Ok((StatusCode::CREATED, Json(payment))) +} + +#[utoipa::path( + post, path = "/payments/{id}/pay", tag = "payments", + params(("id" = Uuid, Path)), + request_body = PayPaymentRequest, + responses( + (status = 200, body = Payment), + (status = 422, description = "Invalid combination / insufficient funds"), + (status = 409, description = "Payment is not payable (not PENDING)"), + (status = 404) + ) +)] +pub async fn pay_payment( + State(db): State, + Path(id): Path, + Json(req): Json, +) -> Result, AccountingError> { + Ok(Json(db.pay_payment(id, req.method).await?)) +} + +#[utoipa::path( + get, path = "/payments/{id}", tag = "payments", + params(("id" = Uuid, Path)), + responses((status = 200, body = Payment), (status = 404)) +)] +pub async fn get_payment( + State(db): State, + Path(id): Path, +) -> Result, AccountingError> { + db.get_payment(id) + .await? + .map(Json) + .ok_or(AccountingError::NotFound) +} + +#[utoipa::path( + patch, path = "/payments/{id}", tag = "payments", + params(("id" = Uuid, Path)), + request_body = PatchPaymentRequest, + responses((status = 200, body = Payment), (status = 404)) +)] +pub async fn patch_payment( + State(db): State, + Path(id): Path, + Json(req): Json, +) -> Result, AccountingError> { + db.patch_payment(id, req) + .await? + .map(Json) + .ok_or(AccountingError::NotFound) +} + +#[utoipa::path( + post, path = "/payments/{id}/confirm", tag = "payments", + params(("id" = Uuid, Path)), + request_body = TransitionRequest, + responses( + (status = 200, body = Payment), + (status = 409, description = "Illegal transition"), + (status = 404) + ) +)] +pub async fn confirm_payment( + State(db): State, + Path(id): Path, + Json(req): Json, +) -> Result, AccountingError> { + Ok(Json( + db.transition_payment(id, PaymentStatus::Succeeded, req.reason) + .await?, + )) +} + +#[utoipa::path( + post, path = "/payments/{id}/reject", tag = "payments", + params(("id" = Uuid, Path)), + request_body = TransitionRequest, + responses((status = 200, body = Payment), (status = 409), (status = 404)) +)] +pub async fn reject_payment( + State(db): State, + Path(id): Path, + Json(req): Json, +) -> Result, AccountingError> { + Ok(Json( + db.transition_payment(id, PaymentStatus::Failed, req.reason) + .await?, + )) +} + +#[utoipa::path( + post, path = "/payments/{id}/cancel", tag = "payments", + params(("id" = Uuid, Path)), + request_body = TransitionRequest, + responses((status = 200, body = Payment), (status = 409), (status = 404)) +)] +pub async fn cancel_payment( + State(db): State, + Path(id): Path, + Json(req): Json, +) -> Result, AccountingError> { + Ok(Json( + db.transition_payment(id, PaymentStatus::Canceled, req.reason) + .await?, + )) +} + +#[utoipa::path( + get, path = "/payments/{id}/events", tag = "payments", + params(("id" = Uuid, Path)), + responses((status = 200, body = Vec)) +)] +pub async fn list_payment_events( + State(db): State, + Path(id): Path, +) -> Result>, AccountingError> { + Ok(Json(db.list_payment_events(id).await?)) +} + +pub fn routes() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(list_payments, create_payment)) + .routes(routes!(pay_payment)) + .routes(routes!(get_payment, patch_payment)) + .routes(routes!(confirm_payment)) + .routes(routes!(reject_payment)) + .routes(routes!(cancel_payment)) + .routes(routes!(list_payment_events)) +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..86758fe --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,261 @@ +//! End-to-end DB-layer tests. Each `#[sqlx::test]` gets a fresh database with +//! the migrations applied; we drive the `DatabaseClient` directly. + +use std::collections::HashMap; + +use accounting_api::db::DatabaseClient; +use accounting_api::errors::AccountingError; +use accounting_api::filters::AccountFilter; +use accounting_api::models::account::CreateAccountRequest; +use accounting_api::models::operation::OperationKind; +use accounting_api::models::payment::{ + CreatePaymentRequest, PaymentMethod, PaymentPurpose, PaymentStatus, +}; +use sqlx::PgPool; +use uuid::Uuid; + +fn new_account(owner: &str, max_debt: i32) -> CreateAccountRequest { + CreateAccountRequest { + owner_id: owner.to_string(), + name: "Test".to_string(), + description: None, + tags: vec!["t".to_string()], + labels: HashMap::new(), + max_allowed_debt: max_debt, + } +} + +async fn ledger_sum(pool: &PgPool, account_id: Uuid) -> i64 { + sqlx::query_scalar!( + r#"SELECT COALESCE(SUM(CASE kind WHEN 'CREDIT' THEN amount ELSE -amount END), 0)::bigint as "s!" + FROM operations WHERE account_id = $1"#, + account_id + ) + .fetch_one(pool) + .await + .unwrap() +} + +#[sqlx::test] +async fn operations_enforce_debt_limit_and_reconcile(pool: PgPool) { + let db = DatabaseClient::new(pool.clone()); + let acc = db.create_account(new_account("owner-1", 50)).await.unwrap(); + assert_eq!(acc.balance, 0); + + db.create_operation(acc.id, OperationKind::Credit, 100, HashMap::new()) + .await + .unwrap(); + // 130 <= 100 + 50, allowed → balance -30. + db.create_operation(acc.id, OperationKind::Debit, 130, HashMap::new()) + .await + .unwrap(); + let acc = db.get_account_by_id(acc.id).await.unwrap().unwrap(); + assert_eq!(acc.balance, -30); + + // 100 more would reach -130 < -50 → rejected. + let err = db + .create_operation(acc.id, OperationKind::Debit, 100, HashMap::new()) + .await + .unwrap_err(); + assert!(matches!( + err, + AccountingError::InsufficientFunds { amount: 100 } + )); + + // Invariant: cached balance equals the ledger sum. + assert_eq!(acc.balance as i64, ledger_sum(&pool, acc.id).await); +} + +fn new_payment(account_id: Uuid, amount: i32, purpose: PaymentPurpose) -> CreatePaymentRequest { + CreatePaymentRequest { + account_id, + amount, + purpose, + labels: HashMap::new(), + reason: None, + } +} + +#[sqlx::test] +async fn account_balance_top_up_is_rejected(pool: PgPool) { + let db = DatabaseClient::new(pool); + let acc = db.create_account(new_account("owner-2", 0)).await.unwrap(); + let payment = db + .create_payment(new_payment(acc.id, 10, PaymentPurpose::TopUp)) + .await + .unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(payment.method.is_none()); + + // Paying a TOP_UP from the balance itself is invalid. + let err = db + .pay_payment(payment.id, PaymentMethod::AccountBalance) + .await + .unwrap_err(); + assert!(matches!(err, AccountingError::InvalidCombination(_))); + + // Still a payable PENDING intent. + let p = db.get_payment(payment.id).await.unwrap().unwrap(); + assert_eq!(p.status, PaymentStatus::Pending); + assert!(p.method.is_none()); +} + +#[sqlx::test] +async fn insufficient_balance_pay_reverts_then_retries(pool: PgPool) { + let db = DatabaseClient::new(pool); + let acc = db.create_account(new_account("owner-3", 0)).await.unwrap(); + let payment = db + .create_payment(new_payment(acc.id, 100, PaymentPurpose::Purchase)) + .await + .unwrap(); + + // Insufficient funds → reverts to PENDING (retryable), method cleared, no ledger move. + let err = db + .pay_payment(payment.id, PaymentMethod::AccountBalance) + .await + .unwrap_err(); + assert!(matches!(err, AccountingError::InsufficientFunds { .. })); + let p = db.get_payment(payment.id).await.unwrap().unwrap(); + assert_eq!(p.status, PaymentStatus::Pending); + assert!(p.method.is_none()); + assert_eq!( + db.get_account_by_id(acc.id).await.unwrap().unwrap().balance, + 0 + ); + + // Fund the account, then retry the same payment → SUCCEEDED. + db.create_operation(acc.id, OperationKind::Credit, 100, HashMap::new()) + .await + .unwrap(); + let paid = db + .pay_payment(payment.id, PaymentMethod::AccountBalance) + .await + .unwrap(); + assert_eq!(paid.status, PaymentStatus::Succeeded); + assert!(paid.operation_id.is_some()); + assert_eq!( + db.get_account_by_id(acc.id).await.unwrap().unwrap().balance, + 0 + ); +} + +#[sqlx::test] +async fn account_balance_purchase_succeeds(pool: PgPool) { + let db = DatabaseClient::new(pool); + let acc = db.create_account(new_account("owner-5", 0)).await.unwrap(); + db.create_operation(acc.id, OperationKind::Credit, 100, HashMap::new()) + .await + .unwrap(); + let payment = db + .create_payment(new_payment(acc.id, 40, PaymentPurpose::Purchase)) + .await + .unwrap(); + let paid = db + .pay_payment(payment.id, PaymentMethod::AccountBalance) + .await + .unwrap(); + assert_eq!(paid.status, PaymentStatus::Succeeded); + assert_eq!(paid.method, Some(PaymentMethod::AccountBalance)); + assert_eq!( + db.get_account_by_id(acc.id).await.unwrap().unwrap().balance, + 60 + ); +} + +#[sqlx::test] +async fn cannot_confirm_unpaid_payment(pool: PgPool) { + let db = DatabaseClient::new(pool); + let acc = db.create_account(new_account("owner-6", 0)).await.unwrap(); + let payment = db + .create_payment(new_payment(acc.id, 10, PaymentPurpose::TopUp)) + .await + .unwrap(); + // A PENDING (unpaid) payment can't be confirmed — it must be paid first. + let err = db + .transition_payment(payment.id, PaymentStatus::Succeeded, None) + .await + .unwrap_err(); + assert!(matches!(err, AccountingError::InvalidTransition { .. })); +} + +#[sqlx::test] +async fn external_top_up_credits_on_confirm(pool: PgPool) { + let db = DatabaseClient::new(pool); + let acc = db.create_account(new_account("owner-4", 0)).await.unwrap(); + let payment = db + .create_payment(new_payment(acc.id, 200, PaymentPurpose::TopUp)) + .await + .unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + + // Pay with EXTERNAL → PROCESSING, no ledger yet. + let processing = db + .pay_payment(payment.id, PaymentMethod::External) + .await + .unwrap(); + assert_eq!(processing.status, PaymentStatus::Processing); + assert_eq!(processing.method, Some(PaymentMethod::External)); + assert_eq!( + db.get_account_by_id(acc.id).await.unwrap().unwrap().balance, + 0 + ); + + // Admin confirm → SUCCEEDED, account credited. + let confirmed = db + .transition_payment(payment.id, PaymentStatus::Succeeded, Some("ok".into())) + .await + .unwrap(); + assert_eq!(confirmed.status, PaymentStatus::Succeeded); + assert!(confirmed.operation_id.is_some()); + assert_eq!( + db.get_account_by_id(acc.id).await.unwrap().unwrap().balance, + 200 + ); + + // Re-confirming a terminal payment is illegal. + let err = db + .transition_payment(payment.id, PaymentStatus::Succeeded, None) + .await + .unwrap_err(); + assert!(matches!(err, AccountingError::InvalidTransition { .. })); + + // Full trail: NULL→PENDING, PENDING→PROCESSING, PROCESSING→SUCCEEDED. + let events = db.list_payment_events(payment.id).await.unwrap(); + assert_eq!(events.len(), 3); + assert_eq!(events[0].to_status, PaymentStatus::Pending); + assert_eq!(events[1].to_status, PaymentStatus::Processing); + assert_eq!(events[2].to_status, PaymentStatus::Succeeded); +} + +#[sqlx::test] +async fn account_search_filters(pool: PgPool) { + let db = DatabaseClient::new(pool); + let rich = db.create_account(new_account("rich", 0)).await.unwrap(); + db.create_operation(rich.id, OperationKind::Credit, 500, HashMap::new()) + .await + .unwrap(); + let poor = db.create_account(new_account("poor", 100)).await.unwrap(); + db.create_operation(poor.id, OperationKind::Debit, 80, HashMap::new()) + .await + .unwrap(); + + let in_debt = db + .list_accounts(&AccountFilter { + in_debt: Some(true), + ..Default::default() + }) + .await + .unwrap(); + assert!(in_debt.iter().any(|a| a.id == poor.id)); + assert!(!in_debt.iter().any(|a| a.id == rich.id)); + + let high = db + .list_accounts(&AccountFilter { + min_balance: Some(200), + ..Default::default() + }) + .await + .unwrap(); + assert!(high.iter().any(|a| a.id == rich.id)); + assert!(!high.iter().any(|a| a.id == poor.id)); +}