From 4228a193e9edb05225e8b5b486dfdcc0e10cb58a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?= <108630700+fetchfern@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:29:29 +0100 Subject: [PATCH] Charge tax on products (#4361) * Initial Anrok integration * Query cache, fmt, clippy * Fmt * Use payment intent function in edit_subscription * Attach Anrok client, use payments in index_billing * Integrate Anrok with refunds * Bug fixes * More bugfixes * Fix resubscriptions * Medal promotion bugfixes * Use stripe metadata constants everywhere * Pre-fill values in products_tax_identifiers * Cleanup billing route module * Cleanup * Email notification for tax charge * Don't charge tax on users which haven't been notified of tax change * Fix taxnotification.amount templates * Update .env.docker-compose * Update .env.local * Clippy * Fmt * Query cache * Periodically update tax amount on upcoming charges * Fix queries * Skip indexing tax amount on charges if no charges to process * chore: query cache, clippy, fmt * Fix a lot of things * Remove test code * chore: query cache, clippy, fmt * Fix money formatting * Fix conflicts * Extra documentation, handle tax association properly * Track loss in tax drift * chore: query cache, clippy, fmt * Add subscription.id variable * chore: query cache, clippy, fmt * chore: query cache, clippy, fmt --- apps/labrinth/.env.docker-compose | 3 + apps/labrinth/.env.local | 3 + ...1761e69bbd12af70bf3b6cc1506394a1ff60.json} | 12 +- ...696ef9be817baed61a8b515a07733f9be2f83.json | 40 + ...adbad085bc79eeefc53142ac8f220dc5390f6.json | 34 + ...8385608f19dae9439cb6a860503600127b04.json} | 38 +- ...55827a27f51e65e668525b8b545f570c0bda.json} | 38 +- ...9057c8fa7b988e29261e29f5e66507dd96e59.json | 28 - ...6ac919a7bedf6f07d9d66a9793bf0f7171dd.json} | 38 +- ...99dca9c0d9c84e55951a730d8c214580e0d6.json} | 38 +- ...fe0bd2faa48259745548d41b9f44f5d82e8f2.json | 34 + ...87e082a000ec1b397957650e1332191613ca.json} | 38 +- ...6e8735867376783d8665036cfc85df3f4867d.json | 130 ++ ...58dfd834fc8cc9142d9e9d60f92cce82b5cf.json} | 12 +- ...bc4a92975781c1a354beba8d04ee7b2ddd25b.json | 40 + ...f178a7828a45ed3134d3336cb59572f40beab.json | 32 + ...acd16403aa0679ef53330f2ce3e8a45f7b9f.json} | 38 +- ...f0b8e4f420a76e511571bb2eed9355771e54.json} | 38 +- ...ff8db0b451dcdcb896afcf82b5351389e263.json} | 12 +- ...d2827b25fbdad4898100cf242b21ce8e9713a.json | 130 ++ .../migrations/20250910145542_tax-charges.sql | 24 + ...20250915212454_tax-change-notification.sql | 45 + apps/labrinth/src/background_task.rs | 14 +- .../src/database/models/charge_item.rs | 115 +- apps/labrinth/src/database/models/mod.rs | 1 + .../src/database/models/product_item.rs | 28 +- .../models/products_tax_identifier_item.rs | 89 + .../database/models/user_subscription_item.rs | 11 +- apps/labrinth/src/lib.rs | 20 +- apps/labrinth/src/main.rs | 4 + apps/labrinth/src/models/v2/notifications.rs | 37 + apps/labrinth/src/models/v3/billing.rs | 19 + apps/labrinth/src/models/v3/notifications.rs | 38 +- apps/labrinth/src/queue/billing.rs | 908 ++++++++++ apps/labrinth/src/queue/email/templates.rs | 69 +- apps/labrinth/src/queue/mod.rs | 1 + apps/labrinth/src/routes/internal/billing.rs | 1467 +++-------------- .../src/routes/internal/billing/payments.rs | 798 +++++++++ apps/labrinth/src/routes/internal/medal.rs | 2 +- apps/labrinth/src/routes/internal/mod.rs | 1 - apps/labrinth/src/routes/mod.rs | 4 + apps/labrinth/src/util/anrok.rs | 293 ++++ apps/labrinth/src/util/mod.rs | 1 + apps/labrinth/tests/common/mod.rs | 3 + 44 files changed, 3438 insertions(+), 1330 deletions(-) rename apps/labrinth/.sqlx/{query-37da053e79c32173d7420edbe9d2f668c8bf7e00f3ca3ae4abd60a7aa36c943b.json => query-042ddb5fa773bb2b7031bdf75e151761e69bbd12af70bf3b6cc1506394a1ff60.json} (56%) create mode 100644 apps/labrinth/.sqlx/query-0867c10a6e4a2f259c957abfbc8696ef9be817baed61a8b515a07733f9be2f83.json create mode 100644 apps/labrinth/.sqlx/query-47a0c91292a3052237e20836ee1adbad085bc79eeefc53142ac8f220dc5390f6.json rename apps/labrinth/.sqlx/{query-109b6307ff4b06297ddd45ac2385e6a445ee4a4d4f447816dfcd059892c8b68b.json => query-4e8e9f9cb42f90cc17702386fdb78385608f19dae9439cb6a860503600127b04.json} (56%) rename apps/labrinth/.sqlx/{query-6bcbb0c584804c492ccee49ba0449a8a1cd88fa5d85d4cd6533f65d4c8021634.json => query-51c542076b4b3811eb12f051294f55827a27f51e65e668525b8b545f570c0bda.json} (56%) delete mode 100644 apps/labrinth/.sqlx/query-693194307c5c557b4e1e45d6b259057c8fa7b988e29261e29f5e66507dd96e59.json rename apps/labrinth/.sqlx/{query-fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9.json => query-6ca75db4bd4260c888a8f30fa4da6ac919a7bedf6f07d9d66a9793bf0f7171dd.json} (51%) rename apps/labrinth/.sqlx/{query-2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7.json => query-7973e569e784f416c1b4f1e6f3b099dca9c0d9c84e55951a730d8c214580e0d6.json} (56%) create mode 100644 apps/labrinth/.sqlx/query-9548cc3de06c6090e945af17e9ffe0bd2faa48259745548d41b9f44f5d82e8f2.json rename apps/labrinth/.sqlx/{query-7fb6f7e0b2b993d4b89146fdd916dbd7efe31a42127f15c9617caa00d60b7bcc.json => query-9a35729acbba06eafaa205922e4987e082a000ec1b397957650e1332191613ca.json} (55%) create mode 100644 apps/labrinth/.sqlx/query-a20149894f03ff65c35f4ce9c2c6e8735867376783d8665036cfc85df3f4867d.json rename apps/labrinth/.sqlx/{query-ba2e3eab0daba9698686cbf324351f5d0ddc7be1d1b650a86a43712786fd4a4d.json => query-aa67ead690b28a8f5ae17b236a4658dfd834fc8cc9142d9e9d60f92cce82b5cf.json} (57%) create mode 100644 apps/labrinth/.sqlx/query-b67a10297f583fe7e3f4d0fadf3bc4a92975781c1a354beba8d04ee7b2ddd25b.json create mode 100644 apps/labrinth/.sqlx/query-c0c70ebc3d59a5ab6a4c81e987df178a7828a45ed3134d3336cb59572f40beab.json rename apps/labrinth/.sqlx/{query-d4d17b6a06c2f607206373b18a1f4c367f03f076e5e264ec8f5e744877c6d362.json => query-e2e58113bc3a3db6ffc75b5c5e10acd16403aa0679ef53330f2ce3e8a45f7b9f.json} (52%) rename apps/labrinth/.sqlx/{query-8f51f552d1c63fa818cbdba581691e97f693a187ed05224a44adeee68c6809d1.json => query-e36e0ac1e2edb73533961a18e913f0b8e4f420a76e511571bb2eed9355771e54.json} (54%) rename apps/labrinth/.sqlx/{query-139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8.json => query-f19b07bc8c23f51cc17f497801c8ff8db0b451dcdcb896afcf82b5351389e263.json} (56%) create mode 100644 apps/labrinth/.sqlx/query-f3f819d5761dcd562d13f98826fd2827b25fbdad4898100cf242b21ce8e9713a.json create mode 100644 apps/labrinth/migrations/20250910145542_tax-charges.sql create mode 100644 apps/labrinth/migrations/20250915212454_tax-change-notification.sql create mode 100644 apps/labrinth/src/database/models/products_tax_identifier_item.rs create mode 100644 apps/labrinth/src/queue/billing.rs create mode 100644 apps/labrinth/src/routes/internal/billing/payments.rs create mode 100644 apps/labrinth/src/util/anrok.rs diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose index 10637dcea..45be8c828 100644 --- a/apps/labrinth/.env.docker-compose +++ b/apps/labrinth/.env.docker-compose @@ -138,4 +138,7 @@ AVALARA_1099_COMPANY_ID=207337084 COMPLIANCE_PAYOUT_THRESHOLD=disabled +ANROK_API_KEY=none +ANROK_API_URL=none + ARCHON_URL=none diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index 0dc3fbc5f..249152133 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -139,4 +139,7 @@ AVALARA_1099_COMPANY_ID=207337084 COMPLIANCE_PAYOUT_THRESHOLD=disabled +ANROK_API_KEY=none +ANROK_API_URL=none + ARCHON_URL=none diff --git a/apps/labrinth/.sqlx/query-37da053e79c32173d7420edbe9d2f668c8bf7e00f3ca3ae4abd60a7aa36c943b.json b/apps/labrinth/.sqlx/query-042ddb5fa773bb2b7031bdf75e151761e69bbd12af70bf3b6cc1506394a1ff60.json similarity index 56% rename from apps/labrinth/.sqlx/query-37da053e79c32173d7420edbe9d2f668c8bf7e00f3ca3ae4abd60a7aa36c943b.json rename to apps/labrinth/.sqlx/query-042ddb5fa773bb2b7031bdf75e151761e69bbd12af70bf3b6cc1506394a1ff60.json index 248d9ceaf..0ebc80bc8 100644 --- a/apps/labrinth/.sqlx/query-37da053e79c32173d7420edbe9d2f668c8bf7e00f3ca3ae4abd60a7aa36c943b.json +++ b/apps/labrinth/.sqlx/query-042ddb5fa773bb2b7031bdf75e151761e69bbd12af70bf3b6cc1506394a1ff60.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE id = ANY($1::bigint[])", + "query": "\n SELECT products.id, products.metadata, products.unitary, products.name\n FROM products\n WHERE id = ANY($1::bigint[])", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "unitary", "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Text" } ], "parameters": { @@ -27,8 +32,9 @@ "nullable": [ false, false, - false + false, + true ] }, - "hash": "37da053e79c32173d7420edbe9d2f668c8bf7e00f3ca3ae4abd60a7aa36c943b" + "hash": "042ddb5fa773bb2b7031bdf75e151761e69bbd12af70bf3b6cc1506394a1ff60" } diff --git a/apps/labrinth/.sqlx/query-0867c10a6e4a2f259c957abfbc8696ef9be817baed61a8b515a07733f9be2f83.json b/apps/labrinth/.sqlx/query-0867c10a6e4a2f259c957abfbc8696ef9be817baed61a8b515a07733f9be2f83.json new file mode 100644 index 000000000..5e5d66844 --- /dev/null +++ b/apps/labrinth/.sqlx/query-0867c10a6e4a2f259c957abfbc8696ef9be817baed61a8b515a07733f9be2f83.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT products.id, products.metadata, products.unitary, products.name\n FROM products\n INNER JOIN products_prices pp ON pp.id = $1\n\t\t\tWHERE products.id = pp.product_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "metadata", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "unitary", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "0867c10a6e4a2f259c957abfbc8696ef9be817baed61a8b515a07733f9be2f83" +} diff --git a/apps/labrinth/.sqlx/query-47a0c91292a3052237e20836ee1adbad085bc79eeefc53142ac8f220dc5390f6.json b/apps/labrinth/.sqlx/query-47a0c91292a3052237e20836ee1adbad085bc79eeefc53142ac8f220dc5390f6.json new file mode 100644 index 000000000..852b8f435 --- /dev/null +++ b/apps/labrinth/.sqlx/query-47a0c91292a3052237e20836ee1adbad085bc79eeefc53142ac8f220dc5390f6.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM products_tax_identifiers WHERE product_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "tax_processor_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "product_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "47a0c91292a3052237e20836ee1adbad085bc79eeefc53142ac8f220dc5390f6" +} diff --git a/apps/labrinth/.sqlx/query-109b6307ff4b06297ddd45ac2385e6a445ee4a4d4f447816dfcd059892c8b68b.json b/apps/labrinth/.sqlx/query-4e8e9f9cb42f90cc17702386fdb78385608f19dae9439cb6a860503600127b04.json similarity index 56% rename from apps/labrinth/.sqlx/query-109b6307ff4b06297ddd45ac2385e6a445ee4a4d4f447816dfcd059892c8b68b.json rename to apps/labrinth/.sqlx/query-4e8e9f9cb42f90cc17702386fdb78385608f19dae9439cb6a860503600127b04.json index ab8f19f4b..77bc16dad 100644 --- a/apps/labrinth/.sqlx/query-109b6307ff4b06297ddd45ac2385e6a445ee4a4d4f447816dfcd059892c8b68b.json +++ b/apps/labrinth/.sqlx/query-4e8e9f9cb42f90cc17702386fdb78385608f19dae9439cb6a860503600127b04.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE user_id = $1 ORDER BY due DESC", + "query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE id = $1", "describe": { "columns": [ { @@ -55,28 +55,48 @@ }, { "ordinal": 10, - "name": "subscription_interval?", - "type_info": "Text" + "name": "tax_amount", + "type_info": "Int8" }, { "ordinal": 11, - "name": "payment_platform", + "name": "tax_platform_id", "type_info": "Text" }, { "ordinal": 12, - "name": "payment_platform_id?", + "name": "subscription_interval?", "type_info": "Text" }, { "ordinal": 13, + "name": "payment_platform", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "payment_platform_id?", + "type_info": "Text" + }, + { + "ordinal": 15, "name": "parent_charge_id?", "type_info": "Int8" }, { - "ordinal": 14, + "ordinal": 16, "name": "net?", "type_info": "Int8" + }, + { + "ordinal": 17, + "name": "tax_last_updated?", + "type_info": "Timestamptz" + }, + { + "ordinal": 18, + "name": "tax_drift_loss?", + "type_info": "Int8" } ], "parameters": { @@ -95,12 +115,16 @@ true, false, true, + false, + true, true, false, true, true, + true, + true, true ] }, - "hash": "109b6307ff4b06297ddd45ac2385e6a445ee4a4d4f447816dfcd059892c8b68b" + "hash": "4e8e9f9cb42f90cc17702386fdb78385608f19dae9439cb6a860503600127b04" } diff --git a/apps/labrinth/.sqlx/query-6bcbb0c584804c492ccee49ba0449a8a1cd88fa5d85d4cd6533f65d4c8021634.json b/apps/labrinth/.sqlx/query-51c542076b4b3811eb12f051294f55827a27f51e65e668525b8b545f570c0bda.json similarity index 56% rename from apps/labrinth/.sqlx/query-6bcbb0c584804c492ccee49ba0449a8a1cd88fa5d85d4cd6533f65d4c8021634.json rename to apps/labrinth/.sqlx/query-51c542076b4b3811eb12f051294f55827a27f51e65e668525b8b545f570c0bda.json index 950d0c724..b3efa3420 100644 --- a/apps/labrinth/.sqlx/query-6bcbb0c584804c492ccee49ba0449a8a1cd88fa5d85d4cd6533f65d4c8021634.json +++ b/apps/labrinth/.sqlx/query-51c542076b4b3811eb12f051294f55827a27f51e65e668525b8b545f570c0bda.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE parent_charge_id = $1", + "query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE parent_charge_id = $1", "describe": { "columns": [ { @@ -55,28 +55,48 @@ }, { "ordinal": 10, - "name": "subscription_interval?", - "type_info": "Text" + "name": "tax_amount", + "type_info": "Int8" }, { "ordinal": 11, - "name": "payment_platform", + "name": "tax_platform_id", "type_info": "Text" }, { "ordinal": 12, - "name": "payment_platform_id?", + "name": "subscription_interval?", "type_info": "Text" }, { "ordinal": 13, + "name": "payment_platform", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "payment_platform_id?", + "type_info": "Text" + }, + { + "ordinal": 15, "name": "parent_charge_id?", "type_info": "Int8" }, { - "ordinal": 14, + "ordinal": 16, "name": "net?", "type_info": "Int8" + }, + { + "ordinal": 17, + "name": "tax_last_updated?", + "type_info": "Timestamptz" + }, + { + "ordinal": 18, + "name": "tax_drift_loss?", + "type_info": "Int8" } ], "parameters": { @@ -95,12 +115,16 @@ true, false, true, + false, + true, true, false, true, true, + true, + true, true ] }, - "hash": "6bcbb0c584804c492ccee49ba0449a8a1cd88fa5d85d4cd6533f65d4c8021634" + "hash": "51c542076b4b3811eb12f051294f55827a27f51e65e668525b8b545f570c0bda" } diff --git a/apps/labrinth/.sqlx/query-693194307c5c557b4e1e45d6b259057c8fa7b988e29261e29f5e66507dd96e59.json b/apps/labrinth/.sqlx/query-693194307c5c557b4e1e45d6b259057c8fa7b988e29261e29f5e66507dd96e59.json deleted file mode 100644 index 2a0c5bba7..000000000 --- a/apps/labrinth/.sqlx/query-693194307c5c557b4e1e45d6b259057c8fa7b988e29261e29f5e66507dd96e59.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval,\n payment_platform = EXCLUDED.payment_platform,\n payment_platform_id = EXCLUDED.payment_platform_id,\n parent_charge_id = EXCLUDED.parent_charge_id,\n net = EXCLUDED.net,\n price_id = EXCLUDED.price_id,\n amount = EXCLUDED.amount,\n currency_code = EXCLUDED.currency_code,\n charge_type = EXCLUDED.charge_type\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Int8", - "Int8", - "Text", - "Text", - "Varchar", - "Timestamptz", - "Timestamptz", - "Int8", - "Text", - "Text", - "Text", - "Int8", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "693194307c5c557b4e1e45d6b259057c8fa7b988e29261e29f5e66507dd96e59" -} diff --git a/apps/labrinth/.sqlx/query-fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9.json b/apps/labrinth/.sqlx/query-6ca75db4bd4260c888a8f30fa4da6ac919a7bedf6f07d9d66a9793bf0f7171dd.json similarity index 51% rename from apps/labrinth/.sqlx/query-fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9.json rename to apps/labrinth/.sqlx/query-6ca75db4bd4260c888a8f30fa4da6ac919a7bedf6f07d9d66a9793bf0f7171dd.json index 844fd2781..2ac429c9b 100644 --- a/apps/labrinth/.sqlx/query-fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9.json +++ b/apps/labrinth/.sqlx/query-6ca75db4bd4260c888a8f30fa4da6ac919a7bedf6f07d9d66a9793bf0f7171dd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'expiring' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ", + "query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'expiring' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ", "describe": { "columns": [ { @@ -55,28 +55,48 @@ }, { "ordinal": 10, - "name": "subscription_interval?", - "type_info": "Text" + "name": "tax_amount", + "type_info": "Int8" }, { "ordinal": 11, - "name": "payment_platform", + "name": "tax_platform_id", "type_info": "Text" }, { "ordinal": 12, - "name": "payment_platform_id?", + "name": "subscription_interval?", "type_info": "Text" }, { "ordinal": 13, + "name": "payment_platform", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "payment_platform_id?", + "type_info": "Text" + }, + { + "ordinal": 15, "name": "parent_charge_id?", "type_info": "Int8" }, { - "ordinal": 14, + "ordinal": 16, "name": "net?", "type_info": "Int8" + }, + { + "ordinal": 17, + "name": "tax_last_updated?", + "type_info": "Timestamptz" + }, + { + "ordinal": 18, + "name": "tax_drift_loss?", + "type_info": "Int8" } ], "parameters": { @@ -95,12 +115,16 @@ true, false, true, + false, + true, true, false, true, true, + true, + true, true ] }, - "hash": "fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9" + "hash": "6ca75db4bd4260c888a8f30fa4da6ac919a7bedf6f07d9d66a9793bf0f7171dd" } diff --git a/apps/labrinth/.sqlx/query-2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7.json b/apps/labrinth/.sqlx/query-7973e569e784f416c1b4f1e6f3b099dca9c0d9c84e55951a730d8c214580e0d6.json similarity index 56% rename from apps/labrinth/.sqlx/query-2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7.json rename to apps/labrinth/.sqlx/query-7973e569e784f416c1b4f1e6f3b099dca9c0d9c84e55951a730d8c214580e0d6.json index 223a79b2d..d8265d339 100644 --- a/apps/labrinth/.sqlx/query-2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7.json +++ b/apps/labrinth/.sqlx/query-7973e569e784f416c1b4f1e6f3b099dca9c0d9c84e55951a730d8c214580e0d6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')", + "query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE user_id = $1 ORDER BY due DESC", "describe": { "columns": [ { @@ -55,28 +55,48 @@ }, { "ordinal": 10, - "name": "subscription_interval?", - "type_info": "Text" + "name": "tax_amount", + "type_info": "Int8" }, { "ordinal": 11, - "name": "payment_platform", + "name": "tax_platform_id", "type_info": "Text" }, { "ordinal": 12, - "name": "payment_platform_id?", + "name": "subscription_interval?", "type_info": "Text" }, { "ordinal": 13, + "name": "payment_platform", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "payment_platform_id?", + "type_info": "Text" + }, + { + "ordinal": 15, "name": "parent_charge_id?", "type_info": "Int8" }, { - "ordinal": 14, + "ordinal": 16, "name": "net?", "type_info": "Int8" + }, + { + "ordinal": 17, + "name": "tax_last_updated?", + "type_info": "Timestamptz" + }, + { + "ordinal": 18, + "name": "tax_drift_loss?", + "type_info": "Int8" } ], "parameters": { @@ -95,12 +115,16 @@ true, false, true, + false, + true, true, false, true, true, + true, + true, true ] }, - "hash": "2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7" + "hash": "7973e569e784f416c1b4f1e6f3b099dca9c0d9c84e55951a730d8c214580e0d6" } diff --git a/apps/labrinth/.sqlx/query-9548cc3de06c6090e945af17e9ffe0bd2faa48259745548d41b9f44f5d82e8f2.json b/apps/labrinth/.sqlx/query-9548cc3de06c6090e945af17e9ffe0bd2faa48259745548d41b9f44f5d82e8f2.json new file mode 100644 index 000000000..26406b39e --- /dev/null +++ b/apps/labrinth/.sqlx/query-9548cc3de06c6090e945af17e9ffe0bd2faa48259745548d41b9f44f5d82e8f2.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n\t\t\tSELECT pti.*\n\t\t\tFROM products_prices pp\n\t\t\tINNER JOIN products_tax_identifiers pti ON pti.product_id = pp.product_id\n\t\t\tWHERE pp.id = $1\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "tax_processor_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "product_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "9548cc3de06c6090e945af17e9ffe0bd2faa48259745548d41b9f44f5d82e8f2" +} diff --git a/apps/labrinth/.sqlx/query-7fb6f7e0b2b993d4b89146fdd916dbd7efe31a42127f15c9617caa00d60b7bcc.json b/apps/labrinth/.sqlx/query-9a35729acbba06eafaa205922e4987e082a000ec1b397957650e1332191613ca.json similarity index 55% rename from apps/labrinth/.sqlx/query-7fb6f7e0b2b993d4b89146fdd916dbd7efe31a42127f15c9617caa00d60b7bcc.json rename to apps/labrinth/.sqlx/query-9a35729acbba06eafaa205922e4987e082a000ec1b397957650e1332191613ca.json index 7c9bd5f4e..4659b4c79 100644 --- a/apps/labrinth/.sqlx/query-7fb6f7e0b2b993d4b89146fdd916dbd7efe31a42127f15c9617caa00d60b7bcc.json +++ b/apps/labrinth/.sqlx/query-9a35729acbba06eafaa205922e4987e082a000ec1b397957650e1332191613ca.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE id = $1", + "query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')", "describe": { "columns": [ { @@ -55,28 +55,48 @@ }, { "ordinal": 10, - "name": "subscription_interval?", - "type_info": "Text" + "name": "tax_amount", + "type_info": "Int8" }, { "ordinal": 11, - "name": "payment_platform", + "name": "tax_platform_id", "type_info": "Text" }, { "ordinal": 12, - "name": "payment_platform_id?", + "name": "subscription_interval?", "type_info": "Text" }, { "ordinal": 13, + "name": "payment_platform", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "payment_platform_id?", + "type_info": "Text" + }, + { + "ordinal": 15, "name": "parent_charge_id?", "type_info": "Int8" }, { - "ordinal": 14, + "ordinal": 16, "name": "net?", "type_info": "Int8" + }, + { + "ordinal": 17, + "name": "tax_last_updated?", + "type_info": "Timestamptz" + }, + { + "ordinal": 18, + "name": "tax_drift_loss?", + "type_info": "Int8" } ], "parameters": { @@ -95,12 +115,16 @@ true, false, true, + false, + true, true, false, true, true, + true, + true, true ] }, - "hash": "7fb6f7e0b2b993d4b89146fdd916dbd7efe31a42127f15c9617caa00d60b7bcc" + "hash": "9a35729acbba06eafaa205922e4987e082a000ec1b397957650e1332191613ca" } diff --git a/apps/labrinth/.sqlx/query-a20149894f03ff65c35f4ce9c2c6e8735867376783d8665036cfc85df3f4867d.json b/apps/labrinth/.sqlx/query-a20149894f03ff65c35f4ce9c2c6e8735867376783d8665036cfc85df3f4867d.json new file mode 100644 index 000000000..e49fdf387 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a20149894f03ff65c35f4ce9c2c6e8735867376783d8665036cfc85df3f4867d.json @@ -0,0 +1,130 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n\t\t\tWHERE\n\t\t\t status = 'succeeded'\n\t\t\t AND tax_platform_id IS NULL\n\t\t\t AND tax_amount <> 0\n\t\t\tORDER BY due ASC\n\t\t\tFOR NO KEY UPDATE SKIP LOCKED\n\t\t\tLIMIT $1\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "price_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "amount", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "currency_code", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "due", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_attempt", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "charge_type", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "tax_amount", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "tax_platform_id", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "subscription_interval?", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "payment_platform", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "payment_platform_id?", + "type_info": "Text" + }, + { + "ordinal": 15, + "name": "parent_charge_id?", + "type_info": "Int8" + }, + { + "ordinal": 16, + "name": "net?", + "type_info": "Int8" + }, + { + "ordinal": 17, + "name": "tax_last_updated?", + "type_info": "Timestamptz" + }, + { + "ordinal": 18, + "name": "tax_drift_loss?", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + true, + true, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "a20149894f03ff65c35f4ce9c2c6e8735867376783d8665036cfc85df3f4867d" +} diff --git a/apps/labrinth/.sqlx/query-ba2e3eab0daba9698686cbf324351f5d0ddc7be1d1b650a86a43712786fd4a4d.json b/apps/labrinth/.sqlx/query-aa67ead690b28a8f5ae17b236a4658dfd834fc8cc9142d9e9d60f92cce82b5cf.json similarity index 57% rename from apps/labrinth/.sqlx/query-ba2e3eab0daba9698686cbf324351f5d0ddc7be1d1b650a86a43712786fd4a4d.json rename to apps/labrinth/.sqlx/query-aa67ead690b28a8f5ae17b236a4658dfd834fc8cc9142d9e9d60f92cce82b5cf.json index ee4696d21..43b8c79ab 100644 --- a/apps/labrinth/.sqlx/query-ba2e3eab0daba9698686cbf324351f5d0ddc7be1d1b650a86a43712786fd4a4d.json +++ b/apps/labrinth/.sqlx/query-aa67ead690b28a8f5ae17b236a4658dfd834fc8cc9142d9e9d60f92cce82b5cf.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE 1 = $1", + "query": "\n SELECT products.id, products.metadata, products.unitary, products.name\n FROM products\n WHERE 1 = $1", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "unitary", "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Text" } ], "parameters": { @@ -27,8 +32,9 @@ "nullable": [ false, false, - false + false, + true ] }, - "hash": "ba2e3eab0daba9698686cbf324351f5d0ddc7be1d1b650a86a43712786fd4a4d" + "hash": "aa67ead690b28a8f5ae17b236a4658dfd834fc8cc9142d9e9d60f92cce82b5cf" } diff --git a/apps/labrinth/.sqlx/query-b67a10297f583fe7e3f4d0fadf3bc4a92975781c1a354beba8d04ee7b2ddd25b.json b/apps/labrinth/.sqlx/query-b67a10297f583fe7e3f4d0fadf3bc4a92975781c1a354beba8d04ee7b2ddd25b.json new file mode 100644 index 000000000..2bb321250 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b67a10297f583fe7e3f4d0fadf3bc4a92975781c1a354beba8d04ee7b2ddd25b.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n products_tax_identifiers.*,\n products.metadata product_metadata\n FROM products_prices\n INNER JOIN products ON products.id = products_prices.product_id\n INNER JOIN products_tax_identifiers ON products_tax_identifiers.product_id = products.id\n WHERE products_prices.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "tax_processor_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "product_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "product_metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "b67a10297f583fe7e3f4d0fadf3bc4a92975781c1a354beba8d04ee7b2ddd25b" +} diff --git a/apps/labrinth/.sqlx/query-c0c70ebc3d59a5ab6a4c81e987df178a7828a45ed3134d3336cb59572f40beab.json b/apps/labrinth/.sqlx/query-c0c70ebc3d59a5ab6a4c81e987df178a7828a45ed3134d3336cb59572f40beab.json new file mode 100644 index 000000000..75a3edf56 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c0c70ebc3d59a5ab6a4c81e987df178a7828a45ed3134d3336cb59572f40beab.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net, tax_amount, tax_platform_id, tax_last_updated, tax_drift_loss)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval,\n payment_platform = EXCLUDED.payment_platform,\n payment_platform_id = EXCLUDED.payment_platform_id,\n parent_charge_id = EXCLUDED.parent_charge_id,\n net = EXCLUDED.net,\n tax_amount = EXCLUDED.tax_amount,\n tax_platform_id = EXCLUDED.tax_platform_id,\n tax_last_updated = EXCLUDED.tax_last_updated,\n price_id = EXCLUDED.price_id,\n amount = EXCLUDED.amount,\n currency_code = EXCLUDED.currency_code,\n charge_type = EXCLUDED.charge_type,\n\t\t\t\t\ttax_drift_loss = EXCLUDED.tax_drift_loss\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Int8", + "Text", + "Text", + "Varchar", + "Timestamptz", + "Timestamptz", + "Int8", + "Text", + "Text", + "Text", + "Int8", + "Int8", + "Int8", + "Text", + "Timestamptz", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c0c70ebc3d59a5ab6a4c81e987df178a7828a45ed3134d3336cb59572f40beab" +} diff --git a/apps/labrinth/.sqlx/query-d4d17b6a06c2f607206373b18a1f4c367f03f076e5e264ec8f5e744877c6d362.json b/apps/labrinth/.sqlx/query-e2e58113bc3a3db6ffc75b5c5e10acd16403aa0679ef53330f2ce3e8a45f7b9f.json similarity index 52% rename from apps/labrinth/.sqlx/query-d4d17b6a06c2f607206373b18a1f4c367f03f076e5e264ec8f5e744877c6d362.json rename to apps/labrinth/.sqlx/query-e2e58113bc3a3db6ffc75b5c5e10acd16403aa0679ef53330f2ce3e8a45f7b9f.json index 677dd8116..d17a32763 100644 --- a/apps/labrinth/.sqlx/query-d4d17b6a06c2f607206373b18a1f4c367f03f076e5e264ec8f5e744877c6d362.json +++ b/apps/labrinth/.sqlx/query-e2e58113bc3a3db6ffc75b5c5e10acd16403aa0679ef53330f2ce3e8a45f7b9f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n status = 'failed' AND due < NOW() - INTERVAL '30 days'\n ", + "query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'open' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ", "describe": { "columns": [ { @@ -55,28 +55,48 @@ }, { "ordinal": 10, - "name": "subscription_interval?", - "type_info": "Text" + "name": "tax_amount", + "type_info": "Int8" }, { "ordinal": 11, - "name": "payment_platform", + "name": "tax_platform_id", "type_info": "Text" }, { "ordinal": 12, - "name": "payment_platform_id?", + "name": "subscription_interval?", "type_info": "Text" }, { "ordinal": 13, + "name": "payment_platform", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "payment_platform_id?", + "type_info": "Text" + }, + { + "ordinal": 15, "name": "parent_charge_id?", "type_info": "Int8" }, { - "ordinal": 14, + "ordinal": 16, "name": "net?", "type_info": "Int8" + }, + { + "ordinal": 17, + "name": "tax_last_updated?", + "type_info": "Timestamptz" + }, + { + "ordinal": 18, + "name": "tax_drift_loss?", + "type_info": "Int8" } ], "parameters": { @@ -95,12 +115,16 @@ true, false, true, + false, + true, true, false, true, true, + true, + true, true ] }, - "hash": "d4d17b6a06c2f607206373b18a1f4c367f03f076e5e264ec8f5e744877c6d362" + "hash": "e2e58113bc3a3db6ffc75b5c5e10acd16403aa0679ef53330f2ce3e8a45f7b9f" } diff --git a/apps/labrinth/.sqlx/query-8f51f552d1c63fa818cbdba581691e97f693a187ed05224a44adeee68c6809d1.json b/apps/labrinth/.sqlx/query-e36e0ac1e2edb73533961a18e913f0b8e4f420a76e511571bb2eed9355771e54.json similarity index 54% rename from apps/labrinth/.sqlx/query-8f51f552d1c63fa818cbdba581691e97f693a187ed05224a44adeee68c6809d1.json rename to apps/labrinth/.sqlx/query-e36e0ac1e2edb73533961a18e913f0b8e4f420a76e511571bb2eed9355771e54.json index d28e9a721..843420112 100644 --- a/apps/labrinth/.sqlx/query-8f51f552d1c63fa818cbdba581691e97f693a187ed05224a44adeee68c6809d1.json +++ b/apps/labrinth/.sqlx/query-e36e0ac1e2edb73533961a18e913f0b8e4f420a76e511571bb2eed9355771e54.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'open' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ", + "query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n status = 'failed' AND due < NOW() - INTERVAL '30 days'\n ", "describe": { "columns": [ { @@ -55,28 +55,48 @@ }, { "ordinal": 10, - "name": "subscription_interval?", - "type_info": "Text" + "name": "tax_amount", + "type_info": "Int8" }, { "ordinal": 11, - "name": "payment_platform", + "name": "tax_platform_id", "type_info": "Text" }, { "ordinal": 12, - "name": "payment_platform_id?", + "name": "subscription_interval?", "type_info": "Text" }, { "ordinal": 13, + "name": "payment_platform", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "payment_platform_id?", + "type_info": "Text" + }, + { + "ordinal": 15, "name": "parent_charge_id?", "type_info": "Int8" }, { - "ordinal": 14, + "ordinal": 16, "name": "net?", "type_info": "Int8" + }, + { + "ordinal": 17, + "name": "tax_last_updated?", + "type_info": "Timestamptz" + }, + { + "ordinal": 18, + "name": "tax_drift_loss?", + "type_info": "Int8" } ], "parameters": { @@ -95,12 +115,16 @@ true, false, true, + false, + true, true, false, true, true, + true, + true, true ] }, - "hash": "8f51f552d1c63fa818cbdba581691e97f693a187ed05224a44adeee68c6809d1" + "hash": "e36e0ac1e2edb73533961a18e913f0b8e4f420a76e511571bb2eed9355771e54" } diff --git a/apps/labrinth/.sqlx/query-139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8.json b/apps/labrinth/.sqlx/query-f19b07bc8c23f51cc17f497801c8ff8db0b451dcdcb896afcf82b5351389e263.json similarity index 56% rename from apps/labrinth/.sqlx/query-139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8.json rename to apps/labrinth/.sqlx/query-f19b07bc8c23f51cc17f497801c8ff8db0b451dcdcb896afcf82b5351389e263.json index 0cc0a3d8e..b7a978b02 100644 --- a/apps/labrinth/.sqlx/query-139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8.json +++ b/apps/labrinth/.sqlx/query-f19b07bc8c23f51cc17f497801c8ff8db0b451dcdcb896afcf82b5351389e263.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE metadata ->> 'type' = $1", + "query": "\n SELECT products.id, products.metadata, products.unitary, products.name\n FROM products\n WHERE metadata ->> 'type' = $1", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "unitary", "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "name", + "type_info": "Text" } ], "parameters": { @@ -27,8 +32,9 @@ "nullable": [ false, false, - false + false, + true ] }, - "hash": "139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8" + "hash": "f19b07bc8c23f51cc17f497801c8ff8db0b451dcdcb896afcf82b5351389e263" } diff --git a/apps/labrinth/.sqlx/query-f3f819d5761dcd562d13f98826fd2827b25fbdad4898100cf242b21ce8e9713a.json b/apps/labrinth/.sqlx/query-f3f819d5761dcd562d13f98826fd2827b25fbdad4898100cf242b21ce8e9713a.json new file mode 100644 index 000000000..f95899885 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f3f819d5761dcd562d13f98826fd2827b25fbdad4898100cf242b21ce8e9713a.json @@ -0,0 +1,130 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n\t\t\tINNER JOIN users u ON u.id = charges.user_id\n\t\t\tWHERE\n\t\t\t status = 'open'\n\t\t\t AND COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) < NOW() - INTERVAL '1 day'\n\t\t\t AND u.email IS NOT NULL\n\t\t\t AND due - INTERVAL '7 days' > NOW()\n\t\t\tORDER BY COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) ASC\n\t\t\tFOR NO KEY UPDATE SKIP LOCKED\n\t\t\tLIMIT $1\n\t\t\t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "price_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "amount", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "currency_code", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "due", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_attempt", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "charge_type", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "tax_amount", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "tax_platform_id", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "subscription_interval?", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "payment_platform", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "payment_platform_id?", + "type_info": "Text" + }, + { + "ordinal": 15, + "name": "parent_charge_id?", + "type_info": "Int8" + }, + { + "ordinal": 16, + "name": "net?", + "type_info": "Int8" + }, + { + "ordinal": 17, + "name": "tax_last_updated?", + "type_info": "Timestamptz" + }, + { + "ordinal": 18, + "name": "tax_drift_loss?", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + false, + true, + true, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "f3f819d5761dcd562d13f98826fd2827b25fbdad4898100cf242b21ce8e9713a" +} diff --git a/apps/labrinth/migrations/20250910145542_tax-charges.sql b/apps/labrinth/migrations/20250910145542_tax-charges.sql new file mode 100644 index 000000000..c7443fd62 --- /dev/null +++ b/apps/labrinth/migrations/20250910145542_tax-charges.sql @@ -0,0 +1,24 @@ +ALTER TABLE charges ADD COLUMN tax_amount BIGINT NOT NULL DEFAULT 0; +ALTER TABLE charges ADD COLUMN tax_platform_id TEXT; + +ALTER TABLE products ADD COLUMN name TEXT; + +CREATE TABLE products_tax_identifiers ( + id SERIAL PRIMARY KEY, + tax_processor_id TEXT NOT NULL, + product_id BIGINT REFERENCES products (id) NOT NULL +); + +INSERT INTO products_tax_identifiers (tax_processor_id, product_id) +SELECT + 'modrinth-servers' AS tax_processor_id, + id AS product_id +FROM products +WHERE metadata ->> 'type' = 'pyro'; + +INSERT INTO products_tax_identifiers (tax_processor_id, product_id) +SELECT + 'modrinth-plus' AS tax_processor_id, + id AS product_id +FROM products +WHERE metadata ->> 'type' = 'midas'; diff --git a/apps/labrinth/migrations/20250915212454_tax-change-notification.sql b/apps/labrinth/migrations/20250915212454_tax-change-notification.sql new file mode 100644 index 000000000..bf8683659 --- /dev/null +++ b/apps/labrinth/migrations/20250915212454_tax-change-notification.sql @@ -0,0 +1,45 @@ +ALTER TABLE charges ADD COLUMN tax_last_updated TIMESTAMPTZ; +ALTER TABLE charges ADD COLUMN tax_drift_loss BIGINT; + +INSERT INTO notifications_types + (name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications) +VALUES ('tax_notification', 2, FALSE, FALSE); + +INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled) +VALUES (NULL, 'email', 'tax_notification', TRUE); + +INSERT INTO notifications_templates + (channel, notification_type, subject_line, body_fetch_url, plaintext_fallback) +VALUES + ( + 'email', + 'tax_notification', + 'Your subscription''s tax is changing', + 'https://modrinth.com/email/subscription-tax-change', + CONCAT( + 'Hi {user.name},', + CHR(10), + CHR(10), + 'Your {taxnotification.service} subscription''s tax rate is changing. Starting with your next {taxnotification.billing_interval} payment, your {taxnotification.service} subscription''s, your charge and all future charges will be updated as follows:', + CHR(10), + CHR(10), + 'Current subtotal: {taxnotification.old_amount}', + CHR(10), + 'Current tax: {taxnotification.old_tax_amount}', + CHR(10), + 'Current TOTAL: {taxnotification.old_total_amount}', + CHR(10), + CHR(10), + 'New subtotal: {taxnotification.new_amount}', + CHR(10), + 'New tax: {taxnotification.new_tax_amount}', + CHR(10), + 'New TOTAL: {taxnotification.new_total_amount}', + CHR(10), + CHR(10), + 'Note that the pre-tax price of your subscription has not changed, only the tax charged has changed as required by local tax regulations.', + CHR(10), + CHR(10), + 'Thank your for using {taxnotification.service}.' + ) + ); diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs index f78ed334d..b79671eaa 100644 --- a/apps/labrinth/src/background_task.rs +++ b/apps/labrinth/src/background_task.rs @@ -1,10 +1,12 @@ use crate::database::redis::RedisPool; +use crate::queue::billing::{index_billing, index_subscriptions}; use crate::queue::email::EmailQueue; use crate::queue::payouts::{ PayoutsQueue, index_payouts_notifications, insert_bank_balances_and_webhook, process_payout, }; use crate::search::indexing::index_projects; +use crate::util::anrok; use crate::{database, search}; use clap::ValueEnum; use sqlx::Postgres; @@ -24,6 +26,7 @@ pub enum BackgroundTask { } impl BackgroundTask { + #[allow(clippy::too_many_arguments)] pub async fn run( self, pool: sqlx::Pool, @@ -31,6 +34,7 @@ impl BackgroundTask { search_config: search::SearchConfig, clickhouse: clickhouse::Client, stripe_client: stripe::Client, + anrok_client: anrok::Client, email_queue: EmailQueue, ) { use BackgroundTask::*; @@ -41,8 +45,9 @@ impl BackgroundTask { UpdateVersions => update_versions(pool, redis_pool).await, Payouts => payouts(pool, clickhouse, redis_pool).await, IndexBilling => { - crate::routes::internal::billing::index_billing( + index_billing( stripe_client, + anrok_client, pool.clone(), redis_pool, ) @@ -51,8 +56,11 @@ impl BackgroundTask { update_bank_balances(pool).await; } IndexSubscriptions => { - crate::routes::internal::billing::index_subscriptions( - pool, redis_pool, + index_subscriptions( + pool, + redis_pool, + stripe_client, + anrok_client, ) .await } diff --git a/apps/labrinth/src/database/models/charge_item.rs b/apps/labrinth/src/database/models/charge_item.rs index 24cc9b0cd..272bf004e 100644 --- a/apps/labrinth/src/database/models/charge_item.rs +++ b/apps/labrinth/src/database/models/charge_item.rs @@ -7,6 +7,7 @@ use crate::models::billing::{ use chrono::{DateTime, Utc}; use std::convert::{TryFrom, TryInto}; +#[derive(Clone)] pub struct DBCharge { pub id: DBChargeId, pub user_id: DBUserId, @@ -26,8 +27,13 @@ pub struct DBCharge { pub parent_charge_id: Option, + pub tax_amount: i64, + pub tax_platform_id: Option, + pub tax_last_updated: Option>, + // Net is always in USD pub net: Option, + pub tax_drift_loss: Option, } struct ChargeQueryResult { @@ -45,7 +51,11 @@ struct ChargeQueryResult { payment_platform: String, payment_platform_id: Option, parent_charge_id: Option, + tax_amount: i64, + tax_platform_id: Option, + tax_last_updated: Option>, net: Option, + tax_drift_loss: Option, } impl TryFrom for DBCharge { @@ -69,7 +79,11 @@ impl TryFrom for DBCharge { payment_platform: PaymentPlatform::from_string(&r.payment_platform), payment_platform_id: r.payment_platform_id, parent_charge_id: r.parent_charge_id.map(DBChargeId), + tax_amount: r.tax_amount, + tax_platform_id: r.tax_platform_id, net: r.net, + tax_last_updated: r.tax_last_updated, + tax_drift_loss: r.tax_drift_loss, }) } } @@ -80,14 +94,16 @@ macro_rules! select_charges_with_predicate { ChargeQueryResult, r#" SELECT - id, user_id, price_id, amount, currency_code, status, due, last_attempt, - charge_type, subscription_id, + charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt, + charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id, -- Workaround for https://github.com/launchbadge/sqlx/issues/3336 - subscription_interval AS "subscription_interval?", - payment_platform, - payment_platform_id AS "payment_platform_id?", - parent_charge_id AS "parent_charge_id?", - net AS "net?" + charges.subscription_interval AS "subscription_interval?", + charges.payment_platform, + charges.payment_platform_id AS "payment_platform_id?", + charges.parent_charge_id AS "parent_charge_id?", + charges.net AS "net?", + charges.tax_last_updated AS "tax_last_updated?", + charges.tax_drift_loss AS "tax_drift_loss?" FROM charges "# + $predicate, @@ -103,8 +119,8 @@ impl DBCharge { ) -> Result { sqlx::query!( r#" - INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net, tax_amount, tax_platform_id, tax_last_updated, tax_drift_loss) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, @@ -116,10 +132,14 @@ impl DBCharge { payment_platform_id = EXCLUDED.payment_platform_id, parent_charge_id = EXCLUDED.parent_charge_id, net = EXCLUDED.net, + tax_amount = EXCLUDED.tax_amount, + tax_platform_id = EXCLUDED.tax_platform_id, + tax_last_updated = EXCLUDED.tax_last_updated, price_id = EXCLUDED.price_id, amount = EXCLUDED.amount, currency_code = EXCLUDED.currency_code, - charge_type = EXCLUDED.charge_type + charge_type = EXCLUDED.charge_type, + tax_drift_loss = EXCLUDED.tax_drift_loss "#, self.id.0, self.user_id.0, @@ -136,6 +156,10 @@ impl DBCharge { self.payment_platform_id.as_deref(), self.parent_charge_id.map(|x| x.0), self.net, + self.tax_amount, + self.tax_platform_id.as_deref(), + self.tax_last_updated, + self.tax_drift_loss, ) .execute(&mut **transaction) .await?; @@ -276,6 +300,71 @@ impl DBCharge { .collect::, serde_json::Error>>()?) } + /// Returns all charges that need to have their tax amount updated. + /// + /// This only selects charges which are: + /// - Open; + /// - Haven't been updated in the last day; + /// - Are due in more than 7 days; + /// - Where the user has an email, because we can't notify users without an email about a price change. + /// + /// This also locks the charges. + pub async fn get_updateable_lock( + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + limit: i64, + ) -> Result, DatabaseError> { + let res = select_charges_with_predicate!( + " + INNER JOIN users u ON u.id = charges.user_id + WHERE + status = 'open' + AND COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) < NOW() - INTERVAL '1 day' + AND u.email IS NOT NULL + AND due - INTERVAL '7 days' > NOW() + ORDER BY COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) ASC + FOR NO KEY UPDATE SKIP LOCKED + LIMIT $1 + ", + limit + ) + .fetch_all(exec) + .await?; + + Ok(res + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } + + /// Returns all charges which are missing a tax identifier, that is, are 1. succeeded, 2. have a tax amount and + /// 3. haven't been assigned a tax identifier yet. + /// + /// Charges are locked. + pub async fn get_missing_tax_identifier_lock( + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + limit: i64, + ) -> Result, DatabaseError> { + let res = select_charges_with_predicate!( + " + WHERE + status = 'succeeded' + AND tax_platform_id IS NULL + AND tax_amount <> 0 + ORDER BY due ASC + FOR NO KEY UPDATE SKIP LOCKED + LIMIT $1 + ", + limit + ) + .fetch_all(exec) + .await?; + + Ok(res + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } + pub async fn remove( id: DBChargeId, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, @@ -293,3 +382,9 @@ impl DBCharge { Ok(()) } } + +pub struct CustomerCharge { + pub stripe_customer_id: String, + pub charge: DBCharge, + pub product_tax_id: String, +} diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 25d77ad94..98653d9d4 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -22,6 +22,7 @@ pub mod pat_item; pub mod payout_item; pub mod payouts_values_notifications; pub mod product_item; +pub mod products_tax_identifier_item; pub mod project_item; pub mod report_item; pub mod session_item; diff --git a/apps/labrinth/src/database/models/product_item.rs b/apps/labrinth/src/database/models/product_item.rs index 205cb1fbd..0936ebe51 100644 --- a/apps/labrinth/src/database/models/product_item.rs +++ b/apps/labrinth/src/database/models/product_item.rs @@ -15,20 +15,22 @@ pub struct DBProduct { pub id: DBProductId, pub metadata: ProductMetadata, pub unitary: bool, + pub name: Option, } struct ProductQueryResult { id: i64, metadata: serde_json::Value, unitary: bool, + name: Option, } macro_rules! select_products_with_predicate { - ($predicate:tt, $param:ident) => { + ($predicate:tt, $param:expr) => { sqlx::query_as!( ProductQueryResult, r#" - SELECT id, metadata, unitary + SELECT products.id, products.metadata, products.unitary, products.name FROM products "# + $predicate, @@ -45,6 +47,7 @@ impl TryFrom for DBProduct { id: DBProductId(r.id), metadata: serde_json::from_value(r.metadata)?, unitary: r.unitary, + name: r.name, }) } } @@ -57,6 +60,23 @@ impl DBProduct { Ok(Self::get_many(&[id], exec).await?.into_iter().next()) } + pub async fn get_price( + id: DBProductPriceId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let maybe_row = select_products_with_predicate!( + "INNER JOIN products_prices pp ON pp.id = $1 + WHERE products.id = pp.product_id", + id.0 + ) + .fetch_optional(exec) + .await?; + + maybe_row + .map(|r| r.try_into().map_err(Into::into)) + .transpose() + } + pub async fn get_by_type<'a, E>( exec: E, r#type: &str, @@ -116,6 +136,8 @@ pub struct QueryProductWithPrices { pub id: DBProductId, pub metadata: ProductMetadata, pub unitary: bool, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub name: Option, pub prices: Vec, } @@ -152,6 +174,7 @@ impl QueryProductWithPrices { Some(QueryProductWithPrices { id: x.id, metadata: x.metadata, + name: x.name, prices: prices .remove(&x.id) .map(|x| x.1)? @@ -195,6 +218,7 @@ impl QueryProductWithPrices { Some(QueryProductWithPrices { id: x.id, metadata: x.metadata, + name: x.name, prices: prices .remove(&x.id) .map(|x| x.1)? diff --git a/apps/labrinth/src/database/models/products_tax_identifier_item.rs b/apps/labrinth/src/database/models/products_tax_identifier_item.rs new file mode 100644 index 000000000..d95ea814f --- /dev/null +++ b/apps/labrinth/src/database/models/products_tax_identifier_item.rs @@ -0,0 +1,89 @@ +use crate::database::models::ids::{DBProductId, DBProductPriceId}; +use crate::models::billing::ProductMetadata; +use crate::routes::ApiError; + +pub struct DBProductsTaxIdentifier { + pub id: i32, + pub tax_processor_id: String, + pub product_id: DBProductId, +} + +impl DBProductsTaxIdentifier { + pub async fn get_product( + product_id: DBProductId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, ApiError> { + let maybe_row = sqlx::query!( + "SELECT * FROM products_tax_identifiers WHERE product_id = $1", + product_id.0, + ) + .fetch_optional(exec) + .await?; + + Ok(maybe_row.map(|row| DBProductsTaxIdentifier { + id: row.id, + tax_processor_id: row.tax_processor_id, + product_id: DBProductId(row.product_id), + })) + } + + pub async fn get_price( + price_id: DBProductPriceId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, ApiError> { + let maybe_row = sqlx::query!( + " + SELECT pti.* + FROM products_prices pp + INNER JOIN products_tax_identifiers pti ON pti.product_id = pp.product_id + WHERE pp.id = $1 + ", + price_id.0, + ) + .fetch_optional(exec) + .await?; + + Ok(maybe_row.map(|row| DBProductsTaxIdentifier { + id: row.id, + tax_processor_id: row.tax_processor_id, + product_id: DBProductId(row.product_id), + })) + } +} + +pub struct ProductInfo { + pub tax_identifier: DBProductsTaxIdentifier, + pub product_metadata: ProductMetadata, +} + +pub async fn product_info_by_product_price_id( + product_price_id: DBProductPriceId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, +) -> Result, ApiError> { + let maybe_row = sqlx::query!( + r#" + SELECT + products_tax_identifiers.*, + products.metadata product_metadata + FROM products_prices + INNER JOIN products ON products.id = products_prices.product_id + INNER JOIN products_tax_identifiers ON products_tax_identifiers.product_id = products.id + WHERE products_prices.id = $1 + "#, + product_price_id.0 as i64, + ) + .fetch_optional(exec) + .await?; + + match maybe_row { + None => Ok(None), + Some(row) => Ok(Some(ProductInfo { + tax_identifier: DBProductsTaxIdentifier { + id: row.id, + tax_processor_id: row.tax_processor_id, + product_id: DBProductId(row.product_id), + }, + product_metadata: serde_json::from_value(row.product_metadata)?, + })), + } +} diff --git a/apps/labrinth/src/database/models/user_subscription_item.rs b/apps/labrinth/src/database/models/user_subscription_item.rs index ec677782a..871890770 100644 --- a/apps/labrinth/src/database/models/user_subscription_item.rs +++ b/apps/labrinth/src/database/models/user_subscription_item.rs @@ -2,7 +2,7 @@ use crate::database::models::{ DBProductPriceId, DBUserId, DBUserSubscriptionId, DatabaseError, }; use crate::models::billing::{ - PriceDuration, SubscriptionMetadata, SubscriptionStatus, + PriceDuration, ProductMetadata, SubscriptionMetadata, SubscriptionStatus, }; use chrono::{DateTime, Utc}; use itertools::Itertools; @@ -161,3 +161,12 @@ impl DBUserSubscription { Ok(()) } } + +pub struct SubscriptionWithCharge { + pub subscription_id: DBUserSubscriptionId, + pub user_id: DBUserId, + pub product_metadata: ProductMetadata, + pub amount: i64, + pub tax_amount: i64, + pub due: DateTime, +} diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index f58c5aaf2..f119f71c2 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -16,7 +16,9 @@ use util::cors::default_cors; use crate::background_task::update_versions; use crate::database::ReadOnlyPgPool; +use crate::queue::billing::{index_billing, index_subscriptions}; use crate::queue::moderation::AutomatedModerationQueue; +use crate::util::anrok; use crate::util::env::{parse_strings_from_var, parse_var}; use crate::util::ratelimit::{AsyncRateLimiter, GCRAParameters}; use sync::friends::handle_pubsub; @@ -58,6 +60,7 @@ pub struct LabrinthConfig { pub automated_moderation_queue: web::Data, pub rate_limiter: web::Data, pub stripe_client: stripe::Client, + pub anrok_client: anrok::Client, pub email_queue: web::Data, } @@ -71,6 +74,7 @@ pub fn app_setup( file_host: Arc, maxmind: Arc, stripe_client: stripe::Client, + anrok_client: anrok::Client, email_queue: EmailQueue, enable_background_tasks: bool, ) -> LabrinthConfig { @@ -161,10 +165,12 @@ pub fn app_setup( let pool_ref = pool.clone(); let redis_ref = redis_pool.clone(); let stripe_client_ref = stripe_client.clone(); + let anrok_client_ref = anrok_client.clone(); actix_rt::spawn(async move { loop { - routes::internal::billing::index_billing( + index_billing( stripe_client_ref.clone(), + anrok_client_ref.clone(), pool_ref.clone(), redis_ref.clone(), ) @@ -175,11 +181,16 @@ pub fn app_setup( let pool_ref = pool.clone(); let redis_ref = redis_pool.clone(); + let stripe_client_ref = stripe_client.clone(); + let anrok_client_ref = anrok_client.clone(); + actix_rt::spawn(async move { loop { - routes::internal::billing::index_subscriptions( + index_subscriptions( pool_ref.clone(), redis_ref.clone(), + stripe_client_ref.clone(), + anrok_client_ref.clone(), ) .await; tokio::time::sleep(Duration::from_secs(60 * 5)).await; @@ -287,6 +298,7 @@ pub fn app_setup( automated_moderation_queue, rate_limiter: limiter, stripe_client, + anrok_client, email_queue: web::Data::new(email_queue), } } @@ -322,6 +334,7 @@ pub fn app_config( .app_data(labrinth_config.active_sockets.clone()) .app_data(labrinth_config.automated_moderation_queue.clone()) .app_data(web::Data::new(labrinth_config.stripe_client.clone())) + .app_data(web::Data::new(labrinth_config.anrok_client.clone())) .app_data(labrinth_config.rate_limiter.clone()) .configure({ #[cfg(target_os = "linux")] @@ -501,6 +514,9 @@ pub fn check_env_vars() -> bool { failed |= check_var::("AVALARA_1099_API_TEAM_ID"); failed |= check_var::("AVALARA_1099_COMPANY_ID"); + failed |= check_var::("ANROK_API_URL"); + failed |= check_var::("ANROK_API_KEY"); + failed |= check_var::("COMPLIANCE_PAYOUT_THRESHOLD"); failed |= check_var::("PAYOUT_ALERT_SLACK_WEBHOOK"); diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs index 8cbe43941..f26989fc2 100644 --- a/apps/labrinth/src/main.rs +++ b/apps/labrinth/src/main.rs @@ -8,6 +8,7 @@ use labrinth::database::redis::RedisPool; use labrinth::file_hosting::{S3BucketConfig, S3Host}; use labrinth::queue::email::EmailQueue; use labrinth::search; +use labrinth::util::anrok; use labrinth::util::env::parse_var; use labrinth::util::ratelimit::rate_limit_middleware; use labrinth::{check_env_vars, clickhouse, database, file_hosting, queue}; @@ -136,6 +137,7 @@ async fn main() -> std::io::Result<()> { let stripe_client = stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap()); + let anrok_client = anrok::Client::from_env().unwrap(); let email_queue = EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap(); @@ -147,6 +149,7 @@ async fn main() -> std::io::Result<()> { search_config, clickhouse, stripe_client, + anrok_client.clone(), email_queue, ) .await; @@ -186,6 +189,7 @@ async fn main() -> std::io::Result<()> { file_host.clone(), maxmind_reader.clone(), stripe_client, + anrok_client.clone(), email_queue, !args.no_background_tasks, ); diff --git a/apps/labrinth/src/models/v2/notifications.rs b/apps/labrinth/src/models/v2/notifications.rs index 664cf4b0b..56bf0d7f7 100644 --- a/apps/labrinth/src/models/v2/notifications.rs +++ b/apps/labrinth/src/models/v2/notifications.rs @@ -1,7 +1,9 @@ use crate::models::ids::{ThreadMessageId, VersionId}; +use crate::models::v3::billing::PriceDuration; use crate::models::{ ids::{ NotificationId, OrganizationId, ProjectId, ReportId, TeamId, ThreadId, + UserSubscriptionId, }, notifications::{Notification, NotificationAction, NotificationBody}, projects::ProjectStatus, @@ -37,6 +39,17 @@ pub struct LegacyNotificationAction { #[derive(Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum LegacyNotificationBody { + TaxNotification { + subscription_id: UserSubscriptionId, + old_amount: i64, + old_tax_amount: i64, + new_amount: i64, + new_tax_amount: i64, + billing_interval: PriceDuration, + currency: String, + due: DateTime, + service: String, + }, ProjectUpdate { project_id: ProjectId, version_id: VersionId, @@ -198,6 +211,9 @@ impl LegacyNotification { NotificationBody::PaymentFailed { .. } => { Some("payment_failed".to_string()) } + NotificationBody::TaxNotification { .. } => { + Some("tax_notification".to_string()) + } NotificationBody::PayoutAvailable { .. } => { Some("payout_available".to_string()) } @@ -341,6 +357,27 @@ impl LegacyNotification { new_email, to_email, }, + NotificationBody::TaxNotification { + subscription_id, + old_amount, + old_tax_amount, + new_amount, + new_tax_amount, + billing_interval, + currency, + due, + service, + } => LegacyNotificationBody::TaxNotification { + subscription_id, + old_amount, + old_tax_amount, + new_amount, + new_tax_amount, + billing_interval, + due, + service, + currency, + }, NotificationBody::PaymentFailed { amount, service } => { LegacyNotificationBody::PaymentFailed { amount, service } } diff --git a/apps/labrinth/src/models/v3/billing.rs b/apps/labrinth/src/models/v3/billing.rs index 5d561ceb0..b7dd2a00b 100644 --- a/apps/labrinth/src/models/v3/billing.rs +++ b/apps/labrinth/src/models/v3/billing.rs @@ -66,6 +66,15 @@ pub enum Price { }, } +impl Price { + pub fn get_interval(&self, interval: PriceDuration) -> Option { + match self { + Price::OneTime { .. } => None, + Price::Recurring { intervals } => intervals.get(&interval).copied(), + } + } +} + #[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Debug, Copy, Clone)] #[serde(rename_all = "kebab-case")] pub enum PriceDuration { @@ -175,6 +184,16 @@ pub enum SubscriptionMetadata { Medal { id: String }, } +impl SubscriptionMetadata { + pub fn is_medal(&self) -> bool { + matches!(self, SubscriptionMetadata::Medal { .. }) + } + + pub fn is_pyro(&self) -> bool { + matches!(self, SubscriptionMetadata::Pyro { .. }) + } +} + #[derive(Serialize, Deserialize)] pub struct Charge { pub id: ChargeId, diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs index b74ab8c57..19b304095 100644 --- a/apps/labrinth/src/models/v3/notifications.rs +++ b/apps/labrinth/src/models/v3/notifications.rs @@ -2,6 +2,7 @@ use super::ids::*; use crate::database::models::notification_item::DBNotification; use crate::database::models::notification_item::DBNotificationAction; use crate::database::models::notifications_deliveries_item::DBNotificationDelivery; +use crate::models::billing::PriceDuration; use crate::models::ids::{ NotificationId, ProjectId, ReportId, TeamId, ThreadId, ThreadMessageId, VersionId, @@ -46,6 +47,7 @@ pub enum NotificationType { PasswordRemoved, EmailChanged, PaymentFailed, + TaxNotification, PatCreated, ModerationMessageReceived, ReportStatusUpdated, @@ -76,7 +78,9 @@ impl NotificationType { NotificationType::PasswordRemoved => "password_removed", NotificationType::EmailChanged => "email_changed", NotificationType::PaymentFailed => "payment_failed", + NotificationType::TaxNotification => "tax_notification", NotificationType::PatCreated => "pat_created", + NotificationType::PayoutAvailable => "payout_available", NotificationType::ModerationMessageReceived => { "moderation_message_received" } @@ -87,7 +91,6 @@ impl NotificationType { } NotificationType::ProjectStatusNeutral => "project_status_neutral", NotificationType::ProjectTransferred => "project_transferred", - NotificationType::PayoutAvailable => "payout_available", NotificationType::Unknown => "unknown", } } @@ -110,18 +113,7 @@ impl NotificationType { "password_removed" => NotificationType::PasswordRemoved, "email_changed" => NotificationType::EmailChanged, "payment_failed" => NotificationType::PaymentFailed, - "pat_created" => NotificationType::PatCreated, - "moderation_message_received" => { - NotificationType::ModerationMessageReceived - } - "report_status_updated" => NotificationType::ReportStatusUpdated, - "report_submitted" => NotificationType::ReportSubmitted, - "project_status_approved" => { - NotificationType::ProjectStatusApproved - } - "project_status_neutral" => NotificationType::ProjectStatusNeutral, - "project_transferred" => NotificationType::ProjectTransferred, - "payout_available" => NotificationType::PayoutAvailable, + "tax_notification" => NotificationType::TaxNotification, "unknown" => NotificationType::Unknown, _ => NotificationType::Unknown, } @@ -218,6 +210,17 @@ pub enum NotificationBody { amount: String, service: String, }, + TaxNotification { + subscription_id: UserSubscriptionId, + new_amount: i64, + new_tax_amount: i64, + old_amount: i64, + old_tax_amount: i64, + billing_interval: PriceDuration, + currency: String, + due: DateTime, + service: String, + }, PayoutAvailable { date_available: DateTime, amount: f64, @@ -293,6 +296,9 @@ impl NotificationBody { NotificationBody::PaymentFailed { .. } => { NotificationType::PaymentFailed } + NotificationBody::TaxNotification { .. } => { + NotificationType::TaxNotification + } NotificationBody::PayoutAvailable { .. } => { NotificationType::PayoutAvailable } @@ -522,6 +528,12 @@ impl From for Notification { "#".to_string(), vec![], ), + NotificationBody::TaxNotification { .. } => ( + "Tax notification".to_string(), + "You've received a tax notification.".to_string(), + "#".to_string(), + vec![], + ), NotificationBody::PayoutAvailable { .. } => ( "Payout available".to_string(), "A payout is available!".to_string(), diff --git a/apps/labrinth/src/queue/billing.rs b/apps/labrinth/src/queue/billing.rs new file mode 100644 index 000000000..d1e943181 --- /dev/null +++ b/apps/labrinth/src/queue/billing.rs @@ -0,0 +1,908 @@ +use crate::database::models::charge_item::DBCharge; +use crate::database::models::notification_item::NotificationBuilder; +use crate::database::models::product_item::DBProduct; +use crate::database::models::products_tax_identifier_item::DBProductsTaxIdentifier; +use crate::database::models::user_item::DBUser; +use crate::database::models::user_subscription_item::DBUserSubscription; +use crate::database::models::users_redeemals::UserRedeemal; +use crate::database::models::{DatabaseError, ids::*}; +use crate::database::models::{ + product_item, user_subscription_item, users_redeemals, +}; +use crate::database::redis::RedisPool; +use crate::models::billing::{ + ChargeStatus, ChargeType, PaymentPlatform, Price, PriceDuration, + ProductMetadata, SubscriptionMetadata, SubscriptionStatus, +}; +use crate::models::notifications::NotificationBody; +use crate::models::users::Badges; +use crate::models::users::User; +use crate::routes::ApiError; +use crate::routes::internal::billing::payments::*; +use crate::util::anrok; +use crate::util::archon::ArchonClient; +use crate::util::archon::{CreateServerRequest, Specs}; +use ariadne::ids::base62_impl::to_base62; +use chrono::Utc; +use futures::stream::{FuturesUnordered, StreamExt}; +use sqlx::PgPool; +use std::collections::HashSet; +use std::str::FromStr; +use stripe::{self, Currency}; +use tracing::{error, info, warn}; + +pub async fn index_subscriptions( + pool: PgPool, + redis: RedisPool, + stripe_client: stripe::Client, + anrok_client: anrok::Client, +) { + info!("Indexing subscriptions"); + + async fn anrok_api_operations( + pool: PgPool, + redis: RedisPool, + stripe_client: stripe::Client, + anrok_client: anrok::Client, + ) { + let then = std::time::Instant::now(); + let result = update_tax_amounts( + pool.clone(), + redis.clone(), + anrok_client.clone(), + stripe_client.clone(), + 100, + ) + .await; + + if let Err(e) = result { + warn!("Error updating tax amount on charges: {:?}", e); + } + + let result = update_tax_transactions( + pool, + redis, + anrok_client, + stripe_client, + 100, + ) + .await; + + if let Err(e) = result { + warn!("Error updating tax transactions: {:?}", e); + } + + info!( + "Updating tax amounts and Anrok transactions took {:?}", + then.elapsed() + ); + } + + /// Updates charges which need to have their tax amount updated. This is done within a timer to avoid reaching + /// Anrok API limits. + /// + /// The global rate limit for Anrok API operations is 10 RPS, so we run ~6 requests every second up + /// to the specified limit of processed charges. + async fn update_tax_amounts( + pg: PgPool, + redis: RedisPool, + anrok_client: anrok::Client, + stripe_client: stripe::Client, + limit: i64, + ) -> Result<(), ApiError> { + let mut interval = + tokio::time::interval(std::time::Duration::from_secs(1)); + interval + .set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + let mut processed_charges = 0; + + loop { + interval.tick().await; + + let mut txn = pg.begin().await?; + + let charges = DBCharge::get_updateable_lock(&mut *txn, 6).await?; + + if charges.is_empty() { + info!("No more charges to process"); + break Ok(()); + } + + let anrok_client_ref = anrok_client.clone(); + let stripe_client_ref = stripe_client.clone(); + let pg_ref = pg.clone(); + let redis_ref = redis.clone(); + + struct ProcessedCharge { + charge: DBCharge, + new_tax_amount: i64, + product_name: String, + } + + let mut futures = charges + .into_iter() + .map(|charge| { + let stripe_client = stripe_client_ref.clone(); + let anrok_client = anrok_client_ref.clone(); + let pg = pg_ref.clone(); + let redis = redis_ref.clone(); + + async move { + let stripe_customer_id = + DBUser::get_id(charge.user_id, &pg, &redis) + .await? + .ok_or_else(|| { + ApiError::from(DatabaseError::Database( + sqlx::Error::RowNotFound, + )) + }) + .and_then(|user| { + user.stripe_customer_id.ok_or_else(|| { + ApiError::InvalidInput( + "User has no Stripe customer ID" + .to_owned(), + ) + }) + })?; + + let tax_id = DBProductsTaxIdentifier::get_price( + charge.price_id, + &pg, + ) + .await? + .ok_or_else(|| { + DatabaseError::Database(sqlx::Error::RowNotFound) + })?; + + let product = + DBProduct::get_price(charge.price_id, &pg) + .await? + .ok_or_else(|| { + DatabaseError::Database( + sqlx::Error::RowNotFound, + ) + })?; + + let Ok(customer_id): Result = + stripe_customer_id.parse() + else { + return Err(ApiError::InvalidInput( + "Charge's Stripe customer ID was invalid" + .to_owned(), + )); + }; + + let customer = stripe::Customer::retrieve( + &stripe_client, + &customer_id, + &[], + ) + .await?; + + let Some(stripe_address) = customer.address else { + return Err(ApiError::InvalidInput( + "Stripe customer had no address".to_owned(), + )); + }; + + let customer_address = + anrok::Address::from_stripe_address( + &stripe_address, + ); + + let tax_amount = anrok_client + .create_ephemeral_txn(&anrok::TransactionFields { + customer_address, + currency_code: charge.currency_code.clone(), + accounting_time: charge.due, + accounting_time_zone: + anrok::AccountingTimeZone::Utc, + line_items: vec![anrok::LineItem::new( + tax_id.tax_processor_id, + charge.amount, + )], + }) + .await? + .tax_amount_to_collect; + + Result::::Ok( + ProcessedCharge { + charge, + new_tax_amount: tax_amount, + product_name: product + .name + .unwrap_or_else(|| "Modrinth".to_owned()), + }, + ) + } + }) + .collect::>(); + + while let Some(result) = futures.next().await { + processed_charges += 1; + + match result { + Ok(ProcessedCharge { + mut charge, + new_tax_amount, + product_name, + }) => { + charge.tax_last_updated = Some(Utc::now()); + + if new_tax_amount != charge.tax_amount { + // The price of the subscription has changed, we need to insert a notification + // for this. + + let Some(subscription_id) = charge.subscription_id + else { + return Err(ApiError::InvalidInput( + "Charge has no subscription ID".to_owned(), + )); + }; + + NotificationBuilder { + body: NotificationBody::TaxNotification { + subscription_id: subscription_id.into(), + new_amount: charge.amount, + new_tax_amount, + old_amount: charge.amount, + old_tax_amount: charge.tax_amount, + billing_interval: charge + .subscription_interval + .unwrap_or(PriceDuration::Monthly), + due: charge.due, + service: product_name, + currency: charge.currency_code.clone(), + }, + } + .insert(charge.user_id, &mut txn, &redis) + .await?; + + charge.tax_amount = new_tax_amount; + } + + charge.upsert(&mut txn).await?; + } + Err(error) => { + error!(%error, "Error indexing tax amount on charge"); + } + } + } + + txn.commit().await?; + + if processed_charges >= limit { + break Ok(()); + } + } + } + + /// Registers Anrok transactions for charges which are missing a tax identifier. + /// + /// Same as update_tax_amounts, this is done within a timer to avoid reaching Anrok API limits. + /// + /// The global rate limit for Anrok API operations is 10 RPS, so we run ~6 requests every second up + /// to the specified limit of processed charges. + async fn update_tax_transactions( + pg: PgPool, + redis: RedisPool, + anrok_client: anrok::Client, + stripe_client: stripe::Client, + limit: i64, + ) -> Result<(), ApiError> { + let mut interval = + tokio::time::interval(std::time::Duration::from_secs(1)); + interval + .set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + let mut processed_charges = 0; + + loop { + interval.tick().await; + + let mut txn = pg.begin().await?; + + let charges = + DBCharge::get_missing_tax_identifier_lock(&mut *txn, 6).await?; + + if charges.is_empty() { + info!("No more charges to process"); + break Ok(()); + } + + for mut c in charges { + let payment_intent_id = c + .payment_platform_id + .as_deref() + .and_then(|x| x.parse().ok()) + .ok_or_else(|| { + ApiError::InvalidInput( + "Charge has no or an invalid payment platform ID" + .to_owned(), + ) + })?; + + let stripe_customer_id = DBUser::get_id(c.user_id, &pg, &redis) + .await? + .ok_or_else(|| { + ApiError::from(DatabaseError::Database( + sqlx::Error::RowNotFound, + )) + }) + .and_then(|user| { + user.stripe_customer_id.ok_or_else(|| { + ApiError::InvalidInput( + "User has no Stripe customer ID".to_owned(), + ) + }) + })?; + + let tax_id = + DBProductsTaxIdentifier::get_price(c.price_id, &pg) + .await? + .ok_or_else(|| { + DatabaseError::Database(sqlx::Error::RowNotFound) + })?; + + let Ok(customer_id): Result = + stripe_customer_id.parse() + else { + return Err(ApiError::InvalidInput( + "Charge's Stripe customer ID was invalid".to_owned(), + )); + }; + + let customer = stripe::Customer::retrieve( + &stripe_client, + &customer_id, + &[], + ) + .await?; + + let Some(stripe_address) = customer.address else { + return Err(ApiError::InvalidInput( + "Stripe customer had no address".to_owned(), + )); + }; + + let customer_address = + anrok::Address::from_stripe_address(&stripe_address); + + let tax_platform_id = + anrok::transaction_id_stripe_pi(&payment_intent_id); + + // Note: if the tax amount that was charged to the customer is *different* than + // what it *should* be NOW, we will take on a loss here. + + let should_have_collected = anrok_client + .create_or_update_txn(&anrok::Transaction { + id: tax_platform_id.clone(), + fields: anrok::TransactionFields { + customer_address, + currency_code: c.currency_code.clone(), + accounting_time: c.due, + accounting_time_zone: + anrok::AccountingTimeZone::Utc, + line_items: vec![ + anrok::LineItem::new_including_tax_amount( + tax_id.tax_processor_id, + c.tax_amount + c.amount, + ), + ], + }, + }) + .await? + .tax_amount_to_collect; + + let drift = should_have_collected - c.tax_amount; + + c.tax_drift_loss = Some(drift); + c.tax_platform_id = Some(tax_platform_id); + c.upsert(&mut txn).await?; + + processed_charges += 1; + } + + txn.commit().await?; + + if processed_charges >= limit { + break Ok(()); + } + } + } + + let tax_charges_index_handle = tokio::spawn(anrok_api_operations( + pool.clone(), + redis.clone(), + stripe_client.clone(), + anrok_client.clone(), + )); + + let res = async { + let mut transaction = pool.begin().await?; + let mut clear_cache_users = Vec::new(); + + // If an active subscription has: + // - A canceled charge due now + // - An expiring charge due now + // - A failed charge more than two days ago + // It should be unprovisioned. + let all_charges = DBCharge::get_unprovision(&pool).await?; + + let mut all_subscriptions = + user_subscription_item::DBUserSubscription::get_many( + &all_charges + .iter() + .filter_map(|x| x.subscription_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + ) + .await?; + let subscription_prices = product_item::DBProductPrice::get_many( + &all_subscriptions + .iter() + .map(|x| x.price_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + ) + .await?; + let subscription_products = product_item::DBProduct::get_many( + &subscription_prices + .iter() + .map(|x| x.product_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + ) + .await?; + let users = DBUser::get_many_ids( + &all_subscriptions + .iter() + .map(|x| x.user_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + &redis, + ) + .await?; + + for charge in all_charges { + let Some(subscription) = all_subscriptions + .iter_mut() + .find(|x| Some(x.id) == charge.subscription_id) + else { + continue; + }; + + if subscription.status == SubscriptionStatus::Unprovisioned { + continue; + } + + let Some(product_price) = subscription_prices + .iter() + .find(|x| x.id == subscription.price_id) + else { + continue; + }; + + let Some(product) = subscription_products + .iter() + .find(|x| x.id == product_price.product_id) + else { + continue; + }; + + let Some(user) = + users.iter().find(|x| x.id == subscription.user_id) + else { + continue; + }; + + let unprovisioned = match product.metadata { + ProductMetadata::Midas => { + let badges = user.badges - Badges::MIDAS; + + sqlx::query!( + " + UPDATE users + SET badges = $1 + WHERE (id = $2) + ", + badges.bits() as i64, + user.id as DBUserId, + ) + .execute(&mut *transaction) + .await?; + + true + } + + ProductMetadata::Pyro { .. } + | ProductMetadata::Medal { .. } => 'server: { + let server_id = match &subscription.metadata { + Some(SubscriptionMetadata::Pyro { id, region: _ }) => { + id + } + Some(SubscriptionMetadata::Medal { id }) => id, + _ => break 'server true, + }; + + let res = reqwest::Client::new() + .post(format!( + "{}/modrinth/v0/servers/{}/suspend", + dotenvy::var("ARCHON_URL")?, + server_id + )) + .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) + .json(&serde_json::json!({ + "reason": if charge.status == ChargeStatus::Cancelled || charge.status == ChargeStatus::Expiring { + "cancelled" + } else { + "paymentfailed" + } + })) + .send() + .await; + + if let Err(e) = res { + warn!("Error suspending pyro server: {:?}", e); + false + } else { + true + } + } + }; + + if unprovisioned { + subscription.status = SubscriptionStatus::Unprovisioned; + subscription.upsert(&mut transaction).await?; + } + + clear_cache_users.push(user.id); + } + + crate::database::models::DBUser::clear_caches( + &clear_cache_users + .into_iter() + .map(|x| (x, None)) + .collect::>(), + &redis, + ) + .await?; + transaction.commit().await?; + + // If an offer redeemal has been processing for over 5 minutes, it should be set pending. + UserRedeemal::update_stuck_5_minutes(&pool).await?; + + // If an offer redeemal is pending, try processing it. + // Try processing it. + let pending_redeemals = UserRedeemal::get_pending(&pool, 100).await?; + for redeemal in pending_redeemals { + if let Err(error) = + try_process_user_redeemal(&pool, &redis, redeemal).await + { + warn!(%error, "Failed to process a redeemal.") + } + } + + Ok::<(), ApiError>(()) + }; + + if let Err(e) = res.await { + warn!("Error indexing subscriptions: {:?}", e); + } + + if let Err(error) = tax_charges_index_handle.await + && error.is_panic() + { + std::panic::resume_unwind(error.into_panic()); + } + + info!("Done indexing subscriptions"); +} + +/// Attempts to process a user redeemal. +/// +/// Returns `Ok` if the entry has been succesfully processed, or will not be processed. +pub async fn try_process_user_redeemal( + pool: &PgPool, + redis: &RedisPool, + mut user_redeemal: UserRedeemal, +) -> Result<(), ApiError> { + // Immediately update redeemal row + user_redeemal.last_attempt = Some(Utc::now()); + user_redeemal.n_attempts += 1; + user_redeemal.status = users_redeemals::Status::Processing; + let updated = user_redeemal.update_status_if_pending(pool).await?; + + if !updated { + return Ok(()); + } + + let user_id = user_redeemal.user_id; + + // Find the Medal product's price & metadata + + let mut medal_products = + product_item::QueryProductWithPrices::list_by_product_type( + pool, "medal", + ) + .await?; + + let Some(product_item::QueryProductWithPrices { + id: _product_id, + metadata, + mut prices, + unitary: _, + name: _, + }) = medal_products.pop() + else { + return Err(ApiError::Conflict( + "Missing Medal subscription product".to_owned(), + )); + }; + + let ProductMetadata::Medal { + cpu, + ram, + swap, + storage, + region, + } = metadata + else { + return Err(ApiError::Conflict( + "Missing or incorrect metadata for Medal subscription".to_owned(), + )); + }; + + let Some(medal_price) = prices.pop() else { + return Err(ApiError::Conflict( + "Missing price for Medal subscription".to_owned(), + )); + }; + + let (price_duration, price_amount) = match medal_price.prices { + Price::OneTime { price: _ } => { + return Err(ApiError::Conflict( + "Unexpected metadata for Medal subscription price".to_owned(), + )); + } + + Price::Recurring { intervals } => { + let Some((price_duration, price_amount)) = + intervals.into_iter().next() + else { + return Err(ApiError::Conflict( + "Missing price interval for Medal subscription".to_owned(), + )); + }; + + (price_duration, price_amount) + } + }; + + let price_id = medal_price.id; + + // Get the user's username + + let user = DBUser::get_id(user_id, pool, redis) + .await? + .ok_or(ApiError::NotFound)?; + + // Send the provision request to Archon. On failure, the redeemal will be "stuck" processing, + // and moved back to pending by `index_subscriptions`. + + let archon_client = ArchonClient::from_env()?; + let server_id = archon_client + .create_server(&CreateServerRequest { + user_id: to_base62(user_id.0 as u64), + name: format!("{}'s Medal server", user.username), + specs: Specs { + memory_mb: ram, + cpu, + swap_mb: swap, + storage_mb: storage, + }, + source: crate::util::archon::Empty::default(), + region, + tags: vec!["medal".to_owned()], + }) + .await?; + + let mut txn = pool.begin().await?; + + // Build a subscription using this price ID. + let subscription = DBUserSubscription { + id: generate_user_subscription_id(&mut txn).await?, + user_id, + price_id, + interval: PriceDuration::FiveDays, + created: Utc::now(), + status: SubscriptionStatus::Provisioned, + metadata: Some(SubscriptionMetadata::Medal { + id: server_id.to_string(), + }), + }; + + subscription.upsert(&mut txn).await?; + + // Insert an expiring charge, `index_subscriptions` will unprovision the + // subscription when expired. + DBCharge { + id: generate_charge_id(&mut txn).await?, + user_id, + price_id, + amount: price_amount.into(), + tax_amount: 0, + tax_platform_id: None, + currency_code: medal_price.currency_code, + status: ChargeStatus::Expiring, + due: Utc::now() + price_duration.duration(), + last_attempt: None, + type_: ChargeType::Subscription, + subscription_id: Some(subscription.id), + subscription_interval: Some(subscription.interval), + payment_platform: PaymentPlatform::None, + payment_platform_id: None, + parent_charge_id: None, + net: None, + tax_last_updated: Some(Utc::now()), + tax_drift_loss: Some(0), + } + .upsert(&mut txn) + .await?; + + // Update `users_redeemal`, mark subscription as redeemed. + user_redeemal.status = users_redeemals::Status::Processed; + user_redeemal.update(&mut *txn).await?; + + txn.commit().await?; + + Ok(()) +} + +pub async fn index_billing( + stripe_client: stripe::Client, + anrok_client: anrok::Client, + pool: PgPool, + redis: RedisPool, +) { + info!("Indexing billing queue"); + let res = async { + // If a charge has continuously failed for more than a month, it should be cancelled + let charges_to_cancel = DBCharge::get_cancellable(&pool).await?; + + for mut charge in charges_to_cancel { + charge.status = ChargeStatus::Cancelled; + + let mut transaction = pool.begin().await?; + charge.upsert(&mut transaction).await?; + transaction.commit().await?; + } + + // If a charge is open and due or has been attempted more than two days ago, it should be processed + let charges_to_do = DBCharge::get_chargeable(&pool).await?; + + let prices = product_item::DBProductPrice::get_many( + &charges_to_do + .iter() + .map(|x| x.price_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + ) + .await?; + + let users = crate::database::models::DBUser::get_many_ids( + &charges_to_do + .iter() + .map(|x| x.user_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + &redis, + ) + .await?; + + for mut charge in charges_to_do { + let Some(product_price) = + prices.iter().find(|x| x.id == charge.price_id) + else { + continue; + }; + + let Some(user) = users.iter().find(|x| x.id == charge.user_id) + else { + continue; + }; + + let Ok(currency) = + Currency::from_str(&product_price.currency_code.to_lowercase()) + else { + warn!( + "Could not find currency for {}", + product_price.currency_code + ); + continue; + }; + + let user = User::from_full(user.clone()); + + let result = create_or_update_payment_intent( + &pool, + &redis, + &stripe_client, + &anrok_client, + PaymentBootstrapOptions { + user: &user, + payment_intent: None, + payment_session: PaymentSession::AutomatedRenewal, + attached_charge: AttachedCharge::UseExisting { + charge: charge.clone(), + }, + currency: CurrencyMode::Set( + currency, + ), + attach_payment_metadata: None, + }, + ) + .await; + + charge.status = ChargeStatus::Processing; + charge.last_attempt = Some(Utc::now()); + + let mut failure = false; + + match result { + Ok(PaymentBootstrapResults { + new_payment_intent, + payment_method: _, + price_id: _, + subtotal, + tax, + }) => { + if new_payment_intent.is_some() { + // The PI will automatically be confirmed + charge.amount = subtotal; + charge.tax_amount = tax; + charge.payment_platform = PaymentPlatform::Stripe; + } else { + error!("Payment bootstrap succeeded but no payment intent was created"); + failure = true; + } + } + + Err(error) => { + error!(%error, "Failed to bootstrap payment for renewal"); + failure = true; + } + }; + + if failure { + charge.status = ChargeStatus::Failed; + } + + let mut transaction = pool.begin().await?; + charge.upsert(&mut transaction).await?; + transaction.commit().await?; + } + + Ok::<(), ApiError>(()) + } + .await; + + if let Err(e) = res { + warn!("Error indexing billing queue: {:?}", e); + } + + info!("Done indexing billing queue"); +} diff --git a/apps/labrinth/src/queue/email/templates.rs b/apps/labrinth/src/queue/email/templates.rs index 0410017e0..c29078d6e 100644 --- a/apps/labrinth/src/queue/email/templates.rs +++ b/apps/labrinth/src/queue/email/templates.rs @@ -24,6 +24,20 @@ const VERIFYEMAIL_URL: &str = "verifyemail.url"; const AUTHPROVIDER_NAME: &str = "authprovider.name"; const EMAILCHANGED_NEW_EMAIL: &str = "emailchanged.new_email"; const BILLING_URL: &str = "billing.url"; +const SUBSCRIPTION_ID: &str = "subscription.id"; + +const TAXNOTIFICATION_OLD_AMOUNT: &str = "taxnotification.old_amount"; +const TAXNOTIFICATION_OLD_TAX_AMOUNT: &str = "taxnotification.old_tax_amount"; +const TAXNOTIFICATION_OLD_TOTAL_AMOUNT: &str = + "taxnotification.old_total_amount"; +const TAXNOTIFICATION_NEW_AMOUNT: &str = "taxnotification.new_amount"; +const TAXNOTIFICATION_NEW_TAX_AMOUNT: &str = "taxnotification.new_tax_amount"; +const TAXNOTIFICATION_NEW_TOTAL_AMOUNT: &str = + "taxnotification.new_total_amount"; +const TAXNOTIFICATION_BILLING_INTERVAL: &str = + "taxnotification.billing_interval"; +const TAXNOTIFICATION_DUE: &str = "taxnotification.due"; +const TAXNOTIFICATION_SERVICE: &str = "taxnotification.service"; const PAYMENTFAILED_AMOUNT: &str = "paymentfailed.amount"; const PAYMENTFAILED_SERVICE: &str = "paymentfailed.service"; @@ -545,12 +559,57 @@ async fn collect_template_variables( map.insert( PAYOUTAVAILABLE_AMOUNT, - format!("{:.2}", (amount * 100.0) as i64), + format!("USD${:.2}", (amount * 100.0) as i64), ); Ok(map) } + NotificationBody::TaxNotification { + subscription_id, + old_amount, + old_tax_amount, + new_amount, + new_tax_amount, + billing_interval, + currency, + due, + service, + } => { + map.insert( + TAXNOTIFICATION_OLD_AMOUNT, + fmt_money(*old_amount, currency), + ); + map.insert( + TAXNOTIFICATION_OLD_TAX_AMOUNT, + fmt_money(*old_tax_amount, currency), + ); + map.insert( + TAXNOTIFICATION_OLD_TOTAL_AMOUNT, + fmt_money(*old_amount + *old_tax_amount, currency), + ); + map.insert( + TAXNOTIFICATION_NEW_AMOUNT, + fmt_money(*new_amount, currency), + ); + map.insert( + TAXNOTIFICATION_NEW_TAX_AMOUNT, + fmt_money(*new_tax_amount, currency), + ); + map.insert( + TAXNOTIFICATION_NEW_TOTAL_AMOUNT, + fmt_money(*new_amount + *new_tax_amount, currency), + ); + map.insert( + TAXNOTIFICATION_BILLING_INTERVAL, + billing_interval.as_str().to_owned(), + ); + map.insert(TAXNOTIFICATION_DUE, date_human_readable(*due)); + map.insert(TAXNOTIFICATION_SERVICE, service.clone()); + map.insert(SUBSCRIPTION_ID, to_base62(subscription_id.0)); + Ok(map) + } + NotificationBody::ProjectUpdate { .. } | NotificationBody::ModeratorMessage { .. } | NotificationBody::LegacyMarkdown { .. } @@ -561,3 +620,11 @@ async fn collect_template_variables( fn date_human_readable(date: chrono::DateTime) -> String { date.format("%B %d, %Y").to_string() } + +fn fmt_money(amount: i64, currency: &str) -> String { + rusty_money::Money::from_minor( + amount, + rusty_money::iso::find(currency).unwrap_or(rusty_money::iso::USD), + ) + .to_string() +} diff --git a/apps/labrinth/src/queue/mod.rs b/apps/labrinth/src/queue/mod.rs index b552d7283..a0fd58275 100644 --- a/apps/labrinth/src/queue/mod.rs +++ b/apps/labrinth/src/queue/mod.rs @@ -1,4 +1,5 @@ pub mod analytics; +pub mod billing; pub mod email; pub mod maxmind; pub mod moderation; diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index c44058ba3..4aefcafb7 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -1,12 +1,10 @@ +use self::payments::*; use crate::auth::get_user_from_headers; use crate::database::models::charge_item::DBCharge; use crate::database::models::notification_item::NotificationBuilder; -use crate::database::models::user_item::DBUser; -use crate::database::models::user_subscription_item::DBUserSubscription; -use crate::database::models::users_redeemals::{self, UserRedeemal}; +use crate::database::models::products_tax_identifier_item::product_info_by_product_price_id; use crate::database::models::{ - charge_item, generate_charge_id, generate_user_subscription_id, - product_item, user_subscription_item, + charge_item, generate_charge_id, product_item, user_subscription_item, }; use crate::database::redis::RedisPool; use crate::models::billing::{ @@ -19,7 +17,7 @@ use crate::models::pats::Scopes; use crate::models::users::Badges; use crate::queue::session::AuthQueue; use crate::routes::ApiError; -use crate::util::archon::{ArchonClient, CreateServerRequest, Specs}; +use crate::util::anrok; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use ariadne::ids::base62_impl::{parse_base62, to_base62}; use chrono::{Duration, Utc}; @@ -28,18 +26,16 @@ use rust_decimal::prelude::ToPrimitive; use serde::Serialize; use serde_with::serde_derive::Deserialize; use sqlx::{PgPool, Postgres, Transaction}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::str::FromStr; use stripe::{ - CreateCustomer, CreatePaymentIntent, CreateRefund, CreateSetupIntent, - CreateSetupIntentAutomaticPaymentMethods, - CreateSetupIntentAutomaticPaymentMethodsAllowRedirects, Currency, - CustomerId, CustomerInvoiceSettings, CustomerPaymentMethodRetrieval, - EventObject, EventType, PaymentIntentId, PaymentIntentOffSession, - PaymentIntentSetupFutureUsage, PaymentMethodId, SetupIntent, - UpdateCustomer, Webhook, + CreateRefund, CreateSetupIntent, CreateSetupIntentAutomaticPaymentMethods, + CreateSetupIntentAutomaticPaymentMethodsAllowRedirects, + CustomerInvoiceSettings, CustomerPaymentMethodRetrieval, EventObject, + EventType, PaymentIntentId, PaymentMethodId, SetupIntent, UpdateCustomer, + Webhook, }; -use tracing::{info, warn}; +use tracing::warn; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( @@ -155,6 +151,7 @@ pub struct ChargeRefund { } #[post("charge/{id}/refund")] +#[allow(clippy::too_many_arguments)] pub async fn refund_charge( req: HttpRequest, pool: web::Data, @@ -163,6 +160,7 @@ pub async fn refund_charge( info: web::Path<(crate::models::ids::ChargeId,)>, body: web::Json, stripe_client: web::Data, + anrok_client: web::Data, ) -> Result { let user = get_user_from_headers( &req, @@ -189,14 +187,14 @@ pub async fn refund_charge( .filter_map(|x| match x.status { ChargeStatus::Open | ChargeStatus::Processing - | ChargeStatus::Succeeded => Some(x.amount), + | ChargeStatus::Succeeded => Some(x.amount + x.tax_amount), ChargeStatus::Failed | ChargeStatus::Cancelled | ChargeStatus::Expiring => None, }) .sum::(); - let refundable = charge.amount - refunds; + let refundable = charge.amount + charge.tax_amount - refunds; let refund_amount = match body.0.amount { ChargeRefundAmount::Full => refundable, @@ -217,8 +215,8 @@ pub async fn refund_charge( )); } - let (id, net) = if refund_amount == 0 { - (None, None) + let (id, net, anrok_result) = if refund_amount == 0 { + (None, None, None) } else { match charge.payment_platform { PaymentPlatform::Stripe => { @@ -230,14 +228,46 @@ pub async fn refund_charge( let mut metadata = HashMap::new(); metadata.insert( - "modrinth_user_id".to_string(), + MODRINTH_USER_ID.to_owned(), to_base62(user.id.0), ); metadata.insert( - "modrinth_charge_id".to_string(), + MODRINTH_CHARGE_ID.to_owned(), to_base62(charge.id.0 as u64), ); + let pi = stripe::PaymentIntent::retrieve( + &stripe_client, + &payment_platform_id, + &["payment_method"], + ) + .await?; + + let Some(billing_address) = pi + .payment_method + .and_then(|x| x.into_object()) + .and_then(|x| x.billing_details.address) + else { + return Err(ApiError::InvalidInput( + "Couldn't retrieve billing address for payment method!" + .to_owned(), + )); + }; + + let tax_id = product_info_by_product_price_id( + charge.price_id, + &**pool, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "Could not find product tax info for price ID!" + .to_owned(), + ) + })? + .tax_identifier + .tax_processor_id; + let refund = stripe::Refund::create( &stripe_client, CreateRefund { @@ -252,12 +282,26 @@ pub async fn refund_charge( ) .await?; + let anrok_txn_result = anrok_client.create_or_update_txn( + &anrok::Transaction { + id: anrok::transaction_id_stripe_pyr(&refund.id), + fields: anrok::TransactionFields { + customer_address: anrok::Address::from_stripe_address(&billing_address), + currency_code: charge.currency_code.clone(), + accounting_time: Utc::now(), + accounting_time_zone: anrok::AccountingTimeZone::Utc, + line_items: vec![anrok::LineItem::new_including_tax_amount(tax_id, refund_amount)], + } + } + ).await; + ( - Some(refund.id.to_string()), + Some(refund.id), refund .balance_transaction .and_then(|x| x.into_object()) .map(|x| x.net), + Some(anrok_txn_result), ) } else { return Err(ApiError::InvalidInput( @@ -283,7 +327,7 @@ pub async fn refund_charge( user_id: charge.user_id, price_id: charge.price_id, amount: -refund_amount, - currency_code: charge.currency_code, + tax_amount: charge.tax_amount, status: ChargeStatus::Succeeded, due: Utc::now(), last_attempt: None, @@ -291,13 +335,17 @@ pub async fn refund_charge( subscription_id: charge.subscription_id, subscription_interval: charge.subscription_interval, payment_platform: charge.payment_platform, - payment_platform_id: id, + tax_platform_id: id.as_ref().map(anrok::transaction_id_stripe_pyr), + payment_platform_id: id.as_ref().map(|x| x.to_string()), parent_charge_id: if refund_amount != 0 { Some(charge.id) } else { None }, net, + currency_code: charge.currency_code, + tax_last_updated: Some(Utc::now()), + tax_drift_loss: Some(0), } .upsert(&mut transaction) .await?; @@ -317,9 +365,16 @@ pub async fn refund_charge( } transaction.commit().await?; + + if let Some(Err(error)) = anrok_result { + return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ + "error": "partial_failure", + "description": &format!("This refund was not processed by the tax processing system. It was still processed on Stripe's end. Manual intervention is required to add a tax record for the refund charge. This will not impact the customer. Tax API Error: {error}") + }))); + } } - Ok(HttpResponse::NoContent().body("")) + Ok(HttpResponse::NoContent().finish()) } #[derive(Deserialize)] @@ -347,6 +402,7 @@ pub async fn edit_subscription( edit_subscription: web::Json, query: web::Query, stripe_client: web::Data, + anrok_client: web::Data, ) -> Result { let user = get_user_from_headers( &req, @@ -486,216 +542,6 @@ pub async fn edit_subscription( )) } - struct IntentMetadata { - pi: stripe::PaymentIntent, - amount: i64, - tax: i64, - } - - #[allow(clippy::too_many_arguments)] - async fn create_intent_for_charge_promotion( - pg: &PgPool, - redis: &RedisPool, - txn: &mut sqlx::PgTransaction<'_>, - stripe_client: &stripe::Client, - user: &crate::models::v3::users::User, - subscription: &user_subscription_item::DBUserSubscription, - current_product_price: &product_item::DBProductPrice, - new_product_price: product_item::DBProductPrice, - new_region: String, - new_interval: PriceDuration, - payment_method: Option, - ) -> Result { - let charge_id = generate_charge_id(txn).await?; - - let customer_id = get_or_create_customer( - user.id, - user.stripe_customer_id.as_deref(), - user.email.as_deref(), - stripe_client, - pg, - redis, - ) - .await?; - - let new_price_value = match new_product_price.prices { - Price::OneTime { ref price } => *price, - Price::Recurring { ref intervals } => { - *intervals - .get(&new_interval) - .ok_or_else(|| ApiError::InvalidInput("Could not find a valid price for the specified duration".to_owned()))? - } - } as i64; - - let currency = Currency::from_str( - ¤t_product_price.currency_code.to_lowercase(), - ) - .map_err(|_| { - ApiError::InvalidInput("Invalid currency code".to_string()) - })?; - - let mut intent = CreatePaymentIntent::new(new_price_value, currency); - - let mut metadata = HashMap::new(); - metadata.insert("modrinth_user_id".to_string(), to_base62(user.id.0)); - metadata.insert( - "modrinth_charge_id".to_string(), - to_base62(charge_id.0 as u64), - ); - metadata.insert( - "modrinth_subscription_id".to_string(), - to_base62(subscription.id.0 as u64), - ); - metadata.insert( - "modrinth_price_id".to_string(), - to_base62(new_product_price.id.0 as u64), - ); - metadata.insert( - "modrinth_subscription_interval".to_string(), - new_interval.as_str().to_string(), - ); - metadata.insert( - "modrinth_charge_type".to_string(), - ChargeType::Subscription.as_str().to_string(), - ); - metadata.insert("modrinth_new_region".to_string(), new_region); - - intent.customer = Some(customer_id); - intent.metadata = Some(metadata); - intent.receipt_email = user.email.as_deref(); - intent.setup_future_usage = - Some(PaymentIntentSetupFutureUsage::OffSession); - - if let Some(ref payment_method) = payment_method { - let Ok(payment_method_id) = - PaymentMethodId::from_str(payment_method) - else { - return Err(ApiError::InvalidInput( - "Invalid payment method id".to_string(), - )); - }; - intent.payment_method = Some(payment_method_id); - } - - let intent = - stripe::PaymentIntent::create(stripe_client, intent).await?; - - // Note: we do not want to update the open charge here. It will be modified to - // be the next charge of the subscription in the stripe webhook, after the payment - // intent succeeds. - // - // We also shouldn't delete it, because if the payment fails, the expiring - // charge will be gone and the preview subscription will never be unprovisioned. - - Ok(IntentMetadata { - pi: intent, - amount: new_price_value, - tax: 0, - }) - } - - #[allow(clippy::too_many_arguments)] - async fn create_intent_for_charge_proration( - pg: &PgPool, - redis: &RedisPool, - txn: &mut sqlx::PgTransaction<'_>, - stripe_client: &stripe::Client, - open_charge: &mut charge_item::DBCharge, - user: &crate::models::v3::users::User, - subscription: &user_subscription_item::DBUserSubscription, - current_price: &product_item::DBProductPrice, - new_product_price: &product_item::DBProductPrice, - new_region: Option, - new_interval: Option, - payment_method: Option, - proration: i64, - ) -> Result { - let charge_id = generate_charge_id(txn).await?; - - let customer_id = get_or_create_customer( - user.id, - user.stripe_customer_id.as_deref(), - user.email.as_deref(), - stripe_client, - pg, - redis, - ) - .await?; - - let currency = - Currency::from_str(¤t_price.currency_code.to_lowercase()) - .map_err(|_| { - ApiError::InvalidInput("Invalid currency code".to_string()) - })?; - - // Add either the *new* interval or the *current* interval to the metadata. - // Once the proration charge succeeds, the open charge's interval will be updated - // to reflect this attached interval. - // - // The proration charge will also have this interval attached to itself, though - // it doesn't necessarily have much significance. - let new_subscription_interval = - new_interval.or(open_charge.subscription_interval); - - let mut intent = CreatePaymentIntent::new(proration, currency); - - let mut metadata = HashMap::new(); - metadata.insert("modrinth_user_id".to_string(), to_base62(user.id.0)); - metadata.insert( - "modrinth_charge_id".to_string(), - to_base62(charge_id.0 as u64), - ); - metadata.insert( - "modrinth_subscription_id".to_string(), - to_base62(subscription.id.0 as u64), - ); - metadata.insert( - "modrinth_price_id".to_string(), - to_base62(new_product_price.id.0 as u64), - ); - metadata.insert( - "modrinth_subscription_interval".to_string(), - new_subscription_interval - .unwrap_or(PriceDuration::Monthly) - .as_str() - .to_string(), - ); - metadata.insert( - "modrinth_charge_type".to_string(), - ChargeType::Proration.as_str().to_string(), - ); - if let Some(region) = &new_region { - metadata - .insert("modrinth_new_region".to_string(), region.to_owned()); - } - - intent.customer = Some(customer_id); - intent.metadata = Some(metadata); - intent.receipt_email = user.email.as_deref(); - intent.setup_future_usage = - Some(PaymentIntentSetupFutureUsage::OffSession); - - if let Some(payment_method) = &payment_method { - let Ok(payment_method_id) = - PaymentMethodId::from_str(payment_method) - else { - return Err(ApiError::InvalidInput( - "Invalid payment method id".to_string(), - )); - }; - intent.payment_method = Some(payment_method_id); - } - - let intent = - stripe::PaymentIntent::create(stripe_client, intent).await?; - - Ok(IntentMetadata { - pi: intent, - amount: proration, - tax: 0, - }) - } - let (id,) = info.into_inner(); let dry = query.dry.unwrap_or_default(); @@ -772,7 +618,7 @@ pub async fn edit_subscription( product: Some(product_id), region, interval, - payment_method, + payment_method: Some(payment_method), .. } => { // Find the new product's price item based on the current currency. @@ -805,6 +651,13 @@ pub async fn edit_subscription( pub required_payment_is_proration: bool, } + let currency = stripe::Currency::from_str( + ¤t_price.currency_code.to_lowercase(), + ) + .map_err(|_| { + ApiError::InvalidInput("Invalid currency code".to_string()) + })?; + // The next charge is an expiring charge, so we are promoting the subscription to a paid product. // Instead of doing a proration (since the product is likely free) we either: // @@ -835,23 +688,44 @@ pub async fn edit_subscription( } if req == PaymentRequirement::RequiresPayment { - let intent = create_intent_for_charge_promotion( + let results = create_or_update_payment_intent( &pool, &redis, - &mut transaction, &stripe_client, - &user, - &subscription, - ¤t_price, - new_product_price, - new_region, - new_interval, - payment_method, + &anrok_client, + PaymentBootstrapOptions { + user: &user, + payment_intent: None, + payment_session: PaymentSession::Interactive { + payment_request_type: + PaymentRequestType::PaymentMethod { + id: payment_method, + }, + }, + attached_charge: AttachedCharge::Promotion { + product_id: new_product_price.product_id.into(), + interval: new_interval, + current_subscription: subscription.id.into(), + new_region, + }, + currency: CurrencyMode::Set(currency), + attach_payment_metadata: None, + }, ) .await?; - Some(intent) + Some(results) } else { + /* + open_charge.status = ChargeStatus::Open; + open_charge.payment_platform = PaymentPlatform::Stripe; + open_charge.amount = new_product_price.prices.get_interval(new_interval).ok_or_else(|| ApiError::InvalidInput("Could not find a valid price for the user's duration".to_owned()))?; + open_charge.currency_code = new_product_price.currency_code; + open_charge.subscription_interval = Some(new_interval); + open_charge.price_id = new_product_price.id; + open_charge.type_ = ChargeType::Subscription; + */ + None } } else { @@ -895,24 +769,41 @@ pub async fn edit_subscription( } Proration::Required(proration) => { - let intent = create_intent_for_charge_proration( + let next_interval = interval + .or(open_charge.subscription_interval) + .unwrap_or(PriceDuration::Monthly); + + let results = create_or_update_payment_intent( &pool, &redis, - &mut transaction, &stripe_client, - &mut open_charge, - &user, - &subscription, - ¤t_price, - &new_product_price, - region, - interval, - payment_method, - proration as i64, + &anrok_client, + PaymentBootstrapOptions { + user: &user, + payment_intent: None, + payment_session: PaymentSession::Interactive { + payment_request_type: + PaymentRequestType::PaymentMethod { + id: payment_method, + }, + }, + attached_charge: AttachedCharge::Proration { + amount: proration as i64, + next_product_id: new_product_price + .product_id + .into(), + next_interval, + current_subscription: subscription + .id + .into(), + }, + currency: CurrencyMode::Set(currency), + attach_payment_metadata: None, + }, ) .await?; - Some(intent) + Some(results) } } } @@ -929,6 +820,15 @@ pub async fn edit_subscription( )); } + SubscriptionEdit { + payment_method: None, + .. + } => { + return Err(ApiError::InvalidInput( + "A known payment method is required at this point to calculate tax information".to_owned(), + )); + } + _ => { return Err(ApiError::InvalidInput( "Unexpected combination of fields in subscription PATCH request. Please either only specify `cancelled`, or specify `product` \ @@ -947,12 +847,19 @@ pub async fn edit_subscription( transaction.commit().await?; } - if let Some(IntentMetadata { pi, amount, tax }) = maybe_intent_metadata { + if let Some(PaymentBootstrapResults { + new_payment_intent: Some(pi), + subtotal, + tax, + payment_method: _, + price_id: _, + }) = maybe_intent_metadata + { Ok(HttpResponse::Ok().json(serde_json::json!({ "payment_intent_id": pi.id, "client_secret": pi.client_secret, "tax": tax, - "total": amount + "total": subtotal + tax, }))) } else { Ok(HttpResponse::NoContent().finish()) @@ -1393,68 +1300,6 @@ pub struct PaymentRequest { pub metadata: Option, } -fn infer_currency_code(country: &str) -> String { - match country { - "US" => "USD", - "GB" => "GBP", - "EU" => "EUR", - "AT" => "EUR", - "BE" => "EUR", - "CY" => "EUR", - "EE" => "EUR", - "ES" => "EUR", - "FI" => "EUR", - "FR" => "EUR", - "DE" => "EUR", - "GR" => "EUR", - "IE" => "EUR", - "IT" => "EUR", - "LV" => "EUR", - "LT" => "EUR", - "LU" => "EUR", - "MT" => "EUR", - "NL" => "EUR", - "PT" => "EUR", - "SK" => "EUR", - "SI" => "EUR", - "RU" => "RUB", - "BR" => "BRL", - "JP" => "JPY", - "ID" => "IDR", - "MY" => "MYR", - "PH" => "PHP", - "TH" => "THB", - "VN" => "VND", - "KR" => "KRW", - "TR" => "TRY", - "UA" => "UAH", - "MX" => "MXN", - "CA" => "CAD", - "NZ" => "NZD", - "NO" => "NOK", - "PL" => "PLN", - "CH" => "CHF", - "LI" => "CHF", - "IN" => "INR", - "CL" => "CLP", - "PE" => "PEN", - "CO" => "COP", - "ZA" => "ZAR", - "HK" => "HKD", - "AR" => "ARS", - "KZ" => "KZT", - "UY" => "UYU", - "CN" => "CNY", - "AU" => "AUD", - "TW" => "TWD", - "SA" => "SAR", - "QA" => "QAR", - "SG" => "SGD", - _ => "USD", - } - .to_string() -} - #[post("payment")] pub async fn initiate_payment( req: HttpRequest, @@ -1462,6 +1307,7 @@ pub async fn initiate_payment( redis: web::Data, session_queue: web::Data, stripe_client: web::Data, + anrok_client: web::Data, payment_request: web::Json, ) -> Result { let user = get_user_from_headers( @@ -1474,317 +1320,47 @@ pub async fn initiate_payment( .await? .1; - let (user_country, payment_method) = match &payment_request.type_ { - PaymentRequestType::PaymentMethod { id } => { - let payment_method_id = stripe::PaymentMethodId::from_str(id) - .map_err(|_| { - ApiError::InvalidInput( - "Invalid payment method id".to_string(), - ) - })?; + let payment_request = payment_request.into_inner(); - let payment_method = stripe::PaymentMethod::retrieve( - &stripe_client, - &payment_method_id, - &[], - ) - .await?; - - let country = payment_method - .billing_details - .address - .as_ref() - .and_then(|x| x.country.clone()); - - (country, payment_method) - } - PaymentRequestType::ConfirmationToken { token } => { - #[derive(Deserialize)] - struct ConfirmationToken { - payment_method_preview: Option, - } - - let mut confirmation: serde_json::Value = stripe_client - .get(&format!("confirmation_tokens/{token}")) - .await?; - - // We patch the JSONs to support the PaymentMethod struct - let p: json_patch::Patch = serde_json::from_value(serde_json::json!([ - { "op": "add", "path": "/payment_method_preview/id", "value": "pm_1PirTdJygY5LJFfKmPIaM1N1" }, - { "op": "add", "path": "/payment_method_preview/created", "value": 1723183475 }, - { "op": "add", "path": "/payment_method_preview/livemode", "value": false } - ])).unwrap(); - json_patch::patch(&mut confirmation, &p).unwrap(); - - let confirmation: ConfirmationToken = - serde_json::from_value(confirmation)?; - - let payment_method = - confirmation.payment_method_preview.ok_or_else(|| { - ApiError::InvalidInput( - "Confirmation token is missing payment method!" - .to_string(), - ) - })?; - - let country = payment_method - .billing_details - .address - .as_ref() - .and_then(|x| x.country.clone()); - - (country, payment_method) - } - }; - - let country = user_country.as_deref().unwrap_or("US"); - let recommended_currency_code = infer_currency_code(country); - - let (price, currency_code, interval, price_id, charge_id, charge_type) = - match payment_request.charge { - ChargeRequestType::Existing { id } => { - let charge = - crate::database::models::charge_item::DBCharge::get( - id.into(), - &**pool, - ) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "Specified charge could not be found!".to_string(), - ) - })?; - - ( - charge.amount, - charge.currency_code, - charge.subscription_interval, - charge.price_id, - Some(id), - charge.type_, - ) - } - ChargeRequestType::New { - product_id, - interval, - } => { - let product = - product_item::DBProduct::get(product_id.into(), &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput( - "Specified product could not be found!" - .to_string(), - ) - })?; - - let mut product_prices = - product_item::DBProductPrice::get_all_public_product_prices( - product.id, &**pool, - ) - .await?; - - let price_item = if let Some(pos) = product_prices - .iter() - .position(|x| x.currency_code == recommended_currency_code) - { - product_prices.remove(pos) - } else if let Some(pos) = - product_prices.iter().position(|x| x.currency_code == "USD") - { - product_prices.remove(pos) - } else { - return Err(ApiError::InvalidInput( - "Could not find a valid price for the user's country" - .to_string(), - )); - }; - - let price = match price_item.prices { - Price::OneTime { price } => price, - Price::Recurring { ref intervals } => { - let interval = interval.ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid interval for the user's country".to_string(), - ) - })?; - - *intervals.get(&interval).ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid price for the user's country".to_string(), - ) - })? - } - }; - - if let Price::Recurring { .. } = price_item.prices - && product.unitary - { - let user_subscriptions = - user_subscription_item::DBUserSubscription::get_all_user( - user.id.into(), - &**pool, - ) - .await?; - - let user_products = product_item::DBProductPrice::get_many( - &user_subscriptions - .iter() - .filter(|x| { - x.status == SubscriptionStatus::Provisioned - }) - .map(|x| x.price_id) - .collect::>(), - &**pool, - ) - .await?; - - if user_products - .into_iter() - .any(|x| x.product_id == product.id) - { - return Err(ApiError::InvalidInput( - "You are already subscribed to this product!" - .to_string(), - )); - } - } - - ( - price as i64, - price_item.currency_code, - interval, - price_item.id, - None, - if let Price::Recurring { .. } = price_item.prices { - ChargeType::Subscription - } else { - ChargeType::OneTime - }, - ) - } - }; - - let customer = get_or_create_customer( - user.id, - user.stripe_customer_id.as_deref(), - user.email.as_deref(), - &stripe_client, + let results = create_or_update_payment_intent( &pool, &redis, + &stripe_client, + &anrok_client, + PaymentBootstrapOptions { + user: &user, + payment_intent: payment_request.existing_payment_intent, + payment_session: PaymentSession::Interactive { + payment_request_type: payment_request.type_, + }, + attached_charge: AttachedCharge::from_charge_request_type( + &**pool, + payment_request.charge, + ) + .await?, + currency: CurrencyMode::Infer, + attach_payment_metadata: payment_request.metadata, + }, ) .await?; - let stripe_currency = Currency::from_str(¤cy_code.to_lowercase()) - .map_err(|_| { - ApiError::InvalidInput("Invalid currency code".to_string()) - })?; - if let Some(payment_intent_id) = &payment_request.existing_payment_intent { - let mut update_payment_intent = stripe::UpdatePaymentIntent { - amount: Some(price), - currency: Some(stripe_currency), - customer: Some(customer), - metadata: interval.map(|interval| { - HashMap::from([( - "modrinth_subscription_interval".to_string(), - interval.as_str().to_string(), - )]) - }), - ..Default::default() - }; - - if let PaymentRequestType::PaymentMethod { .. } = payment_request.type_ - { - update_payment_intent.payment_method = - Some(payment_method.id.clone()); + match results.new_payment_intent { + Some(payment_intent) => { + Ok(HttpResponse::Ok().json(serde_json::json!({ + "payment_intent_id": payment_intent.id, + "client_secret": payment_intent.client_secret, + "price_id": to_base62(results.price_id.0 as u64), + "tax": results.tax, + "total": results.subtotal + results.tax, + "payment_method": results.payment_method, + }))) } - - stripe::PaymentIntent::update( - &stripe_client, - payment_intent_id, - update_payment_intent, - ) - .await?; - - Ok(HttpResponse::Ok().json(serde_json::json!({ - "price_id": to_base62(price_id.0 as u64), - "tax": 0, - "total": price, - "payment_method": payment_method, - }))) - } else { - let mut intent = CreatePaymentIntent::new(price, stripe_currency); - - let mut metadata = HashMap::new(); - metadata.insert("modrinth_user_id".to_string(), to_base62(user.id.0)); - - if let Some(payment_metadata) = &payment_request.metadata { - metadata.insert( - "modrinth_payment_metadata".to_string(), - serde_json::to_string(&payment_metadata)?, - ); - } - - metadata.insert( - "modrinth_charge_type".to_string(), - charge_type.as_str().to_string(), - ); - - if let Some(charge_id) = charge_id { - metadata.insert( - "modrinth_charge_id".to_string(), - to_base62(charge_id.0), - ); - } else { - let mut transaction = pool.begin().await?; - let charge_id = generate_charge_id(&mut transaction).await?; - let subscription_id = - generate_user_subscription_id(&mut transaction).await?; - - metadata.insert( - "modrinth_charge_id".to_string(), - to_base62(charge_id.0 as u64), - ); - metadata.insert( - "modrinth_subscription_id".to_string(), - to_base62(subscription_id.0 as u64), - ); - - metadata.insert( - "modrinth_price_id".to_string(), - to_base62(price_id.0 as u64), - ); - - if let Some(interval) = interval { - metadata.insert( - "modrinth_subscription_interval".to_string(), - interval.as_str().to_string(), - ); - } - } - - intent.customer = Some(customer); - intent.metadata = Some(metadata); - intent.receipt_email = user.email.as_deref(); - intent.setup_future_usage = - Some(PaymentIntentSetupFutureUsage::OffSession); - - if let PaymentRequestType::PaymentMethod { .. } = payment_request.type_ - { - intent.payment_method = Some(payment_method.id.clone()); - } - - let payment_intent = - stripe::PaymentIntent::create(&stripe_client, intent).await?; - - Ok(HttpResponse::Ok().json(serde_json::json!({ - "payment_intent_id": payment_intent.id, - "client_secret": payment_intent.client_secret, - "price_id": to_base62(price_id.0 as u64), - "tax": 0, - "total": price, - "payment_method": payment_method, - }))) + None => Ok(HttpResponse::Ok().json(serde_json::json!({ + "price_id": to_base62(results.price_id.0 as u64), + "tax": results.tax, + "total": results.subtotal + results.tax, + "payment_method": results.payment_method, + }))), } } @@ -1816,12 +1392,13 @@ pub async fn stripe_webhook( Option, pub payment_metadata: Option, pub new_region: Option, + pub next_tax_amount: i64, } #[allow(clippy::too_many_arguments)] async fn get_payment_intent_metadata( payment_intent_id: PaymentIntentId, - amount: i64, + payment_intent_amount: i64, currency: String, metadata: HashMap, pool: &PgPool, @@ -1831,7 +1408,7 @@ pub async fn stripe_webhook( ) -> Result { 'metadata: { let Some(user_id) = metadata - .get("modrinth_user_id") + .get(MODRINTH_USER_ID) .and_then(|x| parse_base62(x).ok()) .map(|x| crate::database::models::ids::DBUserId(x as i64)) else { @@ -1848,11 +1425,11 @@ pub async fn stripe_webhook( }; let payment_metadata = metadata - .get("modrinth_payment_metadata") + .get(MODRINTH_PAYMENT_METADATA) .and_then(|x| serde_json::from_str(x).ok()); let Some(charge_id) = metadata - .get("modrinth_charge_id") + .get(MODRINTH_CHARGE_ID) .and_then(|x| parse_base62(x).ok()) .map(|x| { crate::database::models::ids::DBChargeId(x as i64) @@ -1861,15 +1438,22 @@ pub async fn stripe_webhook( break 'metadata; }; + let tax_amount = metadata + .get(MODRINTH_TAX_AMOUNT) + .and_then(|x| x.parse::().ok()) + .unwrap_or(0); + + let subtotal_amount = payment_intent_amount - tax_amount; + let Some(charge_type) = metadata - .get("modrinth_charge_type") + .get(MODRINTH_CHARGE_TYPE) .map(|x| ChargeType::from_string(x)) else { break 'metadata; }; let new_region = - metadata.get("modrinth_new_region").map(String::to_owned); + metadata.get(MODRINTH_NEW_REGION).map(String::to_owned); let (charge, price, product, subscription, new_region) = if let Some(mut charge) = @@ -1900,10 +1484,13 @@ pub async fn stripe_webhook( charge.last_attempt = Some(Utc::now()); charge.payment_platform_id = Some(payment_intent_id.to_string()); + charge.tax_amount = tax_amount; + charge.tax_platform_id = None; charge.upsert(transaction).await?; if let Some(subscription_id) = charge.subscription_id { - let maybe_subscription = user_subscription_item::DBUserSubscription::get( + let maybe_subscription = + user_subscription_item::DBUserSubscription::get( subscription_id, pool, ) @@ -1948,7 +1535,7 @@ pub async fn stripe_webhook( } } else { let Some(price_id) = metadata - .get("modrinth_price_id") + .get(MODRINTH_PRICE_ID) .and_then(|x| parse_base62(x).ok()) .map(|x| { crate::database::models::ids::DBProductPriceId( @@ -1980,7 +1567,7 @@ pub async fn stripe_webhook( Price::OneTime { .. } => None, Price::Recurring { intervals } => { let Some(interval) = metadata - .get("modrinth_subscription_interval") + .get(MODRINTH_SUBSCRIPTION_INTERVAL) .map(|x| PriceDuration::from_string(x)) else { break 'metadata; @@ -1988,31 +1575,31 @@ pub async fn stripe_webhook( if intervals.get(&interval).is_some() { let Some(subscription_id) = metadata - .get("modrinth_subscription_id") - .and_then(|x| parse_base62(x).ok()) - .map(|x| { - crate::database::models::ids::DBUserSubscriptionId(x as i64) - }) else { - break 'metadata; - }; + .get(MODRINTH_SUBSCRIPTION_ID) + .and_then(|x| parse_base62(x).ok()) + .map(|x| { + crate::database::models::ids::DBUserSubscriptionId(x as i64) + }) else { + break 'metadata; + }; let subscription = if let Some(mut subscription) = user_subscription_item::DBUserSubscription::get(subscription_id, pool).await? { - subscription.status = SubscriptionStatus::Unprovisioned; - subscription.price_id = price_id; - subscription.interval = interval; + subscription.status = SubscriptionStatus::Unprovisioned; + subscription.price_id = price_id; + subscription.interval = interval; - subscription - } else { - user_subscription_item::DBUserSubscription { - id: subscription_id, - user_id, - price_id, - interval, - created: Utc::now(), - status: SubscriptionStatus::Unprovisioned, - metadata: None, - } - }; + subscription + } else { + user_subscription_item::DBUserSubscription { + id: subscription_id, + user_id, + price_id, + interval, + created: Utc::now(), + status: SubscriptionStatus::Unprovisioned, + metadata: None, + } + }; if charge_status != ChargeStatus::Failed { subscription @@ -2031,7 +1618,7 @@ pub async fn stripe_webhook( id: charge_id, user_id, price_id, - amount, + amount: subtotal_amount, currency_code: currency, status: charge_status, due: Utc::now(), @@ -2047,8 +1634,12 @@ pub async fn stripe_webhook( payment_platform_id: Some( payment_intent_id.to_string(), ), + tax_amount, + tax_platform_id: None, parent_charge_id: None, net: None, + tax_last_updated: Some(Utc::now()), + tax_drift_loss: Some(0), }; if charge_status != ChargeStatus::Failed { @@ -2066,6 +1657,7 @@ pub async fn stripe_webhook( user_subscription_item: subscription, payment_metadata, new_region, + next_tax_amount: tax_amount, }); } @@ -2190,7 +1782,7 @@ pub async fn stripe_webhook( cpu, swap_mb: swap, storage_mb: storage, - force_move: region.is_some().then_some(true), + force_move: (region.is_some() && subscription_metadata.is_medal()).then_some(true), region: region.as_deref(), }; @@ -2364,6 +1956,7 @@ pub async fn stripe_webhook( charge.amount = new_price as i64; charge.price_id = metadata.product_price_item.id; + charge.tax_last_updated = None; } else { // Note: do not update the due date charge.subscription_interval = @@ -2383,6 +1976,7 @@ pub async fn stripe_webhook( user_id: metadata.user_item.id, price_id: metadata.product_price_item.id, amount: new_price as i64, + tax_amount: metadata.next_tax_amount, currency_code: metadata .product_price_item .currency_code, @@ -2406,6 +2000,9 @@ pub async fn stripe_webhook( payment_platform_id: None, parent_charge_id: None, net: None, + tax_platform_id: None, + tax_last_updated: Some(Utc::now()), + tax_drift_loss: Some(0), } .upsert(&mut transaction) .await?; @@ -2544,554 +2141,4 @@ pub async fn stripe_webhook( Ok(HttpResponse::Ok().finish()) } -async fn get_or_create_customer( - user_id: ariadne::ids::UserId, - stripe_customer_id: Option<&str>, - user_email: Option<&str>, - client: &stripe::Client, - pool: &PgPool, - redis: &RedisPool, -) -> Result { - if let Some(customer_id) = - stripe_customer_id.and_then(|x| stripe::CustomerId::from_str(x).ok()) - { - Ok(customer_id) - } else { - let mut metadata = HashMap::new(); - metadata.insert("modrinth_user_id".to_string(), to_base62(user_id.0)); - - let customer = stripe::Customer::create( - client, - CreateCustomer { - email: user_email, - metadata: Some(metadata), - ..Default::default() - }, - ) - .await?; - - sqlx::query!( - " - UPDATE users - SET stripe_customer_id = $1 - WHERE id = $2 - ", - customer.id.as_str(), - user_id.0 as i64 - ) - .execute(pool) - .await?; - - crate::database::models::user_item::DBUser::clear_caches( - &[(user_id.into(), None)], - redis, - ) - .await?; - - Ok(customer.id) - } -} - -pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) { - info!("Indexing subscriptions"); - - let res = async { - let mut transaction = pool.begin().await?; - let mut clear_cache_users = Vec::new(); - - // If an active subscription has: - // - A canceled charge due now - // - An expiring charge due now - // - A failed charge more than two days ago - // It should be unprovisioned. - let all_charges = DBCharge::get_unprovision(&pool).await?; - - let mut all_subscriptions = - user_subscription_item::DBUserSubscription::get_many( - &all_charges - .iter() - .filter_map(|x| x.subscription_id) - .collect::>() - .into_iter() - .collect::>(), - &pool, - ) - .await?; - let subscription_prices = product_item::DBProductPrice::get_many( - &all_subscriptions - .iter() - .map(|x| x.price_id) - .collect::>() - .into_iter() - .collect::>(), - &pool, - ) - .await?; - let subscription_products = product_item::DBProduct::get_many( - &subscription_prices - .iter() - .map(|x| x.product_id) - .collect::>() - .into_iter() - .collect::>(), - &pool, - ) - .await?; - let users = crate::database::models::DBUser::get_many_ids( - &all_subscriptions - .iter() - .map(|x| x.user_id) - .collect::>() - .into_iter() - .collect::>(), - &pool, - &redis, - ) - .await?; - - for charge in all_charges { - let Some(subscription) = all_subscriptions - .iter_mut() - .find(|x| Some(x.id) == charge.subscription_id) - else { - continue; - }; - - if subscription.status == SubscriptionStatus::Unprovisioned { - continue; - } - - let Some(product_price) = subscription_prices - .iter() - .find(|x| x.id == subscription.price_id) - else { - continue; - }; - - let Some(product) = subscription_products - .iter() - .find(|x| x.id == product_price.product_id) - else { - continue; - }; - - let Some(user) = - users.iter().find(|x| x.id == subscription.user_id) - else { - continue; - }; - - let unprovisioned = match product.metadata { - ProductMetadata::Midas => { - let badges = user.badges - Badges::MIDAS; - - sqlx::query!( - " - UPDATE users - SET badges = $1 - WHERE (id = $2) - ", - badges.bits() as i64, - user.id as crate::database::models::ids::DBUserId, - ) - .execute(&mut *transaction) - .await?; - - true - } - - ProductMetadata::Pyro { .. } - | ProductMetadata::Medal { .. } => 'server: { - let server_id = match &subscription.metadata { - Some(SubscriptionMetadata::Pyro { id, region: _ }) => { - id - } - Some(SubscriptionMetadata::Medal { id }) => id, - _ => break 'server true, - }; - - let res = reqwest::Client::new() - .post(format!( - "{}/modrinth/v0/servers/{}/suspend", - dotenvy::var("ARCHON_URL")?, - server_id - )) - .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) - .json(&serde_json::json!({ - "reason": if charge.status == ChargeStatus::Cancelled || charge.status == ChargeStatus::Expiring { - "cancelled" - } else { - "paymentfailed" - } - })) - .send() - .await; - - if let Err(e) = res { - warn!("Error suspending pyro server: {:?}", e); - false - } else { - true - } - } - }; - - if unprovisioned { - subscription.status = SubscriptionStatus::Unprovisioned; - subscription.upsert(&mut transaction).await?; - } - - clear_cache_users.push(user.id); - } - - crate::database::models::DBUser::clear_caches( - &clear_cache_users - .into_iter() - .map(|x| (x, None)) - .collect::>(), - &redis, - ) - .await?; - transaction.commit().await?; - - // If an offer redeemal has been processing for over 5 minutes, it should be set pending. - UserRedeemal::update_stuck_5_minutes(&pool).await?; - - // If an offer redeemal is pending, try processing it. - // Try processing it. - let pending_redeemals = UserRedeemal::get_pending(&pool, 100).await?; - for redeemal in pending_redeemals { - if let Err(error) = - try_process_user_redeemal(&pool, &redis, redeemal).await - { - warn!(%error, "Failed to process a redeemal.") - } - } - - Ok::<(), ApiError>(()) - }; - - if let Err(e) = res.await { - warn!("Error indexing subscriptions: {:?}", e); - } - - info!("Done indexing subscriptions"); -} - -/// Attempts to process a user redeemal. -/// -/// Returns `Ok` if the entry has been succesfully processed, or will not be processed. -pub async fn try_process_user_redeemal( - pool: &PgPool, - redis: &RedisPool, - mut user_redeemal: UserRedeemal, -) -> Result<(), ApiError> { - // Immediately update redeemal row - user_redeemal.last_attempt = Some(Utc::now()); - user_redeemal.n_attempts += 1; - user_redeemal.status = users_redeemals::Status::Processing; - let updated = user_redeemal.update_status_if_pending(pool).await?; - - if !updated { - return Ok(()); - } - - let user_id = user_redeemal.user_id; - - // Find the Medal product's price & metadata - - let mut medal_products = - product_item::QueryProductWithPrices::list_by_product_type( - pool, "medal", - ) - .await?; - - let Some(product_item::QueryProductWithPrices { - id: _product_id, - metadata, - mut prices, - unitary: _, - }) = medal_products.pop() - else { - return Err(ApiError::Conflict( - "Missing Medal subscription product".to_owned(), - )); - }; - - let ProductMetadata::Medal { - cpu, - ram, - swap, - storage, - region, - } = metadata - else { - return Err(ApiError::Conflict( - "Missing or incorrect metadata for Medal subscription".to_owned(), - )); - }; - - let Some(medal_price) = prices.pop() else { - return Err(ApiError::Conflict( - "Missing price for Medal subscription".to_owned(), - )); - }; - - let (price_duration, price_amount) = match medal_price.prices { - Price::OneTime { price: _ } => { - return Err(ApiError::Conflict( - "Unexpected metadata for Medal subscription price".to_owned(), - )); - } - - Price::Recurring { intervals } => { - let Some((price_duration, price_amount)) = - intervals.into_iter().next() - else { - return Err(ApiError::Conflict( - "Missing price interval for Medal subscription".to_owned(), - )); - }; - - (price_duration, price_amount) - } - }; - - let price_id = medal_price.id; - - // Get the user's username - - let user = DBUser::get_id(user_id, pool, redis) - .await? - .ok_or(ApiError::NotFound)?; - - // Send the provision request to Archon. On failure, the redeemal will be "stuck" processing, - // and moved back to pending by `index_subscriptions`. - - let archon_client = ArchonClient::from_env()?; - let server_id = archon_client - .create_server(&CreateServerRequest { - user_id: to_base62(user_id.0 as u64), - name: format!("{}'s Medal server", user.username), - specs: Specs { - memory_mb: ram, - cpu, - swap_mb: swap, - storage_mb: storage, - }, - source: crate::util::archon::Empty::default(), - region, - tags: vec!["medal".to_owned()], - }) - .await?; - - let mut txn = pool.begin().await?; - - // Build a subscription using this price ID. - let subscription = DBUserSubscription { - id: generate_user_subscription_id(&mut txn).await?, - user_id, - price_id, - interval: PriceDuration::FiveDays, - created: Utc::now(), - status: SubscriptionStatus::Provisioned, - metadata: Some(SubscriptionMetadata::Medal { - id: server_id.to_string(), - }), - }; - - subscription.upsert(&mut txn).await?; - - // Insert an expiring charge, `index_subscriptions` will unprovision the - // subscription when expired. - DBCharge { - id: generate_charge_id(&mut txn).await?, - user_id, - price_id, - amount: price_amount.into(), - currency_code: medal_price.currency_code, - status: ChargeStatus::Expiring, - due: Utc::now() + price_duration.duration(), - last_attempt: None, - type_: ChargeType::Subscription, - subscription_id: Some(subscription.id), - subscription_interval: Some(subscription.interval), - payment_platform: PaymentPlatform::None, - payment_platform_id: None, - parent_charge_id: None, - net: None, - } - .upsert(&mut txn) - .await?; - - // Update `users_redeemal`, mark subscription as redeemed. - user_redeemal.status = users_redeemals::Status::Processed; - user_redeemal.update(&mut *txn).await?; - - txn.commit().await?; - - Ok(()) -} - -pub async fn index_billing( - stripe_client: stripe::Client, - pool: PgPool, - redis: RedisPool, -) { - info!("Indexing billing queue"); - let res = async { - // If a charge has continuously failed for more than a month, it should be cancelled - let charges_to_cancel = DBCharge::get_cancellable(&pool).await?; - - for mut charge in charges_to_cancel { - charge.status = ChargeStatus::Cancelled; - - let mut transaction = pool.begin().await?; - charge.upsert(&mut transaction).await?; - transaction.commit().await?; - } - - // If a charge is open and due or has been attempted more than two days ago, it should be processed - let charges_to_do = DBCharge::get_chargeable(&pool).await?; - - let prices = product_item::DBProductPrice::get_many( - &charges_to_do - .iter() - .map(|x| x.price_id) - .collect::>() - .into_iter() - .collect::>(), - &pool, - ) - .await?; - - let users = crate::database::models::DBUser::get_many_ids( - &charges_to_do - .iter() - .map(|x| x.user_id) - .collect::>() - .into_iter() - .collect::>(), - &pool, - &redis, - ) - .await?; - - for mut charge in charges_to_do { - let Some(product_price) = - prices.iter().find(|x| x.id == charge.price_id) - else { - continue; - }; - - let Some(user) = users.iter().find(|x| x.id == charge.user_id) - else { - continue; - }; - - let price = match &product_price.prices { - Price::OneTime { price } => Some(price), - Price::Recurring { intervals } => { - if let Some(ref interval) = charge.subscription_interval { - intervals.get(interval) - } else { - warn!( - "Could not find subscription for charge {:?}", - charge.id - ); - continue; - } - } - }; - - if let Some(price) = price { - let customer_id = get_or_create_customer( - user.id.into(), - user.stripe_customer_id.as_deref(), - user.email.as_deref(), - &stripe_client, - &pool, - &redis, - ) - .await?; - - let customer = stripe::Customer::retrieve( - &stripe_client, - &customer_id, - &[], - ) - .await?; - - let Ok(currency) = Currency::from_str( - &product_price.currency_code.to_lowercase(), - ) else { - warn!( - "Could not find currency for {}", - product_price.currency_code - ); - continue; - }; - - let mut intent = - CreatePaymentIntent::new(*price as i64, currency); - - let mut metadata = HashMap::new(); - metadata.insert( - "modrinth_user_id".to_string(), - to_base62(charge.user_id.0 as u64), - ); - metadata.insert( - "modrinth_charge_id".to_string(), - to_base62(charge.id.0 as u64), - ); - metadata.insert( - "modrinth_charge_type".to_string(), - charge.type_.as_str().to_string(), - ); - - intent.metadata = Some(metadata); - intent.customer = Some(customer.id); - - if let Some(payment_method) = customer - .invoice_settings - .and_then(|x| x.default_payment_method.map(|x| x.id())) - { - intent.payment_method = Some(payment_method); - intent.confirm = Some(true); - intent.off_session = - Some(PaymentIntentOffSession::Exists(true)); - - charge.status = ChargeStatus::Processing; - - if let Err(e) = - stripe::PaymentIntent::create(&stripe_client, intent) - .await - { - tracing::error!( - "Failed to create payment intent: {:?}", - e - ); - charge.status = ChargeStatus::Failed; - charge.last_attempt = Some(Utc::now()); - } - } else { - charge.status = ChargeStatus::Failed; - charge.last_attempt = Some(Utc::now()); - } - - let mut transaction = pool.begin().await?; - charge.upsert(&mut transaction).await?; - transaction.commit().await?; - } - } - - Ok::<(), ApiError>(()) - } - .await; - - if let Err(e) = res { - warn!("Error indexing billing queue: {:?}", e); - } - - info!("Done indexing billing queue"); -} +pub mod payments; diff --git a/apps/labrinth/src/routes/internal/billing/payments.rs b/apps/labrinth/src/routes/internal/billing/payments.rs new file mode 100644 index 000000000..ca1b9503b --- /dev/null +++ b/apps/labrinth/src/routes/internal/billing/payments.rs @@ -0,0 +1,798 @@ +use crate::database::models::charge_item::DBCharge; +use crate::database::models::{ + generate_charge_id, generate_user_subscription_id, product_item, + products_tax_identifier_item, user_subscription_item, +}; +use crate::database::redis::RedisPool; +use crate::models::ids::*; +use crate::models::v3::billing::SubscriptionStatus; +use crate::models::v3::users::User; +use crate::routes::ApiError; +use crate::util::anrok; + +use ariadne::ids::base62_impl::to_base62; +use ariadne::ids::*; +use serde::Deserialize; +use sqlx::PgPool; +use std::collections::HashMap; +use std::str::FromStr; +use stripe::{ + self, CreateCustomer, CreatePaymentIntent, Currency, CustomerId, + PaymentIntentOffSession, PaymentIntentSetupFutureUsage, PaymentMethod, + PaymentMethodId, +}; + +use super::{ + ChargeRequestType, ChargeType, PaymentRequestMetadata, PaymentRequestType, + Price, PriceDuration, +}; + +const DEFAULT_USER_COUNTRY: &str = "US"; + +pub const MODRINTH_SUBSCRIPTION_ID: &str = "modrinth_subscription_id"; +pub const MODRINTH_PRICE_ID: &str = "modrinth_price_id"; +pub const MODRINTH_SUBSCRIPTION_INTERVAL: &str = + "modrinth_subscription_interval"; +pub const MODRINTH_CHARGE_TYPE: &str = "modrinth_charge_type"; +pub const MODRINTH_NEW_REGION: &str = "modrinth_new_region"; +pub const MODRINTH_USER_ID: &str = "modrinth_user_id"; +pub const MODRINTH_CHARGE_ID: &str = "modrinth_charge_id"; +pub const MODRINTH_TAX_AMOUNT: &str = "modrinth_tax_amount"; +pub const MODRINTH_PAYMENT_METADATA: &str = "modrinth_payment_metadata"; + +pub enum AttachedCharge { + /// Create a proration charge. + /// + /// This should be accompanied by an interactive payment session. + Proration { + next_product_id: ProductId, + next_interval: PriceDuration, + current_subscription: UserSubscriptionId, + amount: i64, + }, + /// Create a promotion charge. + /// + /// This should be accompanied by an interactive payment session. + Promotion { + product_id: ProductId, + interval: PriceDuration, + current_subscription: UserSubscriptionId, + new_region: String, + }, + /// Base the payment intent amount and tax on the product's price at this interval, + /// but don't actually create a charge item until the payment intent is confirmed. + /// + /// The amount will be based on the product's price at this interval, + /// and tax calculated based on the payment method. + /// + /// This should be accompanied by an interactive payment session. + BaseUpon { + product_id: ProductId, + interval: Option, + }, + /// Use an existing charge to base the payment intent upon. + /// + /// This can be used in the case of resubscription flows. The amount from this + /// charge will be used, but the tax will be recalculated and the charge updated. + /// + /// The charge's status will NOT be updated - it is the caller's responsability to + /// update the charge's status on failure or success. + /// + /// This may be accompanied by an automated payment session. + UseExisting { charge: DBCharge }, +} + +impl AttachedCharge { + pub fn as_charge(&self) -> Option<&DBCharge> { + if let AttachedCharge::UseExisting { charge } = self { + Some(charge) + } else { + None + } + } + + pub async fn from_charge_request_type( + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + charge_request_type: ChargeRequestType, + ) -> Result { + Ok(match charge_request_type { + ChargeRequestType::Existing { id } => AttachedCharge::UseExisting { + charge: DBCharge::get(id.into(), exec).await?.ok_or_else( + || { + ApiError::InvalidInput( + "Could not find charge".to_string(), + ) + }, + )?, + }, + ChargeRequestType::New { + product_id, + interval, + } => AttachedCharge::BaseUpon { + product_id, + interval, + }, + }) + } +} + +pub enum PaymentSession { + Interactive { + payment_request_type: PaymentRequestType, + }, + AutomatedRenewal, +} + +impl PaymentSession { + pub fn set_payment_intent_session_options( + &self, + intent: &mut CreatePaymentIntent, + ) { + if matches!(self, PaymentSession::AutomatedRenewal) { + intent.off_session = Some(PaymentIntentOffSession::Exists(true)); // Mark as the customer isn't able to perform manual verification/isn't on-session + intent.confirm = Some(true); // Immediately confirm the PI + } else { + intent.off_session = None; + intent.setup_future_usage = + Some(PaymentIntentSetupFutureUsage::OffSession); + } + } +} + +pub enum CurrencyMode { + Set(Currency), + Infer, +} + +pub struct PaymentBootstrapOptions<'a> { + pub user: &'a User, + /// Update this payment intent instead of creating a new intent. + pub payment_intent: Option, + /// The status of the current payment session. This is used to derive the payment + /// method as well as set the appropriate parameters on the payment intent. + /// + /// For interactive payment flows, a `PaymentRequestType` can be attached, we can be + /// either an existing PaymentMethodId for existing payment methods, or a ConfirmationToken + /// (ctoken) for new payment methods. + /// + /// For automated subscription renewal flows, use the `AutomatedRenewal` variant to + /// select the default payment method from the Stripe customer. + /// + /// Taxes will always be collected. + /// + /// Note the charge will NOT be updated. It is the caller's responsability to update the charge + /// on success or failure. + pub payment_session: PaymentSession, + /// The charge the payment intent on should be based upon. + pub attached_charge: AttachedCharge, + /// The currency used for the payment amount. + pub currency: CurrencyMode, + /// Some products have additional provisioning metadata that should be attached to the payment + /// intent. + pub attach_payment_metadata: Option, +} + +pub struct PaymentBootstrapResults { + pub new_payment_intent: Option, + pub payment_method: PaymentMethod, + pub price_id: ProductPriceId, + pub subtotal: i64, + pub tax: i64, +} + +/// Updates a PaymentIntent or creates a new one, recalculating tax information and +/// setting metadata fields based on the specified payment request and session options. +/// +/// # Important notes +/// +/// - This function does not perform any database writes. It is the caller's responsability to, for +/// example, update the charge's status on success or failure, or update the charge's tax amount, +/// tax eligibility or payment and tax platform IDs. +/// - You may not update or create a payment intent for an off-session payment flow without +/// attaching a charge. +pub async fn create_or_update_payment_intent( + pg: &PgPool, + redis: &RedisPool, + stripe_client: &stripe::Client, + anrok_client: &anrok::Client, + PaymentBootstrapOptions { + user, + payment_intent: existing_payment_intent, + payment_session, + attached_charge, + currency: currency_mode, + attach_payment_metadata, + }: PaymentBootstrapOptions<'_>, +) -> Result { + let customer_id = get_or_create_customer( + user.id, + user.stripe_customer_id.as_deref(), + user.email.as_deref(), + stripe_client, + pg, + redis, + ) + .await?; + + let payment_method = match &payment_session { + PaymentSession::Interactive { + payment_request_type: PaymentRequestType::PaymentMethod { id }, + } => { + let payment_method_id = + PaymentMethodId::from_str(id).map_err(|_| { + ApiError::InvalidInput( + "Invalid payment method id".to_string(), + ) + })?; + + PaymentMethod::retrieve(stripe_client, &payment_method_id, &[]) + .await? + } + PaymentSession::Interactive { + payment_request_type: + PaymentRequestType::ConfirmationToken { token }, + } => { + #[derive(Deserialize)] + struct ConfirmationToken { + payment_method_preview: Option, + } + + let mut confirmation: serde_json::Value = stripe_client + .get(&format!("confirmation_tokens/{token}")) + .await?; + + // We patch the JSONs to support the PaymentMethod struct + let p: json_patch::Patch = serde_json::from_value(serde_json::json!([ + { "op": "add", "path": "/payment_method_preview/id", "value": "pm_1PirTdJygY5LJFfKmPIaM1N1" }, + { "op": "add", "path": "/payment_method_preview/created", "value": 1723183475 }, + { "op": "add", "path": "/payment_method_preview/livemode", "value": false } + ])).unwrap(); + json_patch::patch(&mut confirmation, &p).unwrap(); + + let confirmation: ConfirmationToken = + serde_json::from_value(confirmation)?; + + confirmation.payment_method_preview.ok_or_else(|| { + ApiError::InvalidInput( + "Confirmation token is missing payment method!".to_string(), + ) + })? + } + PaymentSession::AutomatedRenewal => { + if attached_charge.as_charge().is_none() { + return Err(ApiError::InvalidInput( + "Missing attached charge for automated renewal".to_string(), + )); + } + + let customer = stripe::Customer::retrieve( + stripe_client, + &customer_id, + &["invoice_settings.default_payment_method"], + ) + .await?; + + customer + .invoice_settings + .and_then(|x| { + x.default_payment_method.and_then(|x| x.into_object()) + }) + .ok_or_else(|| { + ApiError::InvalidInput( + "Customer has no default payment method!".to_string(), + ) + })? + } + }; + + let user_country = payment_method + .billing_details + .address + .as_ref() + .and_then(|x| x.country.as_deref()) + .unwrap_or(DEFAULT_USER_COUNTRY); + + let inferred_stripe_currency = match currency_mode { + CurrencyMode::Set(currency) => currency, + CurrencyMode::Infer => infer_currency_code(user_country) + .to_lowercase() + .parse::() + .map_err(|_| { + ApiError::InvalidInput("Invalid currency code".to_string()) + })?, + }; + + let charge_data = match attached_charge { + AttachedCharge::UseExisting { ref charge } => ChargeData { + amount: charge.amount, + currency_code: charge.currency_code.clone(), + interval: charge.subscription_interval, + price_id: charge.price_id.into(), + charge_type: charge.type_, + }, + AttachedCharge::Proration { + amount, + next_product_id, + next_interval, + current_subscription: _, + } => { + // Use the same data as we would use when basing the charge data on + // a product/interval pair, except override the amount and charge type + // to the proration values. + // + // Then, the tax will be based on the next product, and the metadata + // will be inserted as is desired for proration charges, except + // the actual payment intent amount will be the proration amount. + + let mut charge_data = derive_charge_data_from_product_selector( + pg, + user.id, + next_product_id, + Some(next_interval), + inferred_stripe_currency, + ) + .await?; + + charge_data.amount = amount; + charge_data.charge_type = ChargeType::Proration; + charge_data + } + AttachedCharge::Promotion { + product_id, + interval, + current_subscription: _, + new_region: _, + } => { + derive_charge_data_from_product_selector( + pg, + user.id, + product_id, + Some(interval), + inferred_stripe_currency, + ) + .await? + } + AttachedCharge::BaseUpon { + product_id, + interval, + } => { + derive_charge_data_from_product_selector( + pg, + user.id, + product_id, + interval, + inferred_stripe_currency, + ) + .await? + } + }; + + // Create an ephemeral transaction to calculate the tax amount if needed + + let tax_amount = 'tax: { + // If a charge is attached, we must use the tax amount noted on the charge + // as the tax amount. + // + // Note: if we supported interactive payments of existing charges, we may + // want to update the charge's tax amount immediately here. + if let Some(c) = attached_charge.as_charge() { + break 'tax c.tax_amount; + } + + let product_info = + products_tax_identifier_item::product_info_by_product_price_id( + charge_data.price_id.into(), + pg, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "Missing product tax identifier for charge to continue" + .to_owned(), + ) + })?; + + let address = + payment_method.billing_details.address.clone().ok_or_else( + || { + ApiError::InvalidInput( + "Missing billing details from payment method to continue" + .to_owned(), + ) + }, + )?; + + let ephemeral_invoice = anrok_client + .create_ephemeral_txn(&anrok::TransactionFields { + customer_address: anrok::Address::from_stripe_address(&address), + currency_code: charge_data.currency_code.clone(), + accounting_time: chrono::Utc::now(), + accounting_time_zone: anrok::AccountingTimeZone::Utc, + line_items: vec![anrok::LineItem::new( + product_info.tax_identifier.tax_processor_id, + charge_data.amount, + )], + }) + .await?; + + ephemeral_invoice.tax_amount_to_collect + }; + + let mut metadata = HashMap::new(); + + metadata.insert(MODRINTH_USER_ID.to_owned(), to_base62(user.id.0)); + metadata.insert( + MODRINTH_CHARGE_TYPE.to_owned(), + charge_data.charge_type.as_str().to_owned(), + ); + metadata.insert(MODRINTH_TAX_AMOUNT.to_owned(), tax_amount.to_string()); + + if let Some(payment_metadata) = attach_payment_metadata { + metadata.insert( + MODRINTH_PAYMENT_METADATA.to_owned(), + serde_json::to_string(&payment_metadata)?, + ); + } + + if let AttachedCharge::UseExisting { charge } = attached_charge { + metadata.insert( + MODRINTH_CHARGE_ID.to_owned(), + to_base62(charge.id.0 as u64), + ); + + // These are only used to post-create the charge in the stripe webhook, so + // unset them. + metadata.insert(MODRINTH_PRICE_ID.to_owned(), String::new()); + metadata + .insert(MODRINTH_SUBSCRIPTION_INTERVAL.to_owned(), String::new()); + metadata.insert(MODRINTH_SUBSCRIPTION_ID.to_owned(), String::new()); + } else if let AttachedCharge::Proration { + amount: _, + next_product_id: _, + next_interval, + current_subscription, + } = attached_charge + { + let mut transaction = pg.begin().await?; + let charge_id = generate_charge_id(&mut transaction).await?; + + metadata.insert( + MODRINTH_CHARGE_ID.to_owned(), + to_base62(charge_id.0 as u64), + ); + + metadata.insert( + MODRINTH_PRICE_ID.to_owned(), + charge_data.price_id.to_string(), + ); + metadata.insert( + MODRINTH_SUBSCRIPTION_INTERVAL.to_owned(), + next_interval.as_str().to_owned(), + ); + metadata.insert( + MODRINTH_SUBSCRIPTION_ID.to_owned(), + current_subscription.to_string(), + ); + } else if let AttachedCharge::Promotion { + product_id: _, + interval, + current_subscription, + new_region, + } = attached_charge + { + let mut transaction = pg.begin().await?; + let charge_id = generate_charge_id(&mut transaction).await?; + + metadata.insert( + MODRINTH_CHARGE_ID.to_owned(), + to_base62(charge_id.0 as u64), + ); + + metadata.insert( + MODRINTH_PRICE_ID.to_owned(), + charge_data.price_id.to_string(), + ); + metadata.insert( + MODRINTH_SUBSCRIPTION_INTERVAL.to_owned(), + interval.as_str().to_owned(), + ); + metadata.insert( + MODRINTH_SUBSCRIPTION_ID.to_owned(), + current_subscription.to_string(), + ); + metadata.insert(MODRINTH_NEW_REGION.to_owned(), new_region); + } else { + let mut transaction = pg.begin().await?; + let charge_id = generate_charge_id(&mut transaction).await?; + let subscription_id = + generate_user_subscription_id(&mut transaction).await?; + + metadata.insert( + MODRINTH_CHARGE_ID.to_owned(), + to_base62(charge_id.0 as u64), + ); + metadata.insert( + MODRINTH_SUBSCRIPTION_ID.to_owned(), + to_base62(subscription_id.0 as u64), + ); + + metadata.insert( + MODRINTH_PRICE_ID.to_owned(), + charge_data.price_id.to_string(), + ); + + if let Some(interval) = charge_data.interval { + metadata.insert( + MODRINTH_SUBSCRIPTION_INTERVAL.to_owned(), + interval.as_str().to_owned(), + ); + } + } + + if let Some(payment_intent_id) = existing_payment_intent { + let update_payment_intent = stripe::UpdatePaymentIntent { + amount: Some(charge_data.amount + tax_amount), + currency: Some(inferred_stripe_currency), + customer: Some(customer_id), + metadata: Some(metadata), + payment_method: Some(payment_method.id.clone()), + ..Default::default() + }; + + stripe::PaymentIntent::update( + stripe_client, + &payment_intent_id, + update_payment_intent, + ) + .await?; + + Ok(PaymentBootstrapResults { + new_payment_intent: None, + payment_method, + price_id: charge_data.price_id, + subtotal: charge_data.amount, + tax: tax_amount, + }) + } else { + let mut intent = CreatePaymentIntent::new( + charge_data.amount + tax_amount, + inferred_stripe_currency, + ); + + intent.customer = Some(customer_id); + intent.metadata = Some(metadata); + intent.receipt_email = user.email.as_deref(); + intent.payment_method = Some(payment_method.id.clone()); + + payment_session.set_payment_intent_session_options(&mut intent); + + let payment_intent = + stripe::PaymentIntent::create(stripe_client, intent).await?; + + Ok(PaymentBootstrapResults { + new_payment_intent: Some(payment_intent), + payment_method, + price_id: charge_data.price_id, + subtotal: charge_data.amount, + tax: tax_amount, + }) + } +} + +pub async fn get_or_create_customer( + user_id: ariadne::ids::UserId, + stripe_customer_id: Option<&str>, + user_email: Option<&str>, + client: &stripe::Client, + pool: &PgPool, + redis: &RedisPool, +) -> Result { + if let Some(customer_id) = + stripe_customer_id.and_then(|x| stripe::CustomerId::from_str(x).ok()) + { + Ok(customer_id) + } else { + let mut metadata = HashMap::new(); + metadata.insert(MODRINTH_USER_ID.to_owned(), to_base62(user_id.0)); + + let customer = stripe::Customer::create( + client, + CreateCustomer { + email: user_email, + metadata: Some(metadata), + ..Default::default() + }, + ) + .await?; + + sqlx::query!( + " + UPDATE users + SET stripe_customer_id = $1 + WHERE id = $2 + ", + customer.id.as_str(), + user_id.0 as i64 + ) + .execute(pool) + .await?; + + crate::database::models::user_item::DBUser::clear_caches( + &[(user_id.into(), None)], + redis, + ) + .await?; + + Ok(customer.id) + } +} + +pub fn infer_currency_code(country: &str) -> String { + match country { + "US" => "USD", + "GB" => "GBP", + "EU" => "EUR", + "AT" => "EUR", + "BE" => "EUR", + "CY" => "EUR", + "EE" => "EUR", + "ES" => "EUR", + "FI" => "EUR", + "FR" => "EUR", + "DE" => "EUR", + "GR" => "EUR", + "IE" => "EUR", + "IT" => "EUR", + "LV" => "EUR", + "LT" => "EUR", + "LU" => "EUR", + "MT" => "EUR", + "NL" => "EUR", + "PT" => "EUR", + "SK" => "EUR", + "SI" => "EUR", + "RU" => "RUB", + "BR" => "BRL", + "JP" => "JPY", + "ID" => "IDR", + "MY" => "MYR", + "PH" => "PHP", + "TH" => "THB", + "VN" => "VND", + "KR" => "KRW", + "TR" => "TRY", + "UA" => "UAH", + "MX" => "MXN", + "CA" => "CAD", + "NZ" => "NZD", + "NO" => "NOK", + "PL" => "PLN", + "CH" => "CHF", + "LI" => "CHF", + "IN" => "INR", + "CL" => "CLP", + "PE" => "PEN", + "CO" => "COP", + "ZA" => "ZAR", + "HK" => "HKD", + "AR" => "ARS", + "KZ" => "KZT", + "UY" => "UYU", + "CN" => "CNY", + "AU" => "AUD", + "TW" => "TWD", + "SA" => "SAR", + "QA" => "QAR", + "SG" => "SGD", + _ => "USD", + } + .to_string() +} + +struct ChargeData { + pub amount: i64, + pub currency_code: String, + pub interval: Option, + pub price_id: ProductPriceId, + pub charge_type: ChargeType, +} + +async fn derive_charge_data_from_product_selector( + pool: &PgPool, + user_id: UserId, + product_id: ProductId, + interval: Option, + stripe_currency: Currency, +) -> Result { + let recommended_currency_code = stripe_currency.to_string().to_uppercase(); + + let product = product_item::DBProduct::get(product_id.into(), pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "Specified product could not be found!".to_string(), + ) + })?; + + let mut product_prices = + product_item::DBProductPrice::get_all_public_product_prices( + product.id, pool, + ) + .await?; + + let price_item = if let Some(pos) = product_prices + .iter() + .position(|x| x.currency_code == recommended_currency_code) + { + product_prices.remove(pos) + } else if let Some(pos) = + product_prices.iter().position(|x| x.currency_code == "USD") + { + product_prices.remove(pos) + } else { + return Err(ApiError::InvalidInput( + "Could not find a valid price for the user's country".to_string(), + )); + }; + + let price = match price_item.prices { + Price::OneTime { price } => price, + Price::Recurring { ref intervals } => { + let interval = interval.ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's country" + .to_string(), + ) + })?; + + *intervals.get(&interval).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's country" + .to_string(), + ) + })? + } + }; + + if let Price::Recurring { .. } = price_item.prices + && product.unitary + { + let user_subscriptions = + user_subscription_item::DBUserSubscription::get_all_user( + user_id.into(), + pool, + ) + .await?; + + let user_products = product_item::DBProductPrice::get_many( + &user_subscriptions + .iter() + .filter(|x| x.status == SubscriptionStatus::Provisioned) + .map(|x| x.price_id) + .collect::>(), + pool, + ) + .await?; + + if user_products + .into_iter() + .any(|x| x.product_id == product.id) + { + return Err(ApiError::InvalidInput( + "You are already subscribed to this product!".to_string(), + )); + } + } + + Ok(ChargeData { + amount: price as i64, + currency_code: price_item.currency_code.clone(), + interval, + price_id: price_item.id.into(), + charge_type: if let Price::Recurring { .. } = price_item.prices { + ChargeType::Subscription + } else { + ChargeType::OneTime + }, + }) +} diff --git a/apps/labrinth/src/routes/internal/medal.rs b/apps/labrinth/src/routes/internal/medal.rs index 4bc29e399..51018f2fd 100644 --- a/apps/labrinth/src/routes/internal/medal.rs +++ b/apps/labrinth/src/routes/internal/medal.rs @@ -9,8 +9,8 @@ use crate::database::models::users_redeemals::{ Offer, RedeemalLookupFields, Status, UserRedeemal, }; use crate::database::redis::RedisPool; +use crate::queue::billing::try_process_user_redeemal; use crate::routes::ApiError; -use crate::routes::internal::billing::try_process_user_redeemal; use crate::util::guards::medal_key_guard; pub fn config(cfg: &mut web::ServiceConfig) { diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 2701c2a68..00730373f 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -8,7 +8,6 @@ pub mod medal; pub mod moderation; pub mod pats; pub mod session; - pub mod statuses; pub use super::ApiError; diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 3dee8f225..38292695f 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -143,6 +143,8 @@ pub enum ApiError { Conflict(String), #[error("External tax compliance API Error")] TaxComplianceApi, + #[error(transparent)] + TaxProcessor(#[from] crate::util::anrok::AnrokError), #[error( "You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining." )] @@ -184,6 +186,7 @@ impl ApiError { ApiError::Io(..) => "io_error", ApiError::RateLimitError(..) => "ratelimit_error", ApiError::Stripe(..) => "stripe_error", + ApiError::TaxProcessor(..) => "tax_processor_error", ApiError::Slack(..) => "slack_error", }, description: self.to_string(), @@ -223,6 +226,7 @@ impl actix_web::ResponseError for ApiError { ApiError::Io(..) => StatusCode::BAD_REQUEST, ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS, ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY, + ApiError::TaxProcessor(..) => StatusCode::INTERNAL_SERVER_ERROR, ApiError::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR, } } diff --git a/apps/labrinth/src/util/anrok.rs b/apps/labrinth/src/util/anrok.rs new file mode 100644 index 000000000..38d07e6b2 --- /dev/null +++ b/apps/labrinth/src/util/anrok.rs @@ -0,0 +1,293 @@ +use chrono::{DateTime, Utc}; +use rand::Rng; +use reqwest::{Method, StatusCode}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_with::{DisplayFromStr, serde_as}; +use thiserror::Error; +use tracing::trace; + +pub fn transaction_id_stripe_pi(pi: &stripe::PaymentIntentId) -> String { + format!("stripe:charge:{pi}") +} + +pub fn transaction_id_stripe_pyr(charge: &stripe::RefundId) -> String { + format!("stripe:refund:{charge}") +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct InvoiceResponse { + pub tax_amount_to_collect: i64, + pub version: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct EmptyResponse {} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Address { + #[serde(skip_serializing_if = "Option::is_none", default)] + pub country: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub line1: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub city: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub region: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub postal_code: Option, +} + +impl Address { + pub fn from_stripe_address(address: &stripe::Address) -> Self { + Self { + country: address.country.clone(), + line1: address.line1.clone(), + city: address.city.clone(), + region: address.state.clone(), + postal_code: address.postal_code.clone(), + } + } +} + +#[serde_as] +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct LineItem { + #[serde(skip_serializing_if = "Option::is_none", default)] + pub id: Option, + pub product_external_id: String, + #[serde_as(as = "DisplayFromStr")] + pub quantity: u32, + #[serde(rename = "amount")] + pub amount_in_smallest_denominations: i64, + pub is_tax_included_in_amount: bool, +} + +impl LineItem { + pub const fn new( + product_external_id: String, + amount_in_smallest_denominations: i64, + ) -> Self { + Self { + id: None, + product_external_id, + quantity: 1, + amount_in_smallest_denominations, + is_tax_included_in_amount: false, + } + } + + pub const fn new_including_tax_amount( + product_external_id: String, + amount_in_smallest_denominations: i64, + ) -> Self { + Self { + id: None, + product_external_id, + quantity: 1, + amount_in_smallest_denominations, + is_tax_included_in_amount: true, + } + } +} + +#[derive(Serialize, Deserialize, Default, Clone, Debug, Eq, PartialEq)] +pub enum AccountingTimeZone { + #[default] + #[serde(rename = "UTC")] + Utc, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct TransactionFields { + pub customer_address: Address, + pub currency_code: String, + pub accounting_time: DateTime, + pub accounting_time_zone: AccountingTimeZone, + pub line_items: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Transaction { + #[serde(flatten)] + pub fields: TransactionFields, + pub id: String, +} + +#[derive(Error, Debug)] +pub enum AnrokError { + #[error("Anrok API Error: {0}")] + Conflict(String), + #[error("Anrok API Error: Bad request: {0}")] + BadRequest(String), + #[error("Rate limit exceeded using Anrok API")] + RateLimit, + #[error("Anrok API error: {0}")] + Other(#[from] reqwest::Error), +} + +#[derive(Clone)] +pub struct Client { + client: reqwest::Client, + api_key: String, + api_url: String, +} + +impl Client { + pub fn from_env() -> Result { + let api_key = dotenvy::var("ANROK_API_KEY")?; + let api_url = dotenvy::var("ANROK_API_URL")? + .trim_start_matches('/') + .to_owned(); + + Ok(Self { + client: reqwest::Client::builder() + .user_agent("Modrinth") + .build() + .expect("AnrokClient to build"), + api_key, + api_url, + }) + } + + pub async fn create_ephemeral_txn( + &self, + body: &TransactionFields, + ) -> Result { + self.make_request( + Method::POST, + "/v1/seller/transactions/createEphemeral", + Some(body), + ) + .await + } + + pub async fn create_or_update_txn( + &self, + body: &Transaction, + ) -> Result { + self.make_request( + Method::POST, + "/v1/seller/transactions/createOrUpdate", + Some(body), + ) + .await + } + + pub async fn void_txn( + &self, + id: String, + version: i32, + ) -> Result { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Body { + transaction_expected_version: i32, + } + + self.make_request( + Method::POST, + &format!("/v1/seller/transactions/id:{id}/void"), + Some(&Body { + transaction_expected_version: version, + }), + ) + .await + } + + async fn make_request( + &self, + method: Method, + path: &str, + body: Option<&T>, + ) -> Result { + let mut n = 0u64; + + loop { + if n >= 3 { + return Err(AnrokError::RateLimit); + } + + match self.make_request_inner(method.clone(), path, body).await { + Err(AnrokError::RateLimit) => { + n += 1; + // 1000 + ~500, 2000 + ~1000, 5000 + ~2500 + let base = (n - 1).pow(2) * 1000 + 1000; + let random = rand::thread_rng().gen_range(0..(base / 2)); + tokio::time::sleep(std::time::Duration::from_millis( + base + random, + )) + .await; + } + + other => return other, + } + } + } + + async fn make_request_inner( + &self, + method: Method, + path: &str, + body: Option<&T>, + ) -> Result { + let then = std::time::Instant::now(); + + #[derive(Deserialize)] + struct ConflictResponse { + #[serde(rename = "type")] + type_: String, + } + + let mut builder = self + .client + .request(method.clone(), format!("{}/{}", self.api_url, path)) + .bearer_auth(&self.api_key); + + if let Some(body) = body { + builder = builder.json(&body); + } + + let response = builder.send().await?; + + trace!( + http.status = %response.status().as_u16(), + http.method = %method, + http.path = %path, + duration = format!("{}ms", then.elapsed().as_millis()), + "Received Anrok response", + ); + + match response.status() { + StatusCode::CONFLICT => { + return Err(AnrokError::Conflict( + response.json::().await?.type_, + )); + } + + StatusCode::BAD_REQUEST => { + return Err(AnrokError::BadRequest( + response.json::().await.unwrap_or_default(), + )); + } + + StatusCode::TOO_MANY_REQUESTS => return Err(AnrokError::RateLimit), + + s if !s.is_success() => { + if let Err(error) = response.error_for_status_ref() { + return Err(AnrokError::Other(error)); + } + } + + _ => {} + } + + let body = response.json::().await?; + Ok(body) + } +} diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs index 9ac089cd8..8a5156a6a 100644 --- a/apps/labrinth/src/util/mod.rs +++ b/apps/labrinth/src/util/mod.rs @@ -1,4 +1,5 @@ pub mod actix; +pub mod anrok; pub mod archon; pub mod avalara1099; pub mod bitflag; diff --git a/apps/labrinth/tests/common/mod.rs b/apps/labrinth/tests/common/mod.rs index 1efc99bf5..1056ab0d5 100644 --- a/apps/labrinth/tests/common/mod.rs +++ b/apps/labrinth/tests/common/mod.rs @@ -1,4 +1,5 @@ use labrinth::queue::email::EmailQueue; +use labrinth::util::anrok; use labrinth::{LabrinthConfig, file_hosting, queue}; use labrinth::{check_env_vars, clickhouse}; use std::sync::Arc; @@ -40,6 +41,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig { let stripe_client = stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap()); + let anrok_client = anrok::Client::from_env().unwrap(); let email_queue = EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap(); @@ -52,6 +54,7 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig { file_host.clone(), maxmind_reader, stripe_client, + anrok_client, email_queue, false, )