diff --git a/.env b/.env index 1b0cc280..21aa0d99 100644 --- a/.env +++ b/.env @@ -18,7 +18,7 @@ REDIS_URL=redis://localhost REDIS_MAX_CONNECTIONS=10000 BIND_ADDR=127.0.0.1:8000 -SELF_ADDR=http://localhost:8000 +SELF_ADDR=http://127.0.0.1:8000 MODERATION_DISCORD_WEBHOOK= PUBLIC_DISCORD_WEBHOOK= @@ -49,10 +49,6 @@ WHITELISTED_MODPACK_DOMAINS='["cdn.modrinth.com", "github.com", "raw.githubuserc ALLOWED_CALLBACK_URLS='["localhost", ".modrinth.com", "127.0.0.1"]' -TROLLEY_ACCESS_KEY=none -TROLLEY_SECRET_KEY=none -TROLLEY_WEBHOOK_SIGNATURE=none - GITHUB_CLIENT_ID=none GITHUB_CLIENT_SECRET=none @@ -68,8 +64,18 @@ MICROSOFT_CLIENT_SECRET=none GOOGLE_CLIENT_ID=none GOOGLE_CLIENT_SECRET=none +PAYPAL_API_URL=https://api-m.sandbox.paypal.com/v1/ +PAYPAL_WEBHOOK_ID=none +PAYPAL_CLIENT_ID=none +PAYPAL_CLIENT_SECRET=none + STEAM_API_KEY=none +TREMENDOUS_API_URL=https://testflight.tremendous.com/api/v2/ +TREMENDOUS_API_KEY=none +TREMENDOUS_PRIVATE_KEY=none +TREMENDOUS_CAMPAIGN_ID=none + TURNSTILE_SECRET=none SMTP_USERNAME=none diff --git a/.sqlx/query-06bfc01bafa2db73eb049f268eb9f333aeeb23a048ee524b278fe184e2d3ae45.json b/.sqlx/query-06bfc01bafa2db73eb049f268eb9f333aeeb23a048ee524b278fe184e2d3ae45.json new file mode 100644 index 00000000..fac94876 --- /dev/null +++ b/.sqlx/query-06bfc01bafa2db73eb049f268eb9f333aeeb23a048ee524b278fe184e2d3ae45.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET paypal_country = NULL, paypal_email = NULL, paypal_id = NULL\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "06bfc01bafa2db73eb049f268eb9f333aeeb23a048ee524b278fe184e2d3ae45" +} diff --git a/.sqlx/query-0ba5a9f4d1381ed37a67b7dc90edf7e3ec86cae6c2860e5db1e53144d4654e58.json b/.sqlx/query-0ba5a9f4d1381ed37a67b7dc90edf7e3ec86cae6c2860e5db1e53144d4654e58.json deleted file mode 100644 index 758be954..00000000 --- a/.sqlx/query-0ba5a9f4d1381ed37a67b7dc90edf7e3ec86cae6c2860e5db1e53144d4654e58.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT SUM(pv.amount) amount\n FROM payouts_values pv\n WHERE pv.user_id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "amount", - "type_info": "Numeric" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - null - ] - }, - "hash": "0ba5a9f4d1381ed37a67b7dc90edf7e3ec86cae6c2860e5db1e53144d4654e58" -} diff --git a/.sqlx/query-0d23c47e3f6803078016c4ae5d52c47b7a0d23ca747c753a5710b3eb3cf7c621.json b/.sqlx/query-0d23c47e3f6803078016c4ae5d52c47b7a0d23ca747c753a5710b3eb3cf7c621.json new file mode 100644 index 00000000..1b911274 --- /dev/null +++ b/.sqlx/query-0d23c47e3f6803078016c4ae5d52c47b7a0d23ca747c753a5710b3eb3cf7c621.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET paypal_country = $1, paypal_email = $2, paypal_id = $3\n WHERE (id = $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "0d23c47e3f6803078016c4ae5d52c47b7a0d23ca747c753a5710b3eb3cf7c621" +} diff --git a/.sqlx/query-105c44658c58739b933ae3ef0504c66b7926390c9f30e09c635161544177762a.json b/.sqlx/query-105c44658c58739b933ae3ef0504c66b7926390c9f30e09c635161544177762a.json new file mode 100644 index 00000000..807d8545 --- /dev/null +++ b/.sqlx/query-105c44658c58739b933ae3ef0504c66b7926390c9f30e09c635161544177762a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE payouts\n SET status = $1\n WHERE platform_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "105c44658c58739b933ae3ef0504c66b7926390c9f30e09c635161544177762a" +} diff --git a/.sqlx/query-285c089b43bf0225ba03e279f7a227c3483bae818d077efdc54e588b858c8760.json b/.sqlx/query-285c089b43bf0225ba03e279f7a227c3483bae818d077efdc54e588b858c8760.json new file mode 100644 index 00000000..0b54267b --- /dev/null +++ b/.sqlx/query-285c089b43bf0225ba03e279f7a227c3483bae818d077efdc54e588b858c8760.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO payouts (\n id, amount, fee, user_id, status, method, method_address, platform_id\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Numeric", + "Numeric", + "Int8", + "Varchar", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "285c089b43bf0225ba03e279f7a227c3483bae818d077efdc54e588b858c8760" +} diff --git a/.sqlx/query-2e5ddc7876d8041fec781893027f84b49b5794c85fa442296c35156d0a72464a.json b/.sqlx/query-36c8a2fe704197539ee5010e94a03a48637ac9227d683e0c75eb2603ba156610.json similarity index 60% rename from .sqlx/query-2e5ddc7876d8041fec781893027f84b49b5794c85fa442296c35156d0a72464a.json rename to .sqlx/query-36c8a2fe704197539ee5010e94a03a48637ac9227d683e0c75eb2603ba156610.json index ed566ac7..bb894c59 100644 --- a/.sqlx/query-2e5ddc7876d8041fec781893027f84b49b5794c85fa442296c35156d0a72464a.json +++ b/.sqlx/query-36c8a2fe704197539ee5010e94a03a48637ac9227d683e0c75eb2603ba156610.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO users (\n id, username, name, email,\n avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15\n )\n ", + "query": "\n INSERT INTO users (\n id, username, name, email,\n avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19\n )\n ", "describe": { "columns": [], "parameters": { @@ -19,10 +19,14 @@ "Int8", "Varchar", "Bool", + "Text", + "Text", + "Text", + "Text", "Text" ] }, "nullable": [] }, - "hash": "2e5ddc7876d8041fec781893027f84b49b5794c85fa442296c35156d0a72464a" + "hash": "36c8a2fe704197539ee5010e94a03a48637ac9227d683e0c75eb2603ba156610" } diff --git a/.sqlx/query-382753714620109f2ad1a4cacbb6f699732db321a2dcb1f9d83e57332e32357d.json b/.sqlx/query-382753714620109f2ad1a4cacbb6f699732db321a2dcb1f9d83e57332e32357d.json deleted file mode 100644 index c4c12218..00000000 --- a/.sqlx/query-382753714620109f2ad1a4cacbb6f699732db321a2dcb1f9d83e57332e32357d.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE users\n SET trolley_account_status = NULL, trolley_id = NULL\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "382753714620109f2ad1a4cacbb6f699732db321a2dcb1f9d83e57332e32357d" -} diff --git a/.sqlx/query-3db83286f5aea07f399db451bfd6d90c9bb2bd94b6d8accb3d2e0906bb289798.json b/.sqlx/query-3db83286f5aea07f399db451bfd6d90c9bb2bd94b6d8accb3d2e0906bb289798.json new file mode 100644 index 00000000..795800ff --- /dev/null +++ b/.sqlx/query-3db83286f5aea07f399db451bfd6d90c9bb2bd94b6d8accb3d2e0906bb289798.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET balance = balance - $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Numeric", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "3db83286f5aea07f399db451bfd6d90c9bb2bd94b6d8accb3d2e0906bb289798" +} diff --git a/.sqlx/query-3f525e05e94ccaea4abc059d54f48011517bd8997df0c7d42cc4caae62194ae6.json b/.sqlx/query-3f525e05e94ccaea4abc059d54f48011517bd8997df0c7d42cc4caae62194ae6.json deleted file mode 100644 index 7177f721..00000000 --- a/.sqlx/query-3f525e05e94ccaea4abc059d54f48011517bd8997df0c7d42cc4caae62194ae6.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE users\n SET trolley_id = $1, trolley_account_status = $2\n WHERE id = $3\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "3f525e05e94ccaea4abc059d54f48011517bd8997df0c7d42cc4caae62194ae6" -} diff --git a/.sqlx/query-48dc011567c5d50ee734fd0bdd1f5d07d9ef066c485a9b34495120c9947489f8.json b/.sqlx/query-48dc011567c5d50ee734fd0bdd1f5d07d9ef066c485a9b34495120c9947489f8.json deleted file mode 100644 index 9192d237..00000000 --- a/.sqlx/query-48dc011567c5d50ee734fd0bdd1f5d07d9ef066c485a9b34495120c9947489f8.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO historical_payouts (user_id, amount, status, batch_id, payment_id)\n VALUES ($1, $2, $3, $4, $5)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Numeric", - "Varchar", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "48dc011567c5d50ee734fd0bdd1f5d07d9ef066c485a9b34495120c9947489f8" -} diff --git a/.sqlx/query-4de9fdac1831320d744cb6edd0cfe6e1fd670348f0ef881f67d7aec1acd6571f.json b/.sqlx/query-4de9fdac1831320d744cb6edd0cfe6e1fd670348f0ef881f67d7aec1acd6571f.json new file mode 100644 index 00000000..6baa687a --- /dev/null +++ b/.sqlx/query-4de9fdac1831320d744cb6edd0cfe6e1fd670348f0ef881f67d7aec1acd6571f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM payouts\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "4de9fdac1831320d744cb6edd0cfe6e1fd670348f0ef881f67d7aec1acd6571f" +} diff --git a/.sqlx/query-5722c001ba705d5d6237037512081b0a006aca2fc6f1db71e5b47ec9d68a82b9.json b/.sqlx/query-5722c001ba705d5d6237037512081b0a006aca2fc6f1db71e5b47ec9d68a82b9.json new file mode 100644 index 00000000..685fa94f --- /dev/null +++ b/.sqlx/query-5722c001ba705d5d6237037512081b0a006aca2fc6f1db71e5b47ec9d68a82b9.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET paypal_id = $2\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "5722c001ba705d5d6237037512081b0a006aca2fc6f1db71e5b47ec9d68a82b9" +} diff --git a/.sqlx/query-faec0a606ccaeb3f21c81e60a1749640b929e97db40252118fb72610df64a457.json b/.sqlx/query-5e7e85c8c1f4b4e600c51669b6591b5cc279bd7482893ec687e83ee22d00a3a0.json similarity index 83% rename from .sqlx/query-faec0a606ccaeb3f21c81e60a1749640b929e97db40252118fb72610df64a457.json rename to .sqlx/query-5e7e85c8c1f4b4e600c51669b6591b5cc279bd7482893ec687e83ee22d00a3a0.json index 6298af11..2932ef87 100644 --- a/.sqlx/query-faec0a606ccaeb3f21c81e60a1749640b929e97db40252118fb72610df64a457.json +++ b/.sqlx/query-5e7e85c8c1f4b4e600c51669b6591b5cc279bd7482893ec687e83ee22d00a3a0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, name, email,\n avatar_url, username, bio,\n created, role, badges,\n balance,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, trolley_id, trolley_account_status\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", + "query": "\n SELECT id, name, email,\n avatar_url, username, bio,\n created, role, badges,\n balance,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", "describe": { "columns": [ { @@ -100,12 +100,22 @@ }, { "ordinal": 19, - "name": "trolley_id", + "name": "paypal_id", "type_info": "Text" }, { "ordinal": 20, - "name": "trolley_account_status", + "name": "paypal_country", + "type_info": "Text" + }, + { + "ordinal": 21, + "name": "paypal_email", + "type_info": "Text" + }, + { + "ordinal": 22, + "name": "venmo_handle", "type_info": "Text" } ], @@ -136,8 +146,10 @@ true, true, true, + true, + true, true ] }, - "hash": "faec0a606ccaeb3f21c81e60a1749640b929e97db40252118fb72610df64a457" + "hash": "5e7e85c8c1f4b4e600c51669b6591b5cc279bd7482893ec687e83ee22d00a3a0" } diff --git a/.sqlx/query-6d10ec782e422e868681827a6eb999edc6bf4fe8fa2b94d1f8970db2578c6db4.json b/.sqlx/query-6d10ec782e422e868681827a6eb999edc6bf4fe8fa2b94d1f8970db2578c6db4.json deleted file mode 100644 index 3a83bab7..00000000 --- a/.sqlx/query-6d10ec782e422e868681827a6eb999edc6bf4fe8fa2b94d1f8970db2578c6db4.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT hp.created, hp.amount, hp.status\n FROM historical_payouts hp\n WHERE hp.user_id = $1\n ORDER BY hp.created DESC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "created", - "type_info": "Timestamptz" - }, - { - "ordinal": 1, - "name": "amount", - "type_info": "Numeric" - }, - { - "ordinal": 2, - "name": "status", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "6d10ec782e422e868681827a6eb999edc6bf4fe8fa2b94d1f8970db2578c6db4" -} diff --git a/.sqlx/query-7c0cdacf0898155c94008a96a0b918550df4475b9e3362a926d4d00e001880c1.json b/.sqlx/query-7c0cdacf0898155c94008a96a0b918550df4475b9e3362a926d4d00e001880c1.json deleted file mode 100644 index 0f23e0ea..00000000 --- a/.sqlx/query-7c0cdacf0898155c94008a96a0b918550df4475b9e3362a926d4d00e001880c1.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT SUM(pv.amount) amount\n FROM payouts_values pv\n WHERE pv.user_id = $1 AND created > NOW() - '1 month'::interval\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "amount", - "type_info": "Numeric" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - null - ] - }, - "hash": "7c0cdacf0898155c94008a96a0b918550df4475b9e3362a926d4d00e001880c1" -} diff --git a/.sqlx/query-83f8d3fcc4ba1544f593abaf29c79157bc35e3fca79cc93f6512ca01acd8e5ce.json b/.sqlx/query-83f8d3fcc4ba1544f593abaf29c79157bc35e3fca79cc93f6512ca01acd8e5ce.json new file mode 100644 index 00000000..ac2af681 --- /dev/null +++ b/.sqlx/query-83f8d3fcc4ba1544f593abaf29c79157bc35e3fca79cc93f6512ca01acd8e5ce.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, created, amount, status, method, method_address, platform_id, fee\n FROM payouts\n WHERE id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "amount", + "type_info": "Numeric" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "method", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "method_address", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "platform_id", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "fee", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "83f8d3fcc4ba1544f593abaf29c79157bc35e3fca79cc93f6512ca01acd8e5ce" +} diff --git a/.sqlx/query-8422dcab178b4121d438a8fe4e365f527467c09d40a470a6c2cbdab71b04be4e.json b/.sqlx/query-8422dcab178b4121d438a8fe4e365f527467c09d40a470a6c2cbdab71b04be4e.json deleted file mode 100644 index 16644942..00000000 --- a/.sqlx/query-8422dcab178b4121d438a8fe4e365f527467c09d40a470a6c2cbdab71b04be4e.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM historical_payouts\n WHERE user_id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "8422dcab178b4121d438a8fe4e365f527467c09d40a470a6c2cbdab71b04be4e" -} diff --git a/.sqlx/query-8f45a48700b8836f4ba8626b25b7be7f838d35d260430a46817729d9787e2013.json b/.sqlx/query-8f45a48700b8836f4ba8626b25b7be7f838d35d260430a46817729d9787e2013.json deleted file mode 100644 index b595f0c9..00000000 --- a/.sqlx/query-8f45a48700b8836f4ba8626b25b7be7f838d35d260430a46817729d9787e2013.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE users\n SET email = $1, email_verified = $2, trolley_account_status = $3\n WHERE id = $4\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Bool", - "Text", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "8f45a48700b8836f4ba8626b25b7be7f838d35d260430a46817729d9787e2013" -} diff --git a/.sqlx/query-9774f59e5d5ce6ba00ca7e3a4a81f80f78b908bdf664a4cdfad592a1b14c0d44.json b/.sqlx/query-9774f59e5d5ce6ba00ca7e3a4a81f80f78b908bdf664a4cdfad592a1b14c0d44.json deleted file mode 100644 index 7b467e91..00000000 --- a/.sqlx/query-9774f59e5d5ce6ba00ca7e3a4a81f80f78b908bdf664a4cdfad592a1b14c0d44.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE historical_payouts\n SET status = $1\n WHERE payment_id = $2\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "9774f59e5d5ce6ba00ca7e3a4a81f80f78b908bdf664a4cdfad592a1b14c0d44" -} diff --git a/.sqlx/query-aab77a2364f08b81d3e01bf396da0ea5162f0c11465dfeb50eb17ca6c8bd337b.json b/.sqlx/query-aab77a2364f08b81d3e01bf396da0ea5162f0c11465dfeb50eb17ca6c8bd337b.json new file mode 100644 index 00000000..bfbc024f --- /dev/null +++ b/.sqlx/query-aab77a2364f08b81d3e01bf396da0ea5162f0c11465dfeb50eb17ca6c8bd337b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET venmo_handle = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "aab77a2364f08b81d3e01bf396da0ea5162f0c11465dfeb50eb17ca6c8bd337b" +} diff --git a/.sqlx/query-b1e77dbaf4b190ab361f4fa203c442e5905cef6c1a135011a59ebd6e2dc0a92a.json b/.sqlx/query-b1e77dbaf4b190ab361f4fa203c442e5905cef6c1a135011a59ebd6e2dc0a92a.json deleted file mode 100644 index 0101d0ec..00000000 --- a/.sqlx/query-b1e77dbaf4b190ab361f4fa203c442e5905cef6c1a135011a59ebd6e2dc0a92a.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE users\n SET balance = balance - $1\n WHERE id = $2\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Numeric", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "b1e77dbaf4b190ab361f4fa203c442e5905cef6c1a135011a59ebd6e2dc0a92a" -} diff --git a/.sqlx/query-bbc96f470cb7819a5c8aa0725e630311cfa8da5b3b119b9ce791e8e978250c6e.json b/.sqlx/query-bbc96f470cb7819a5c8aa0725e630311cfa8da5b3b119b9ce791e8e978250c6e.json new file mode 100644 index 00000000..72792633 --- /dev/null +++ b/.sqlx/query-bbc96f470cb7819a5c8aa0725e630311cfa8da5b3b119b9ce791e8e978250c6e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE payouts\n SET status = $1\n WHERE platform_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "bbc96f470cb7819a5c8aa0725e630311cfa8da5b3b119b9ce791e8e978250c6e" +} diff --git a/.sqlx/query-04128dd06489004e0d0305bfd0f4ca5ee4b4a6b9f610de6e1b9ef9c8543cc025.json b/.sqlx/query-c03c8c00fe93569d0f464b7a058a903d9b3bbd09fc7f70136c3c09e1c15a9062.json similarity index 66% rename from .sqlx/query-04128dd06489004e0d0305bfd0f4ca5ee4b4a6b9f610de6e1b9ef9c8543cc025.json rename to .sqlx/query-c03c8c00fe93569d0f464b7a058a903d9b3bbd09fc7f70136c3c09e1c15a9062.json index f4687bbd..ccf73956 100644 --- a/.sqlx/query-04128dd06489004e0d0305bfd0f4ca5ee4b4a6b9f610de6e1b9ef9c8543cc025.json +++ b/.sqlx/query-c03c8c00fe93569d0f464b7a058a903d9b3bbd09fc7f70136c3c09e1c15a9062.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id FROM users WHERE trolley_id = $1", + "query": "SELECT id FROM users WHERE paypal_id = $1", "describe": { "columns": [ { @@ -18,5 +18,5 @@ false ] }, - "hash": "04128dd06489004e0d0305bfd0f4ca5ee4b4a6b9f610de6e1b9ef9c8543cc025" + "hash": "c03c8c00fe93569d0f464b7a058a903d9b3bbd09fc7f70136c3c09e1c15a9062" } diff --git a/.sqlx/query-f141cc6711123b4fe5a5d9a7337a0b009b80e5d8fbda664b8d62b1a3f38eb936.json b/.sqlx/query-f141cc6711123b4fe5a5d9a7337a0b009b80e5d8fbda664b8d62b1a3f38eb936.json deleted file mode 100644 index 429dac64..00000000 --- a/.sqlx/query-f141cc6711123b4fe5a5d9a7337a0b009b80e5d8fbda664b8d62b1a3f38eb936.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE users\n SET balance = balance + $1\n WHERE id = $2\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Numeric", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "f141cc6711123b4fe5a5d9a7337a0b009b80e5d8fbda664b8d62b1a3f38eb936" -} diff --git a/.sqlx/query-e5adaf219c52ec828b72bd89c6b86a475f73181abf180a024dfe05f918e58edb.json b/.sqlx/query-f1441ead59f221901f34d3f7c8e2b27bb020c083c333a1be518827d6df79846e.json similarity index 55% rename from .sqlx/query-e5adaf219c52ec828b72bd89c6b86a475f73181abf180a024dfe05f918e58edb.json rename to .sqlx/query-f1441ead59f221901f34d3f7c8e2b27bb020c083c333a1be518827d6df79846e.json index bff48f76..9cfc51db 100644 --- a/.sqlx/query-e5adaf219c52ec828b72bd89c6b86a475f73181abf180a024dfe05f918e58edb.json +++ b/.sqlx/query-f1441ead59f221901f34d3f7c8e2b27bb020c083c333a1be518827d6df79846e.json @@ -1,11 +1,11 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, amount, user_id, status FROM historical_payouts WHERE payment_id = $1", + "query": "SELECT user_id, amount, fee FROM payouts WHERE platform_id = $1 AND status = $2", "describe": { "columns": [ { "ordinal": 0, - "name": "id", + "name": "user_id", "type_info": "Int8" }, { @@ -15,26 +15,21 @@ }, { "ordinal": 2, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 3, - "name": "status", - "type_info": "Varchar" + "name": "fee", + "type_info": "Numeric" } ], "parameters": { "Left": [ + "Text", "Text" ] }, "nullable": [ false, false, - false, - false + true ] }, - "hash": "e5adaf219c52ec828b72bd89c6b86a475f73181abf180a024dfe05f918e58edb" + "hash": "f1441ead59f221901f34d3f7c8e2b27bb020c083c333a1be518827d6df79846e" } diff --git a/.sqlx/query-f8ede4a0cd843e8f5622d6e0fb26df14fbc3a47b17c4d628a1bb21cff387238e.json b/.sqlx/query-f8ede4a0cd843e8f5622d6e0fb26df14fbc3a47b17c4d628a1bb21cff387238e.json new file mode 100644 index 00000000..5be0b837 --- /dev/null +++ b/.sqlx/query-f8ede4a0cd843e8f5622d6e0fb26df14fbc3a47b17c4d628a1bb21cff387238e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id\n FROM payouts\n WHERE user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f8ede4a0cd843e8f5622d6e0fb26df14fbc3a47b17c4d628a1bb21cff387238e" +} diff --git a/.sqlx/query-ff474fba4d18f7788b1a1900a6e5549f0899689e857634cfc5d85bd7b8718c46.json b/.sqlx/query-ff474fba4d18f7788b1a1900a6e5549f0899689e857634cfc5d85bd7b8718c46.json new file mode 100644 index 00000000..6b1ae87a --- /dev/null +++ b/.sqlx/query-ff474fba4d18f7788b1a1900a6e5549f0899689e857634cfc5d85bd7b8718c46.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET balance = balance + $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Numeric", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ff474fba4d18f7788b1a1900a6e5549f0899689e857634cfc5d85bd7b8718c46" +} diff --git a/Cargo.lock b/Cargo.lock index 1b27597b..70c971d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1107,6 +1107,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "csv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + [[package]] name = "curl" version = "0.4.44" @@ -1368,6 +1389,16 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.3.7" @@ -1379,6 +1410,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dlv-list" version = "0.3.0" @@ -1416,6 +1458,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.33" @@ -2292,6 +2340,7 @@ dependencies = [ "reqwest", "rust-s3", "rust_decimal", + "rust_iso3166", "sentry", "sentry-actix", "serde", @@ -2958,6 +3007,48 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.39", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "phonenumber" version = "0.3.3+8.13.9" @@ -3079,6 +3170,20 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "prettytable-rs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +dependencies = [ + "csv", + "encode_unicode", + "is-terminal", + "lazy_static", + "term", + "unicode-width", +] + [[package]] name = "proc-macro-crate" version = "0.1.5" @@ -3565,6 +3670,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "rust_iso3166" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc46f436f726b768364d35d099f43a94f22fd34857ff4f679b1f5cbcb03b9f71" +dependencies = [ + "js-sys", + "phf", + "prettytable-rs", + "wasm-bindgen", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -4025,6 +4142,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slab" version = "0.4.9" @@ -4457,6 +4580,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "termcolor" version = "1.3.0" @@ -4753,6 +4887,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + [[package]] name = "unicode_categories" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index f2b881c2..83cb9d6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,7 @@ woothee = "0.13.0" lettre = "0.10.4" derive-new = "0.5.9" +rust_iso3166 = "0.1.11" [dev-dependencies] actix-http = "3.4.0" diff --git a/migrations/20230919183129_trolley.sql b/migrations/20230919183129_trolley.sql deleted file mode 100644 index 8dfb148f..00000000 --- a/migrations/20230919183129_trolley.sql +++ /dev/null @@ -1,16 +0,0 @@ -ALTER TABLE users - ADD COLUMN trolley_id text NULL, - ADD COLUMN trolley_account_status text NULL, - DROP COLUMN midas_expires, - DROP COLUMN is_overdue, - DROP COLUMN stripe_customer_id, - DROP COLUMN payout_wallet, - DROP COLUMN payout_wallet_type, - DROP COLUMN payout_address; - -ALTER TABLE historical_payouts - ADD COLUMN batch_id text NULL, - ADD COLUMN payment_id text NULL; - -UPDATE historical_payouts -SET status = 'processed' \ No newline at end of file diff --git a/migrations/20231114175920_new-payment-methods.sql b/migrations/20231114175920_new-payment-methods.sql new file mode 100644 index 00000000..f5126a8e --- /dev/null +++ b/migrations/20231114175920_new-payment-methods.sql @@ -0,0 +1,28 @@ +ALTER TABLE users + ADD COLUMN paypal_country text NULL, + ADD COLUMN paypal_email text NULL, + ADD COLUMN paypal_id text NULL, + ADD COLUMN venmo_handle text NULL, + + DROP COLUMN midas_expires, + DROP COLUMN is_overdue, + DROP COLUMN stripe_customer_id, + DROP COLUMN payout_wallet, + DROP COLUMN payout_wallet_type, + DROP COLUMN payout_address; + +ALTER TABLE historical_payouts + RENAME TO payouts; + +ALTER TABLE payouts + ADD COLUMN method text NULL, + ADD COLUMN method_address text NULL, + ADD COLUMN platform_id text NULL, + ADD COLUMN fee numeric(40, 20) NULL, + ALTER COLUMN id TYPE bigint, + ALTER COLUMN id DROP DEFAULT; + +UPDATE payouts +SET status = 'success'; + +DROP SEQUENCE IF EXISTS historical_payouts_id_seq; diff --git a/src/auth/flows.rs b/src/auth/flows.rs index 53f80607..75788bf6 100644 --- a/src/auth/flows.rs +++ b/src/auth/flows.rs @@ -8,8 +8,7 @@ use crate::file_hosting::FileHost; use crate::models::ids::base62_impl::{parse_base62, to_base62}; use crate::models::ids::random_base62_rng; use crate::models::pats::Scopes; -use crate::models::users::{Badges, RecipientStatus, Role, UserPayoutData}; -use crate::queue::payouts::{AccountUser, PayoutsQueue}; +use crate::models::users::{Badges, Role}; use crate::queue::session::AuthQueue; use crate::queue::socket::ActiveSockets; use crate::routes::ApiError; @@ -22,6 +21,7 @@ use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use actix_ws::Closed; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; +use base64::Engine; use chrono::{Duration, Utc}; use rand_chacha::rand_core::SeedableRng; use rand_chacha::ChaCha20Rng; @@ -31,7 +31,7 @@ use serde::{Deserialize, Serialize}; use sqlx::postgres::PgPool; use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::{Mutex, RwLock}; +use tokio::sync::RwLock; use validator::Validate; pub fn config(cfg: &mut ServiceConfig) { @@ -52,8 +52,7 @@ pub fn config(cfg: &mut ServiceConfig) { .service(resend_verify_email) .service(set_email) .service(verify_email) - .service(subscribe_newsletter) - .service(link_trolley), + .service(subscribe_newsletter), ); } @@ -67,6 +66,7 @@ pub enum AuthProvider { GitLab, Google, Steam, + PayPal, } #[derive(Debug)] @@ -78,6 +78,8 @@ pub struct TempUser { pub avatar_url: Option, pub bio: Option, pub name: Option, + + pub country: Option, } impl TempUser { @@ -211,11 +213,23 @@ impl TempUser { None }, microsoft_id: if provider == AuthProvider::Microsoft { - Some(self.id) + Some(self.id.clone()) } else { None }, password: None, + paypal_id: if provider == AuthProvider::PayPal { + Some(self.id) + } else { + None + }, + paypal_country: self.country, + paypal_email: if provider == AuthProvider::PayPal { + self.email.clone() + } else { + None + }, + venmo_handle: None, totp_secret: None, username, name: self.name, @@ -227,8 +241,6 @@ impl TempUser { role: Role::Developer.to_string(), badges: Badges::default(), balance: Decimal::ZERO, - trolley_id: None, - trolley_account_status: None, } .insert(transaction) .await?; @@ -299,6 +311,21 @@ impl AuthProvider { "http://specs.openid.net/auth/2.0/identifier_select", ) } + AuthProvider::PayPal => { + let api_url = dotenvy::var("PAYPAL_API_URL")?; + let client_id = dotenvy::var("PAYPAL_CLIENT_ID")?; + + let auth_url = if api_url.contains("sandbox") { + "sandbox.paypal.com" + } else { + "paypal.com" + }; + + format!( + "https://{auth_url}/connect?flowEntry=static&client_id={client_id}&scope={}&response_type=code&redirect_uri={redirect_uri}&state={state}", + urlencoding::encode("openid email address https://uri.paypal.com/services/paypalattributes"), + ) + } }) } @@ -487,6 +514,37 @@ impl AuthProvider { return Err(AuthenticationError::InvalidCredentials); } } + AuthProvider::PayPal => { + let code = query + .get("code") + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + let api_url = dotenvy::var("PAYPAL_API_URL")?; + let client_id = dotenvy::var("PAYPAL_CLIENT_ID")?; + let client_secret = dotenvy::var("PAYPAL_CLIENT_SECRET")?; + + let mut map = HashMap::new(); + map.insert("code", code.as_str()); + map.insert("grant_type", "authorization_code"); + + let token: AccessToken = reqwest::Client::new() + .post(&format!("{api_url}oauth2/token")) + .header(reqwest::header::ACCEPT, "application/json") + .header( + AUTHORIZATION, + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode(format!("{client_id}:{client_secret}")) + ), + ) + .form(&map) + .send() + .await? + .json() + .await?; + + token.access_token + } }; Ok(res) @@ -532,6 +590,7 @@ impl AuthProvider { avatar_url: Some(github_user.avatar_url), bio: github_user.bio, name: github_user.name, + country: None, } } AuthProvider::Discord => { @@ -563,6 +622,7 @@ impl AuthProvider { .map(|x| format!("https://cdn.discordapp.com/avatars/{}/{}.webp", id, x)), bio: None, name: discord_user.global_name, + country: None, } } AuthProvider::Microsoft => { @@ -594,6 +654,7 @@ impl AuthProvider { avatar_url: None, bio: None, name: microsoft_user.display_name, + country: None, } } AuthProvider::GitLab => { @@ -623,6 +684,7 @@ impl AuthProvider { avatar_url: gitlab_user.avatar_url, bio: gitlab_user.bio, name: gitlab_user.name, + country: None, } } AuthProvider::Google => { @@ -656,6 +718,7 @@ impl AuthProvider { avatar_url: google_user.picture, bio: None, name: google_user.name, + country: None, } } AuthProvider::Steam => { @@ -707,11 +770,54 @@ impl AuthProvider { avatar_url: player.avatar, bio: None, name: Some(player.personaname), + country: None, } } else { return Err(AuthenticationError::InvalidCredentials); } } + AuthProvider::PayPal => { + #[derive(Deserialize, Debug)] + pub struct PayPalUser { + pub payer_id: String, + pub email: String, + pub picture: Option, + pub address: PayPalAddress, + } + + #[derive(Deserialize, Debug)] + pub struct PayPalAddress { + pub country: String, + } + + let api_url = dotenvy::var("PAYPAL_API_URL")?; + + let paypal_user: PayPalUser = reqwest::Client::new() + .get(&format!( + "{api_url}identity/openidconnect/userinfo?schema=openid" + )) + .header(reqwest::header::USER_AGENT, "Modrinth") + .header(AUTHORIZATION, format!("Bearer {token}")) + .send() + .await? + .json() + .await?; + + TempUser { + id: paypal_user.payer_id, + username: paypal_user + .email + .split('@') + .next() + .unwrap_or_default() + .to_string(), + email: Some(paypal_user.email), + avatar_url: paypal_user.picture, + bio: None, + name: None, + country: Some(paypal_user.address.country), + } + } }; Ok(res) @@ -782,6 +888,13 @@ impl AuthProvider { .fetch_optional(executor) .await?; + value.map(|x| crate::database::models::UserId(x.id)) + } + AuthProvider::PayPal => { + let value = sqlx::query!("SELECT id FROM users WHERE paypal_id = $1", id) + .fetch_optional(executor) + .await?; + value.map(|x| crate::database::models::UserId(x.id)) } }) @@ -872,6 +985,32 @@ impl AuthProvider { .execute(&mut **transaction) .await?; } + AuthProvider::PayPal => { + if id.is_none() { + sqlx::query!( + " + UPDATE users + SET paypal_country = NULL, paypal_email = NULL, paypal_id = NULL + WHERE (id = $1) + ", + user_id as crate::database::models::UserId, + ) + .execute(&mut **transaction) + .await?; + } else { + sqlx::query!( + " + UPDATE users + SET paypal_id = $2 + WHERE (id = $1) + ", + user_id as crate::database::models::UserId, + id, + ) + .execute(&mut **transaction) + .await?; + } + } } Ok(()) @@ -885,6 +1024,7 @@ impl AuthProvider { AuthProvider::GitLab => "GitLab", AuthProvider::Google => "Google", AuthProvider::Steam => "Steam", + AuthProvider::PayPal => "PayPal", } } } @@ -1043,7 +1183,22 @@ pub async fn auth_callback( .await?; let user = crate::database::models::User::get_id(id, &**client, &redis).await?; - if let Some(email) = user.and_then(|x| x.email) { + + if provider == AuthProvider::PayPal { + sqlx::query!( + " + UPDATE users + SET paypal_country = $1, paypal_email = $2, paypal_id = $3 + WHERE (id = $4) + ", + oauth_user.country, + oauth_user.email, + oauth_user.id, + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } else if let Some(email) = user.and_then(|x| x.email) { send_email( email, "Authentication method added", @@ -1241,14 +1396,16 @@ pub async fn delete_auth_provider( .update_user_id(user.id.into(), None, &mut transaction) .await?; - if let Some(email) = user.email { - send_email( - email, - "Authentication method removed", - &format!("When logging into Modrinth, you can no longer log in using the {} authentication provider.", delete_provider.provider.as_str()), - "If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).", - None, - )?; + if delete_provider.provider != AuthProvider::PayPal { + if let Some(email) = user.email { + send_email( + email, + "Authentication method removed", + &format!("When logging into Modrinth, you can no longer log in using the {} authentication provider.", delete_provider.provider.as_str()), + "If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).", + None, + )?; + } } crate::database::models::User::clear_caches(&[(user.id.into(), None)], &redis).await?; @@ -1375,6 +1532,10 @@ pub async fn create_account_with_password( steam_id: None, microsoft_id: None, password: Some(password_hash), + paypal_id: None, + paypal_country: None, + paypal_email: None, + venmo_handle: None, totp_secret: None, username: new_account.username.clone(), name: Some(new_account.username), @@ -1386,8 +1547,6 @@ pub async fn create_account_with_password( role: Role::Developer.to_string(), badges: Badges::default(), balance: Decimal::ZERO, - trolley_id: None, - trolley_account_status: None, } .insert(&mut transaction) .await?; @@ -2011,7 +2170,6 @@ pub async fn set_email( redis: Data, email: web::Json, session_queue: Data, - payouts_queue: Data>, ) -> Result { email .0 @@ -2065,17 +2223,6 @@ pub async fn set_email( "We need to verify your email address.", )?; - if let Some(UserPayoutData { - trolley_id: Some(trolley_id), - .. - }) = user.payout_data - { - let queue = payouts_queue.lock().await; - queue - .update_recipient_email(&trolley_id, &email.email) - .await?; - } - crate::database::models::User::clear_caches(&[(user.id.into(), None)], &redis).await?; transaction.commit().await?; @@ -2218,63 +2365,3 @@ fn send_email_verify( Some(("Verify email", &format!("{}/{}?flow={}", dotenvy::var("SITE_URL")?, dotenvy::var("SITE_VERIFY_EMAIL_PATH")?, flow))), ) } - -#[post("trolley/link")] -pub async fn link_trolley( - req: HttpRequest, - pool: Data, - redis: Data, - session_queue: Data, - payouts_queue: Data>, - body: web::Json, -) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PAYOUTS_WRITE]), - ) - .await? - .1; - - if let Some(payout_data) = user.payout_data { - if payout_data.trolley_id.is_some() { - return Err(ApiError::InvalidInput( - "User already has a trolley account.".to_string(), - )); - } - } - - if let Some(email) = user.email { - let id = payouts_queue - .lock() - .await - .register_recipient(&email, body.0) - .await?; - - let mut transaction = pool.begin().await?; - - sqlx::query!( - " - UPDATE users - SET trolley_id = $1, trolley_account_status = $2 - WHERE id = $3 - ", - id, - RecipientStatus::Incomplete.as_str(), - user.id.0 as i64, - ) - .execute(&mut *transaction) - .await?; - - transaction.commit().await?; - crate::database::models::User::clear_caches(&[(user.id.into(), None)], &redis).await?; - - Ok(HttpResponse::NoContent().finish()) - } else { - Err(ApiError::InvalidInput( - "User needs to have an email set on account.".to_string(), - )) - } -} diff --git a/src/auth/validate.rs b/src/auth/validate.rs index 8c2a92e1..475c5956 100644 --- a/src/auth/validate.rs +++ b/src/auth/validate.rs @@ -44,6 +44,9 @@ where if db_user.steam_id.is_some() { auth_providers.push(AuthProvider::Steam) } + if db_user.paypal_id.is_some() { + auth_providers.push(AuthProvider::PayPal) + } let user = User { id: UserId::from(db_user.id), @@ -61,9 +64,10 @@ where has_totp: Some(db_user.totp_secret.is_some()), github_id: None, payout_data: Some(UserPayoutData { + paypal_address: db_user.paypal_email, + paypal_country: db_user.paypal_country, + venmo_handle: db_user.venmo_handle, balance: db_user.balance, - trolley_id: db_user.trolley_id, - trolley_status: db_user.trolley_account_status, }), }; diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index bd274fb5..0ce3c7c8 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -184,6 +184,14 @@ generate_ids!( OAuthAccessTokenId ); +generate_ids!( + pub generate_payout_id, + PayoutId, + 8, + "SELECT EXISTS(SELECT 1 FROM oauth_access_tokens WHERE id=$1)", + PayoutId +); + #[derive(Copy, Clone, Debug, PartialEq, Eq, Type, Hash, Serialize, Deserialize)] #[sqlx(transparent)] pub struct UserId(pub i64); @@ -298,6 +306,10 @@ pub struct OAuthRedirectUriId(pub i64); #[sqlx(transparent)] pub struct OAuthAccessTokenId(pub i64); +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[sqlx(transparent)] +pub struct PayoutId(pub i64); + use crate::models::ids; impl From for ProjectId { @@ -440,3 +452,14 @@ impl From for ids::OAuthClientAuthorizationId { ids::OAuthClientAuthorizationId(id.0 as u64) } } + +impl From for PayoutId { + fn from(id: ids::PayoutId) -> Self { + PayoutId(id.0 as i64) + } +} +impl From for ids::PayoutId { + fn from(id: PayoutId) -> Self { + ids::PayoutId(id.0 as u64) + } +} diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index eb4335cb..eb931f7d 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -13,6 +13,7 @@ pub mod oauth_client_item; pub mod oauth_token_item; pub mod organization_item; pub mod pat_item; +pub mod payout_item; pub mod project_item; pub mod report_item; pub mod session_item; diff --git a/src/database/models/payout_item.rs b/src/database/models/payout_item.rs new file mode 100644 index 00000000..c2d03f48 --- /dev/null +++ b/src/database/models/payout_item.rs @@ -0,0 +1,117 @@ +use crate::models::payouts::{PayoutMethodType, PayoutStatus}; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +use super::{DatabaseError, PayoutId, UserId}; + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct Payout { + pub id: PayoutId, + pub user_id: UserId, + pub created: DateTime, + pub status: PayoutStatus, + pub amount: Decimal, + + pub fee: Option, + pub method: Option, + pub method_address: Option, + pub platform_id: Option, +} + +impl Payout { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO payouts ( + id, amount, fee, user_id, status, method, method_address, platform_id + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 + ) + ", + self.id.0, + self.amount, + self.fee, + self.user_id.0, + self.status.as_str(), + self.method.map(|x| x.as_str()), + self.method_address, + self.platform_id, + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn get<'a, 'b, E>(id: PayoutId, executor: E) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Payout::get_many(&[id], executor) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many<'a, E>( + payout_ids: &[PayoutId], + exec: E, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use futures::TryStreamExt; + + let results = sqlx::query!( + " + SELECT id, user_id, created, amount, status, method, method_address, platform_id, fee + FROM payouts + WHERE id = ANY($1) + ", + &payout_ids.into_iter().map(|x| x.0).collect::>() + ) + .fetch_many(exec) + .try_filter_map(|e| async { + Ok(e.right().map(|r| Payout { + id: PayoutId(r.id), + user_id: UserId(r.user_id), + created: r.created, + status: PayoutStatus::from_string(&r.status), + amount: r.amount, + method: r.method.map(|x| PayoutMethodType::from_string(&x)), + method_address: r.method_address, + platform_id: r.platform_id, + fee: r.fee, + })) + }) + .try_collect::>() + .await?; + + Ok(results) + } + + pub async fn get_all_for_user( + user_id: UserId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let results = sqlx::query!( + " + SELECT id + FROM payouts + WHERE user_id = $1 + ", + user_id.0 + ) + .fetch_all(exec) + .await?; + + Ok(results + .into_iter() + .map(|r| PayoutId(r.id)) + .collect::>()) + } +} diff --git a/src/database/models/user_item.rs b/src/database/models/user_item.rs index 8230ff58..6df542f6 100644 --- a/src/database/models/user_item.rs +++ b/src/database/models/user_item.rs @@ -3,7 +3,7 @@ use super::CollectionId; use crate::database::models::{DatabaseError, OrganizationId}; use crate::database::redis::RedisPool; use crate::models::ids::base62_impl::{parse_base62, to_base62}; -use crate::models::users::{Badges, RecipientStatus}; +use crate::models::users::Badges; use chrono::{DateTime, Utc}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -24,6 +24,11 @@ pub struct User { pub microsoft_id: Option, pub password: Option, + pub paypal_id: Option, + pub paypal_country: Option, + pub paypal_email: Option, + pub venmo_handle: Option, + pub totp_secret: Option, pub username: String, @@ -37,8 +42,6 @@ pub struct User { pub badges: Badges, pub balance: Decimal, - pub trolley_id: Option, - pub trolley_account_status: Option, } impl User { @@ -52,13 +55,14 @@ impl User { id, username, name, email, avatar_url, bio, created, github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, - email_verified, password + email_verified, password, paypal_id, paypal_country, paypal_email, + venmo_handle ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, - $14, $15 + $14, $15, $16, $17, $18, $19 ) ", self.id as UserId, @@ -76,6 +80,10 @@ impl User { self.microsoft_id, self.email_verified, self.password, + self.paypal_id, + self.paypal_country, + self.paypal_email, + self.venmo_handle ) .execute(&mut **transaction) .await?; @@ -192,7 +200,8 @@ impl User { created, role, badges, balance, github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, - email_verified, password, totp_secret, trolley_id, trolley_account_status + email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email, + venmo_handle FROM users WHERE id = ANY($1) OR LOWER(username) = ANY($2) ", @@ -223,12 +232,11 @@ impl User { badges: Badges::from_bits(u.badges as u64).unwrap_or_default(), balance: u.balance, password: u.password, + paypal_id: u.paypal_id, + paypal_country: u.paypal_country, + paypal_email: u.paypal_email, + venmo_handle: u.venmo_handle, totp_secret: u.totp_secret, - trolley_id: u.trolley_id, - trolley_account_status: u - .trolley_account_status - .as_ref() - .map(|x| RecipientStatus::from_string(x)), })) }) .try_collect::>() @@ -559,7 +567,7 @@ impl User { sqlx::query!( " - DELETE FROM historical_payouts + DELETE FROM payouts WHERE user_id = $1 ", id as UserId, diff --git a/src/lib.rs b/src/lib.rs index 0912e4e1..13eef167 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,7 @@ use queue::{ }; use scheduler::Scheduler; use sqlx::Postgres; -use tokio::sync::{Mutex, RwLock}; +use tokio::sync::RwLock; extern crate clickhouse as clickhouse_crate; use clickhouse_crate::Client; @@ -49,7 +49,7 @@ pub struct LabrinthConfig { pub ip_salt: Pepper, pub search_config: search::SearchConfig, pub session_queue: web::Data, - pub payouts_queue: web::Data>, + pub payouts_queue: web::Data, pub analytics_queue: Arc, pub active_sockets: web::Data>, } @@ -227,7 +227,7 @@ pub fn app_setup( pepper: models::ids::Base62Id(models::ids::random_base62(11)).to_string(), }; - let payouts_queue = web::Data::new(Mutex::new(PayoutsQueue::new())); + let payouts_queue = web::Data::new(PayoutsQueue::new()); let active_sockets = web::Data::new(RwLock::new(ActiveSockets::default())); LabrinthConfig { @@ -349,10 +349,6 @@ pub fn check_env_vars() -> bool { failed |= true; } - failed |= check_var::("TROLLEY_ACCESS_KEY"); - failed |= check_var::("TROLLEY_SECRET_KEY"); - failed |= check_var::("TROLLEY_WEBHOOK_SIGNATURE"); - failed |= check_var::("GITHUB_CLIENT_ID"); failed |= check_var::("GITHUB_CLIENT_SECRET"); failed |= check_var::("GITLAB_CLIENT_ID"); @@ -365,6 +361,15 @@ pub fn check_env_vars() -> bool { failed |= check_var::("GOOGLE_CLIENT_SECRET"); failed |= check_var::("STEAM_API_KEY"); + failed |= check_var::("TREMENDOUS_API_URL"); + failed |= check_var::("TREMENDOUS_API_KEY"); + failed |= check_var::("TREMENDOUS_PRIVATE_KEY"); + + failed |= check_var::("PAYPAL_API_URL"); + failed |= check_var::("PAYPAL_WEBHOOK_ID"); + failed |= check_var::("PAYPAL_CLIENT_ID"); + failed |= check_var::("PAYPAL_CLIENT_SECRET"); + failed |= check_var::("TURNSTILE_SECRET"); failed |= check_var::("SMTP_USERNAME"); diff --git a/src/models/v3/error.rs b/src/models/error.rs similarity index 100% rename from src/models/v3/error.rs rename to src/models/error.rs diff --git a/src/models/mod.rs b/src/models/mod.rs index c4ff81a2..b1a12c9b 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,9 +1,9 @@ +pub mod error; pub mod v2; pub mod v3; pub use v3::analytics; pub use v3::collections; -pub use v3::error; pub use v3::ids; pub use v3::images; pub use v3::notifications; @@ -11,6 +11,7 @@ pub use v3::oauth_clients; pub use v3::organizations; pub use v3::pack; pub use v3::pats; +pub use v3::payouts; pub use v3::projects; pub use v3::reports; pub use v3::sessions; diff --git a/src/models/v3/ids.rs b/src/models/v3/ids.rs index 8cea089f..73d0c32c 100644 --- a/src/models/v3/ids.rs +++ b/src/models/v3/ids.rs @@ -7,6 +7,7 @@ pub use super::oauth_clients::OAuthClientAuthorizationId; pub use super::oauth_clients::{OAuthClientId, OAuthRedirectUriId}; pub use super::organizations::OrganizationId; pub use super::pats::PatId; +pub use super::payouts::PayoutId; pub use super::projects::{ProjectId, VersionId}; pub use super::reports::ReportId; pub use super::sessions::SessionId; @@ -127,6 +128,7 @@ base62_id_impl!(ImageId, ImageId); base62_id_impl!(OAuthClientId, OAuthClientId); base62_id_impl!(OAuthRedirectUriId, OAuthRedirectUriId); base62_id_impl!(OAuthClientAuthorizationId, OAuthClientAuthorizationId); +base62_id_impl!(PayoutId, PayoutId); pub mod base62_impl { use serde::de::{self, Deserializer, Visitor}; diff --git a/src/models/v3/mod.rs b/src/models/v3/mod.rs index 7c97ad31..34f5836b 100644 --- a/src/models/v3/mod.rs +++ b/src/models/v3/mod.rs @@ -1,6 +1,5 @@ pub mod analytics; pub mod collections; -pub mod error; pub mod ids; pub mod images; pub mod notifications; @@ -8,6 +7,7 @@ pub mod oauth_clients; pub mod organizations; pub mod pack; pub mod pats; +pub mod payouts; pub mod projects; pub mod reports; pub mod sessions; diff --git a/src/models/v3/payouts.rs b/src/models/v3/payouts.rs new file mode 100644 index 00000000..ba4b6310 --- /dev/null +++ b/src/models/v3/payouts.rs @@ -0,0 +1,176 @@ +use crate::models::ids::{Base62Id, UserId}; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct PayoutId(pub u64); + +#[derive(Serialize, Deserialize, Clone)] +pub struct Payout { + pub id: PayoutId, + pub user_id: UserId, + pub status: PayoutStatus, + pub created: DateTime, + #[serde(with = "rust_decimal::serde::float")] + pub amount: Decimal, + + #[serde(with = "rust_decimal::serde::float_option")] + pub fee: Option, + pub method: Option, + /// the address this payout was sent to: ex: email, paypal email, venmo handle + pub method_address: Option, + pub platform_id: Option, +} + +impl Payout { + pub fn from(data: crate::database::models::payout_item::Payout) -> Self { + Self { + id: data.id.into(), + user_id: data.user_id.into(), + status: data.status, + created: data.created, + amount: data.amount, + fee: data.fee, + method: data.method, + method_address: data.method_address, + platform_id: data.platform_id, + } + } +} + +#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)] +#[serde(rename_all = "lowercase")] +pub enum PayoutMethodType { + Venmo, + PayPal, + Tremendous, + Unknown, +} + +impl std::fmt::Display for PayoutMethodType { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(fmt, "{}", self.as_str()) + } +} + +impl PayoutMethodType { + pub fn as_str(&self) -> &'static str { + match self { + PayoutMethodType::Venmo => "venmo", + PayoutMethodType::PayPal => "paypal", + PayoutMethodType::Tremendous => "tremendous", + PayoutMethodType::Unknown => "unknown", + } + } + + pub fn from_string(string: &str) -> PayoutMethodType { + match string { + "venmo" => PayoutMethodType::Venmo, + "paypal" => PayoutMethodType::PayPal, + "tremendous" => PayoutMethodType::Tremendous, + _ => PayoutMethodType::Unknown, + } + } +} + +#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum PayoutStatus { + Success, + InTransit, + Cancelled, + Cancelling, + Failed, + Unknown, +} + +impl std::fmt::Display for PayoutStatus { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(fmt, "{}", self.as_str()) + } +} + +impl PayoutStatus { + pub fn as_str(&self) -> &'static str { + match self { + PayoutStatus::Success => "success", + PayoutStatus::InTransit => "in-transit", + PayoutStatus::Cancelled => "cancelled", + PayoutStatus::Cancelling => "cancelling", + PayoutStatus::Failed => "failed", + PayoutStatus::Unknown => "unknown", + } + } + + pub fn from_string(string: &str) -> PayoutStatus { + match string { + "success" => PayoutStatus::Success, + "in-transit" => PayoutStatus::InTransit, + "cancelled" => PayoutStatus::Cancelled, + "cancelling" => PayoutStatus::Cancelling, + "failed" => PayoutStatus::Failed, + _ => PayoutStatus::Unknown, + } + } +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct PayoutMethod { + pub id: String, + #[serde(rename = "type")] + pub type_: PayoutMethodType, + pub name: String, + pub supported_countries: Vec, + pub image_url: Option, + pub interval: PayoutInterval, + pub fee: PayoutMethodFee, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct PayoutMethodFee { + #[serde(with = "rust_decimal::serde::float")] + pub percentage: Decimal, + #[serde(with = "rust_decimal::serde::float")] + pub min: Decimal, + #[serde(with = "rust_decimal::serde::float_option")] + pub max: Option, +} + +#[derive(Clone)] +pub struct PayoutDecimal(pub Decimal); + +impl Serialize for PayoutDecimal { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + rust_decimal::serde::float::serialize(&self.0, serializer) + } +} + +impl<'de> Deserialize<'de> for PayoutDecimal { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let decimal = rust_decimal::serde::float::deserialize(deserializer)?; + Ok(PayoutDecimal(decimal)) + } +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum PayoutInterval { + Standard { + #[serde(with = "rust_decimal::serde::float")] + min: Decimal, + #[serde(with = "rust_decimal::serde::float")] + max: Decimal, + }, + Fixed { + values: Vec, + }, +} diff --git a/src/models/v3/users.rs b/src/models/v3/users.rs index 8a012bf1..0eefc30a 100644 --- a/src/models/v3/users.rs +++ b/src/models/v3/users.rs @@ -5,7 +5,7 @@ use chrono::{DateTime, Utc}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; -#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug, Hash)] #[serde(from = "Base62Id")] #[serde(into = "Base62Id")] pub struct UserId(pub u64); @@ -61,9 +61,11 @@ pub struct User { #[derive(Serialize, Deserialize, Clone, Debug)] pub struct UserPayoutData { + pub paypal_address: Option, + pub paypal_country: Option, + pub venmo_handle: Option, + #[serde(with = "rust_decimal::serde::float")] pub balance: Decimal, - pub trolley_id: Option, - pub trolley_status: Option, } use crate::database::models::user_item::User as DBUser; @@ -134,89 +136,3 @@ impl Role { } } } - -#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)] -#[serde(rename_all = "lowercase")] -pub enum RecipientStatus { - Active, - Incomplete, - Disabled, - Archived, - Suspended, - Blocked, -} - -impl RecipientStatus { - pub fn from_string(string: &str) -> RecipientStatus { - match string { - "active" => RecipientStatus::Active, - "incomplete" => RecipientStatus::Incomplete, - "disabled" => RecipientStatus::Disabled, - "archived" => RecipientStatus::Archived, - "suspended" => RecipientStatus::Suspended, - "blocked" => RecipientStatus::Blocked, - _ => RecipientStatus::Disabled, - } - } - - pub fn as_str(&self) -> &'static str { - match self { - RecipientStatus::Active => "active", - RecipientStatus::Incomplete => "incomplete", - RecipientStatus::Disabled => "disabled", - RecipientStatus::Archived => "archived", - RecipientStatus::Suspended => "suspended", - RecipientStatus::Blocked => "blocked", - } - } -} - -#[derive(Serialize)] -pub struct Payout { - pub created: DateTime, - pub amount: Decimal, - pub status: PayoutStatus, -} - -#[derive(Serialize, Deserialize, PartialEq, Eq, Clone)] -#[serde(rename_all = "lowercase")] -pub enum PayoutStatus { - Pending, - Failed, - Processed, - Returned, - Processing, -} - -impl PayoutStatus { - pub fn from_string(string: &str) -> PayoutStatus { - match string { - "pending" => PayoutStatus::Pending, - "failed" => PayoutStatus::Failed, - "processed" => PayoutStatus::Processed, - "returned" => PayoutStatus::Returned, - "processing" => PayoutStatus::Processing, - _ => PayoutStatus::Processing, - } - } - - pub fn as_str(&self) -> &'static str { - match self { - PayoutStatus::Pending => "pending", - PayoutStatus::Failed => "failed", - PayoutStatus::Processed => "processed", - PayoutStatus::Returned => "returned", - PayoutStatus::Processing => "processing", - } - } - - pub fn is_failed(&self) -> bool { - match self { - PayoutStatus::Pending => false, - PayoutStatus::Failed => true, - PayoutStatus::Processed => false, - PayoutStatus::Returned => true, - PayoutStatus::Processing => false, - } - } -} diff --git a/src/queue/payouts.rs b/src/queue/payouts.rs index 2c9608a2..6c8ac34e 100644 --- a/src/queue/payouts.rs +++ b/src/queue/payouts.rs @@ -1,131 +1,255 @@ +use crate::models::ids::UserId; +use crate::models::payouts::{ + PayoutDecimal, PayoutInterval, PayoutMethod, PayoutMethodFee, PayoutMethodType, +}; use crate::routes::ApiError; use crate::util::env::parse_var; use crate::{database::redis::RedisPool, models::projects::MonetizationStatus}; +use base64::Engine; use chrono::{DateTime, Datelike, Duration, Utc, Weekday}; -use hex::ToHex; -use hmac::{Hmac, Mac, NewMac}; +use dashmap::DashMap; use reqwest::Method; use rust_decimal::Decimal; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use sha2::Sha256; +use serde_json::Value; use sqlx::PgPool; use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; pub struct PayoutsQueue { - access_key: String, - secret_key: String, + credential: RwLock>, + payout_options: RwLock>, + payouts_locks: DashMap>>, } -impl Default for PayoutsQueue { - fn default() -> Self { - Self::new() - } +#[derive(Clone)] +struct PayPalCredentials { + access_token: String, + token_type: String, + expires: DateTime, } -#[derive(Clone, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum AccountUser { - Business { name: String }, - Individual { first: String, last: String }, +#[derive(Clone)] +struct PayoutMethods { + options: Vec, + expires: DateTime, } -#[derive(Serialize)] -pub struct PaymentInfo { - country: String, - payout_method: String, - route_minimum: Decimal, - estimated_fees: Decimal, - deduct_fees: Decimal, +impl Default for PayoutsQueue { + fn default() -> Self { + Self::new() + } } - // Batches payouts and handles token refresh impl PayoutsQueue { pub fn new() -> Self { PayoutsQueue { - access_key: dotenvy::var("TROLLEY_ACCESS_KEY").expect("missing trolley access key"), - secret_key: dotenvy::var("TROLLEY_SECRET_KEY").expect("missing trolley secret key"), + credential: RwLock::new(None), + payout_options: RwLock::new(None), + payouts_locks: DashMap::new(), + } + } + + async fn refresh_token(&self) -> Result { + let mut creds = self.credential.write().await; + let client = reqwest::Client::new(); + + let combined_key = format!( + "{}:{}", + dotenvy::var("PAYPAL_CLIENT_ID")?, + dotenvy::var("PAYPAL_CLIENT_SECRET")? + ); + let formatted_key = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(combined_key) + ); + + let mut form = HashMap::new(); + form.insert("grant_type", "client_credentials"); + + #[derive(Deserialize)] + struct PaypalCredential { + access_token: String, + token_type: String, + expires_in: i64, } + + let credential: PaypalCredential = client + .post(&format!("{}oauth2/token", dotenvy::var("PAYPAL_API_URL")?)) + .header("Accept", "application/json") + .header("Accept-Language", "en_US") + .header("Authorization", formatted_key) + .form(&form) + .send() + .await + .map_err(|_| ApiError::Payments("Error while authenticating with PayPal".to_string()))? + .json() + .await + .map_err(|_| { + ApiError::Payments( + "Error while authenticating with PayPal (deser error)".to_string(), + ) + })?; + + let new_creds = PayPalCredentials { + access_token: credential.access_token, + token_type: credential.token_type, + expires: Utc::now() + Duration::seconds(credential.expires_in), + }; + + *creds = Some(new_creds.clone()); + + Ok(new_creds) } - pub async fn make_trolley_request( + pub async fn make_paypal_request( &self, method: Method, path: &str, body: Option, + raw_text: Option, + no_api_prefix: Option, ) -> Result { - let timestamp = Utc::now().timestamp(); - - let mut mac: Hmac = Hmac::new_from_slice(self.secret_key.as_bytes()) - .map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?; - mac.update( - if let Some(body) = &body { - format!( - "{}\n{}\n{}\n{}\n", - timestamp, - method.as_str(), - path, - serde_json::to_string(&body)? - ) + let read = self.credential.read().await; + let credentials = if let Some(credentials) = read.as_ref() { + if credentials.expires < Utc::now() { + drop(read); + self.refresh_token().await.map_err(|_| { + ApiError::Payments("Error while authenticating with PayPal".to_string()) + })? } else { - format!("{}\n{}\n{}\n\n", timestamp, method.as_str(), path) + credentials.clone() } - .as_bytes(), - ); - let request_signature = mac.finalize().into_bytes().encode_hex::(); + } else { + drop(read); + self.refresh_token().await.map_err(|_| { + ApiError::Payments("Error while authenticating with PayPal".to_string()) + })? + }; let client = reqwest::Client::new(); - let mut request = client - .request(method, format!("https://api.trolley.com{path}")) + .request( + method, + if no_api_prefix.unwrap_or(false) { + path.to_string() + } else { + format!("{}{path}", dotenvy::var("PAYPAL_API_URL")?) + }, + ) .header( "Authorization", - format!("prsign {}:{}", self.access_key, request_signature), - ) - .header("X-PR-Timestamp", timestamp); + format!("{} {}", credentials.token_type, credentials.access_token), + ); if let Some(body) = body { request = request.json(&body); + } else if let Some(body) = raw_text { + request = request + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body); } let resp = request .send() .await - .map_err(|_| ApiError::Payments("could not communicate with Trolley".to_string()))?; + .map_err(|_| ApiError::Payments("could not communicate with PayPal".to_string()))?; + + let status = resp.status(); let value = resp.json::().await.map_err(|_| { - ApiError::Payments("could not retrieve Trolley response body".to_string()) + ApiError::Payments("could not retrieve PayPal response body".to_string()) })?; - if let Some(obj) = value.as_object() { - if !obj.get("ok").and_then(|x| x.as_bool()).unwrap_or(true) { - #[derive(Deserialize)] - struct TrolleyError { - field: Option, - message: String, - } + if !status.is_success() { + #[derive(Deserialize)] + struct PayPalError { + pub name: String, + pub message: String, + } + + #[derive(Deserialize)] + struct PayPalIdentityError { + pub error: String, + pub error_description: String, + } + + if let Ok(error) = serde_json::from_value::(value.clone()) { + return Err(ApiError::Payments(format!( + "error name: {}, message: {}", + error.name, error.message + ))); + } + + if let Ok(error) = serde_json::from_value::(value) { + return Err(ApiError::Payments(format!( + "error name: {}, message: {}", + error.error, error.error_description + ))); + } + + return Err(ApiError::Payments( + "could not retrieve PayPal error body".to_string(), + )); + } + + Ok(serde_json::from_value(value)?) + } + + pub async fn make_tremendous_request( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result { + let client = reqwest::Client::new(); + let mut request = client + .request( + method, + format!("{}{path}", dotenvy::var("TREMENDOUS_API_URL")?), + ) + .header( + "Authorization", + format!("Bearer {}", dotenvy::var("TREMENDOUS_API_KEY")?), + ); + + if let Some(body) = body { + request = request.json(&body); + } + + let resp = request + .send() + .await + .map_err(|_| ApiError::Payments("could not communicate with Tremendous".to_string()))?; + + let status = resp.status(); + + let value = resp.json::().await.map_err(|_| { + ApiError::Payments("could not retrieve Tremendous response body".to_string()) + })?; + if !status.is_success() { + if let Some(obj) = value.as_object() { if let Some(array) = obj.get("errors") { - let err = serde_json::from_value::>(array.clone()).map_err( - |_| { + #[derive(Deserialize)] + struct TremendousError { + message: String, + } + + let err = + serde_json::from_value::(array.clone()).map_err(|_| { ApiError::Payments( - "could not retrieve Trolley error json body".to_string(), + "could not retrieve Tremendous error json body".to_string(), ) - }, - )?; - - if let Some(first) = err.into_iter().next() { - return Err(ApiError::Payments(if let Some(field) = &first.field { - format!("error - field: {field} message: {}", first.message) - } else { - first.message - })); - } + })?; + + return Err(ApiError::Payments(err.message)); } return Err(ApiError::Payments( - "could not retrieve Trolley error body".to_string(), + "could not retrieve Tremendous error body".to_string(), )); } } @@ -133,200 +257,260 @@ impl PayoutsQueue { Ok(serde_json::from_value(value)?) } - pub async fn send_payout( - &mut self, - recipient: &str, - amount: Decimal, - ) -> Result<(String, Option), ApiError> { - #[derive(Deserialize)] - struct TrolleyRes { - batch: Batch, - } + pub async fn get_payout_methods(&self) -> Result, ApiError> { + async fn refresh_payout_methods(queue: &PayoutsQueue) -> Result { + let mut options = queue.payout_options.write().await; - #[derive(Deserialize)] - struct Batch { - id: String, - payments: BatchPayments, - } + let mut methods = Vec::new(); - #[derive(Deserialize)] - struct Payment { - id: String, - } + #[derive(Deserialize)] + pub struct Sku { + pub min: Decimal, + pub max: Decimal, + } - #[derive(Deserialize)] - struct BatchPayments { - payments: Vec, - } + #[derive(Deserialize, Eq, PartialEq)] + #[serde(rename_all = "snake_case")] + pub enum ProductImageType { + Card, + Logo, + } - let fee = self.get_estimated_fees(recipient, amount).await?; + #[derive(Deserialize)] + pub struct ProductImage { + pub src: String, + #[serde(rename = "type")] + pub type_: ProductImageType, + } - if fee.estimated_fees > amount || fee.route_minimum > amount { - return Err(ApiError::Payments( - "Account balance is too low to withdraw funds".to_string(), - )); - } + #[derive(Deserialize)] + pub struct ProductCountry { + pub abbr: String, + } - let send_amount = amount - fee.deduct_fees; - - let res = self - .make_trolley_request::<_, TrolleyRes>( - Method::POST, - "/v1/batches/", - Some(json!({ - "currency": "USD", - "description": "labrinth payout", - "payments": [{ - "recipient": { - "id": recipient - }, - "amount": send_amount.to_string(), - "currency": "USD", - "memo": "Modrinth ad revenue payout" - }], - })), - ) - .await?; + #[derive(Deserialize)] + pub struct Product { + pub id: String, + pub category: String, + pub name: String, + pub description: String, + pub disclosure: String, + pub skus: Vec, + pub currency_codes: Vec, + pub countries: Vec, + pub images: Vec, + } - self.make_trolley_request::( - Method::POST, - &format!("/v1/batches/{}/start-processing", res.batch.id), - None, - ) - .await?; + #[derive(Deserialize)] + pub struct TremendousResponse { + pub products: Vec, + } - let payment_id = res.batch.payments.payments.into_iter().next().map(|x| x.id); + let response = queue + .make_tremendous_request::<(), TremendousResponse>(Method::GET, "products", None) + .await?; + + for product in response.products { + const BLACKLISTED_IDS: &[&str] = &[ + // physical visa + "A2J05SWPI2QG", + // crypto + "1UOOSHUUYTAM", + "5EVJN47HPDFT", + "NI9M4EVAVGFJ", + "VLY29QHTMNGT", + "7XU98H109Y3A", + "0CGEDFP2UIKV", + "PDYLQU0K073Y", + "HCS5Z7O2NV5G", + "IY1VMST1MOXS", + "VRPZLJ7HCA8X", + // bitcard (crypto) + "GWQQS5RM8IZS", + "896MYD4SGOGZ", + "PWLEN1VZGMZA", + "A2VRM96J5K5W", + "HV9ICIM3JT7P", + "K2KLSPVWC2Q4", + "HRBRQLLTDF95", + "UUBYLZVK7QAB", + "BH8W3XEDEOJN", + "7WGE043X1RYQ", + "2B13MHUZZVTF", + "JN6R44P86EYX", + "DA8H43GU84SO", + "QK2XAQHSDEH4", + "J7K1IQFS76DK", + "NL4JQ2G7UPRZ", + "OEFTMSBA5ELH", + "A3CQK6UHNV27", + ]; + const SUPPORTED_METHODS: &[&str] = + &["merchant_cards", "visa", "bank", "ach", "visa_card"]; + + if !SUPPORTED_METHODS.contains(&&*product.category) + || BLACKLISTED_IDS.contains(&&*product.id) + { + continue; + }; + + let method = PayoutMethod { + id: product.id, + type_: PayoutMethodType::Tremendous, + name: product.name.clone(), + supported_countries: product.countries.into_iter().map(|x| x.abbr).collect(), + image_url: product + .images + .into_iter() + .find(|x| x.type_ == ProductImageType::Card) + .map(|x| x.src), + interval: if product.skus.len() > 1 { + let mut values = product + .skus + .into_iter() + .map(|x| PayoutDecimal(x.min)) + .collect::>(); + values.sort_by(|a, b| a.0.cmp(&b.0)); + + PayoutInterval::Fixed { values } + } else if let Some(first) = product.skus.first() { + PayoutInterval::Standard { + min: first.min, + max: first.max, + } + } else { + PayoutInterval::Standard { + min: Decimal::ZERO, + max: Decimal::from(5_000), + } + }, + fee: if product.category == "ach" { + PayoutMethodFee { + percentage: Decimal::from(4) / Decimal::from(100), + min: Decimal::from(1) / Decimal::from(4), + max: None, + } + } else { + PayoutMethodFee { + percentage: Default::default(), + min: Default::default(), + max: None, + } + }, + }; - Ok((res.batch.id, payment_id)) - } + // we do not support interval gift cards with non US based currencies since we cannot do currency conversions properly + if let PayoutInterval::Fixed { .. } = method.interval { + if !product.currency_codes.contains(&"USD".to_string()) { + continue; + } + } - pub async fn register_recipient( - &self, - email: &str, - user: AccountUser, - ) -> Result { - #[derive(Deserialize)] - struct TrolleyRes { - recipient: Recipient, - } + methods.push(method); + } - #[derive(Deserialize)] - struct Recipient { - id: String, - } + const UPRANK_IDS: &[&str] = &["ET0ZVETV5ILN", "Q24BD9EZ332JT", "UIL1ZYJU5MKN"]; + const DOWNRANK_IDS: &[&str] = &["EIPF8Q00EMM1", "OU2MWXYWPNWQ"]; + + methods.sort_by(|a, b| { + let a_top = UPRANK_IDS.contains(&&*a.id); + let a_bottom = DOWNRANK_IDS.contains(&&*a.id); + let b_top = UPRANK_IDS.contains(&&*b.id); + let b_bottom = DOWNRANK_IDS.contains(&&*b.id); + + match (a_top, a_bottom, b_top, b_bottom) { + (true, _, true, _) => a.name.cmp(&b.name), // Both in top_priority: sort alphabetically + (_, true, _, true) => a.name.cmp(&b.name), // Both in bottom_priority: sort alphabetically + (true, _, _, _) => std::cmp::Ordering::Less, // a in top_priority: a comes first + (_, _, true, _) => std::cmp::Ordering::Greater, // b in top_priority: b comes first + (_, true, _, _) => std::cmp::Ordering::Greater, // a in bottom_priority: b comes first + (_, _, _, true) => std::cmp::Ordering::Less, // b in bottom_priority: a comes first + (_, _, _, _) => a.name.cmp(&b.name), // Neither in priority: sort alphabetically + } + }); + + { + let paypal_us = PayoutMethod { + id: "paypal_us".to_string(), + type_: PayoutMethodType::PayPal, + name: "PayPal".to_string(), + supported_countries: vec!["US".to_string()], + image_url: None, + interval: PayoutInterval::Standard { + min: Decimal::from(1) / Decimal::from(4), + max: Decimal::from(100_000), + }, + fee: PayoutMethodFee { + percentage: Decimal::from(2) / Decimal::from(100), + min: Decimal::from(1) / Decimal::from(4), + max: Some(Decimal::from(1)), + }, + }; - let id = self - .make_trolley_request::<_, TrolleyRes>( - Method::POST, - "/v1/recipients/", - Some(match user { - AccountUser::Business { name } => json!({ - "type": "business", - "email": email, - "name": name, - }), - AccountUser::Individual { first, last } => json!({ - "type": "individual", - "firstName": first, - "lastName": last, - "email": email, - }), - }), - ) - .await?; + let mut venmo = paypal_us.clone(); + venmo.id = "venmo".to_string(); + venmo.name = "Venmo".to_string(); + venmo.type_ = PayoutMethodType::Venmo; - Ok(id.recipient.id) - } + methods.insert(0, paypal_us); + methods.insert(1, venmo) + } - // lhs minimum, rhs estimate - pub async fn get_estimated_fees( - &self, - id: &str, - amount: Decimal, - ) -> Result { - #[derive(Deserialize)] - struct TrolleyRes { - recipient: Recipient, - } + methods.insert( + 2, + PayoutMethod { + id: "paypal_in".to_string(), + type_: PayoutMethodType::PayPal, + name: "PayPal".to_string(), + supported_countries: rust_iso3166::ALL + .iter() + .filter(|x| x.alpha2 != "US") + .map(|x| x.alpha2.to_string()) + .collect(), + image_url: None, + interval: PayoutInterval::Standard { + min: Decimal::from(1) / Decimal::from(4), + max: Decimal::from(100_000), + }, + fee: PayoutMethodFee { + percentage: Decimal::from(2) / Decimal::from(100), + min: Decimal::ZERO, + max: Some(Decimal::from(20)), + }, + }, + ); - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct Recipient { - route_minimum: Option, - estimated_fees: Option, - address: RecipientAddress, - payout_method: String, - } + let new_options = PayoutMethods { + options: methods, + expires: Utc::now() + Duration::hours(6), + }; - #[derive(Deserialize)] - struct RecipientAddress { - country: String, + *options = Some(new_options.clone()); + + Ok(new_options) } - let id = self - .make_trolley_request::( - Method::GET, - &format!("/v1/recipients/{id}"), - None, - ) - .await?; - - if &id.recipient.payout_method == "paypal" { - // based on https://www.paypal.com/us/webapps/mpp/merchant-fees. see paypal payouts section - let fee = if &id.recipient.address.country == "US" { - std::cmp::min( - std::cmp::max( - Decimal::ONE / Decimal::from(4), - (Decimal::from(2) / Decimal::ONE_HUNDRED) * amount, - ), - Decimal::from(1), - ) + let read = self.payout_options.read().await; + let options = if let Some(options) = read.as_ref() { + if options.expires < Utc::now() { + drop(read); + refresh_payout_methods(self).await? } else { - std::cmp::min( - (Decimal::from(2) / Decimal::ONE_HUNDRED) * amount, - Decimal::from(20), - ) - }; - - Ok(PaymentInfo { - country: id.recipient.address.country, - payout_method: id.recipient.payout_method, - route_minimum: fee, - estimated_fees: fee, - deduct_fees: fee, - }) - } else if &id.recipient.payout_method == "venmo" { - let venmo_fee = Decimal::ONE / Decimal::from(4); - - Ok(PaymentInfo { - country: id.recipient.address.country, - payout_method: id.recipient.payout_method, - route_minimum: id.recipient.route_minimum.unwrap_or(Decimal::ZERO) + venmo_fee, - estimated_fees: id.recipient.estimated_fees.unwrap_or(Decimal::ZERO) + venmo_fee, - deduct_fees: venmo_fee, - }) + options.clone() + } } else { - Ok(PaymentInfo { - country: id.recipient.address.country, - payout_method: id.recipient.payout_method, - route_minimum: id.recipient.route_minimum.unwrap_or(Decimal::ZERO), - estimated_fees: id.recipient.estimated_fees.unwrap_or(Decimal::ZERO), - deduct_fees: Decimal::ZERO, - }) - } - } + drop(read); + refresh_payout_methods(self).await? + }; - pub async fn update_recipient_email(&self, id: &str, email: &str) -> Result<(), ApiError> { - self.make_trolley_request::<_, Value>( - Method::PATCH, - &format!("/v1/recipients/{}", id), - Some(json!({ - "email": email, - })), - ) - .await?; + Ok(options.options) + } - Ok(()) + pub fn lock_user_payouts(&self, user_id: UserId) -> Arc> { + self.payouts_locks + .entry(user_id) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() } } diff --git a/src/routes/v2/admin.rs b/src/routes/v2/admin.rs index be914e91..c3eadbcd 100644 --- a/src/routes/v2/admin.rs +++ b/src/routes/v2/admin.rs @@ -1,10 +1,8 @@ use crate::auth::validate::get_user_record_from_bearer_token; -use crate::database::models::User; use crate::database::redis::RedisPool; use crate::models::analytics::Download; use crate::models::ids::ProjectId; use crate::models::pats::Scopes; -use crate::models::users::{PayoutStatus, RecipientStatus}; use crate::queue::analytics::AnalyticsQueue; use crate::queue::maxmind::MaxMindIndexer; use crate::queue::session::AuthQueue; @@ -12,12 +10,8 @@ use crate::routes::ApiError; use crate::search::SearchConfig; use crate::util::date::get_current_tenths_of_ms; use crate::util::guards::admin_key_guard; -use crate::util::routes::read_from_payload; use actix_web::{patch, post, web, HttpRequest, HttpResponse}; -use hex::ToHex; -use hmac::{Hmac, Mac, NewMac}; use serde::Deserialize; -use sha2::Sha256; use sqlx::PgPool; use std::collections::HashMap; use std::net::Ipv4Addr; @@ -28,7 +22,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("admin") .service(count_download) - .service(trolley_webhook) .service(force_reindex), ); } @@ -143,174 +136,6 @@ pub async fn count_download( Ok(HttpResponse::NoContent().body("")) } -#[derive(Deserialize)] -pub struct TrolleyWebhook { - model: String, - action: String, - body: HashMap, -} - -#[post("/_trolley")] -#[allow(clippy::too_many_arguments)] -pub async fn trolley_webhook( - req: HttpRequest, - pool: web::Data, - redis: web::Data, - mut payload: web::Payload, -) -> Result { - if let Some(signature) = req.headers().get("X-PaymentRails-Signature") { - let payload = read_from_payload( - &mut payload, - 1 << 20, - "Webhook payload exceeds the maximum of 1MiB.", - ) - .await?; - - let mut signature = signature.to_str().ok().unwrap_or_default().split(','); - let timestamp = signature - .next() - .and_then(|x| x.split('=').nth(1)) - .unwrap_or_default(); - let v1 = signature - .next() - .and_then(|x| x.split('=').nth(1)) - .unwrap_or_default(); - - let mut mac: Hmac = - Hmac::new_from_slice(dotenvy::var("TROLLEY_WEBHOOK_SIGNATURE")?.as_bytes()) - .map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?; - mac.update(timestamp.as_bytes()); - mac.update(&payload); - let request_signature = mac.finalize().into_bytes().encode_hex::(); - - if &*request_signature == v1 { - let webhook = serde_json::from_slice::(&payload)?; - - if webhook.model == "recipient" { - #[derive(Deserialize)] - struct Recipient { - pub id: String, - pub email: Option, - pub status: Option, - } - - if let Some(body) = webhook.body.get("recipient") { - if let Ok(recipient) = serde_json::from_value::(body.clone()) { - let value = sqlx::query!( - "SELECT id FROM users WHERE trolley_id = $1", - recipient.id - ) - .fetch_optional(&**pool) - .await?; - - if let Some(user) = value { - let user = User::get_id( - crate::database::models::UserId(user.id), - &**pool, - &redis, - ) - .await?; - - if let Some(user) = user { - let mut transaction = pool.begin().await?; - - if webhook.action == "deleted" { - sqlx::query!( - " - UPDATE users - SET trolley_account_status = NULL, trolley_id = NULL - WHERE id = $1 - ", - user.id.0 - ) - .execute(&mut *transaction) - .await?; - } else { - sqlx::query!( - " - UPDATE users - SET email = $1, email_verified = $2, trolley_account_status = $3 - WHERE id = $4 - ", - recipient.email.clone(), - user.email_verified && recipient.email == user.email, - recipient.status.map(|x| x.as_str()), - user.id.0 - ) - .execute(&mut *transaction).await?; - } - - transaction.commit().await?; - User::clear_caches(&[(user.id, None)], &redis).await?; - } - } - } - } - } - - if webhook.model == "payment" { - #[derive(Deserialize)] - struct Payment { - pub id: String, - pub status: PayoutStatus, - } - - if let Some(body) = webhook.body.get("payment") { - if let Ok(payment) = serde_json::from_value::(body.clone()) { - let value = sqlx::query!( - "SELECT id, amount, user_id, status FROM historical_payouts WHERE payment_id = $1", - payment.id - ) - .fetch_optional(&**pool) - .await?; - - if let Some(payout) = value { - let mut transaction = pool.begin().await?; - - if payment.status.is_failed() - && !PayoutStatus::from_string(&payout.status).is_failed() - { - sqlx::query!( - " - UPDATE users - SET balance = balance + $1 - WHERE id = $2 - ", - payout.amount, - payout.user_id, - ) - .execute(&mut *transaction) - .await?; - } - - sqlx::query!( - " - UPDATE historical_payouts - SET status = $1 - WHERE payment_id = $2 - ", - payment.status.as_str(), - payment.id, - ) - .execute(&mut *transaction) - .await?; - - transaction.commit().await?; - User::clear_caches( - &[(crate::database::models::UserId(payout.user_id), None)], - &redis, - ) - .await?; - } - } - } - } - } - } - - Ok(HttpResponse::NoContent().finish()) -} - #[post("/_force_reindex", guard = "admin_key_guard")] pub async fn force_reindex( pool: web::Data, diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index f17c1cd1..16bf7f91 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -38,7 +38,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(delete_gallery_item) .service(project_follow) .service(project_unfollow) - .service(project_schedule) .service(super::teams::team_members_get_project) .service( web::scope("{project_id}") @@ -526,36 +525,6 @@ pub async fn projects_edit( .await } -#[derive(Deserialize)] -pub struct SchedulingData { - pub time: DateTime, - pub requested_status: ProjectStatus, -} - -#[post("{id}/schedule")] -pub async fn project_schedule( - req: HttpRequest, - info: web::Path<(String,)>, - pool: web::Data, - redis: web::Data, - session_queue: web::Data, - scheduling_data: web::Json, -) -> Result { - let scheduling_data = scheduling_data.into_inner(); - v3::projects::project_schedule( - req, - info, - pool, - redis, - session_queue, - web::Json(v3::projects::SchedulingData { - time: scheduling_data.time, - requested_status: scheduling_data.requested_status, - }), - ) - .await -} - #[derive(Serialize, Deserialize)] pub struct Extension { pub ext: String, diff --git a/src/routes/v2/teams.rs b/src/routes/v2/teams.rs index 87b2df16..fca310d3 100644 --- a/src/routes/v2/teams.rs +++ b/src/routes/v2/teams.rs @@ -112,6 +112,7 @@ pub struct NewTeamMember { #[serde(default)] pub organization_permissions: Option, #[serde(default)] + #[serde(with = "rust_decimal::serde::float")] pub payouts_split: Decimal, #[serde(default = "default_ordering")] pub ordering: i64, diff --git a/src/routes/v2/users.rs b/src/routes/v2/users.rs index 830d81a3..7c8c2b6a 100644 --- a/src/routes/v2/users.rs +++ b/src/routes/v2/users.rs @@ -3,17 +3,14 @@ use crate::file_hosting::FileHost; use crate::models::projects::Project; use crate::models::users::{Badges, Role}; use crate::models::v2::projects::LegacyProject; -use crate::queue::payouts::PayoutsQueue; use crate::queue::session::AuthQueue; use crate::routes::{v2_reroute, v3, ApiError}; -use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; +use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse}; use lazy_static::lazy_static; use regex::Regex; -use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::sync::Arc; -use tokio::sync::Mutex; use validator::Validate; pub fn config(cfg: &mut web::ServiceConfig) { @@ -30,10 +27,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(user_edit) .service(user_icon_edit) .service(user_notifications) - .service(user_follows) - .service(user_payouts) - .service(user_payouts_fees) - .service(user_payouts_request), + .service(user_follows), ); } @@ -158,6 +152,7 @@ pub async fn user_edit( bio: new_user.bio, role: new_user.role, badges: new_user.badges, + venmo_handle: None, }), pool, redis, @@ -250,72 +245,3 @@ pub async fn user_notifications( ) -> Result { v3::users::user_notifications(req, info, pool, redis, session_queue).await } - -#[get("{id}/payouts")] -pub async fn user_payouts( - req: HttpRequest, - info: web::Path<(String,)>, - pool: web::Data, - redis: web::Data, - session_queue: web::Data, -) -> Result { - v3::users::user_payouts(req, info, pool, redis, session_queue).await -} - -#[derive(Deserialize)] -pub struct FeeEstimateAmount { - amount: Decimal, -} - -#[get("{id}/payouts_fees")] -pub async fn user_payouts_fees( - req: HttpRequest, - info: web::Path<(String,)>, - web::Query(amount): web::Query, - pool: web::Data, - redis: web::Data, - session_queue: web::Data, - payouts_queue: web::Data>, -) -> Result { - v3::users::user_payouts_fees( - req, - info, - web::Query(v3::users::FeeEstimateAmount { - amount: amount.amount, - }), - pool, - redis, - session_queue, - payouts_queue, - ) - .await -} - -#[derive(Deserialize)] -pub struct PayoutData { - amount: Decimal, -} - -#[post("{id}/payouts")] -pub async fn user_payouts_request( - req: HttpRequest, - info: web::Path<(String,)>, - pool: web::Data, - data: web::Json, - payouts_queue: web::Data>, - redis: web::Data, - session_queue: web::Data, -) -> Result { - v3::users::user_payouts_request( - req, - info, - pool, - web::Json(v3::users::PayoutData { - amount: data.amount, - }), - payouts_queue, - redis, - session_queue, - ) - .await -} diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index 255ecfc2..5236bf0c 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -8,8 +8,7 @@ use crate::models::projects::{Dependency, FileType, Version, VersionStatus, Vers use crate::models::v2::projects::LegacyVersion; use crate::queue::session::AuthQueue; use crate::routes::{v2_reroute, v3}; -use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; -use chrono::{DateTime, Utc}; +use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use validator::Validate; @@ -23,7 +22,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(version_get) .service(version_delete) .service(version_edit) - .service(version_schedule) .service(super::version_creation::upload_file_to_version), ); } @@ -254,35 +252,6 @@ pub async fn version_edit( Ok(response) } -#[derive(Deserialize)] -pub struct SchedulingData { - pub time: DateTime, - pub requested_status: VersionStatus, -} - -#[post("{id}/schedule")] -pub async fn version_schedule( - req: HttpRequest, - info: web::Path<(models::ids::VersionId,)>, - pool: web::Data, - redis: web::Data, - scheduling_data: web::Json, - session_queue: web::Data, -) -> Result { - v3::versions::version_schedule( - req, - info, - pool, - redis, - web::Json(v3::versions::SchedulingData { - time: scheduling_data.time, - requested_status: scheduling_data.requested_status, - }), - session_queue, - ) - .await -} - #[delete("{version_id}")] pub async fn version_delete( req: HttpRequest, diff --git a/src/routes/v3/admin.rs b/src/routes/v3/admin.rs index be914e91..c3eadbcd 100644 --- a/src/routes/v3/admin.rs +++ b/src/routes/v3/admin.rs @@ -1,10 +1,8 @@ use crate::auth::validate::get_user_record_from_bearer_token; -use crate::database::models::User; use crate::database::redis::RedisPool; use crate::models::analytics::Download; use crate::models::ids::ProjectId; use crate::models::pats::Scopes; -use crate::models::users::{PayoutStatus, RecipientStatus}; use crate::queue::analytics::AnalyticsQueue; use crate::queue::maxmind::MaxMindIndexer; use crate::queue::session::AuthQueue; @@ -12,12 +10,8 @@ use crate::routes::ApiError; use crate::search::SearchConfig; use crate::util::date::get_current_tenths_of_ms; use crate::util::guards::admin_key_guard; -use crate::util::routes::read_from_payload; use actix_web::{patch, post, web, HttpRequest, HttpResponse}; -use hex::ToHex; -use hmac::{Hmac, Mac, NewMac}; use serde::Deserialize; -use sha2::Sha256; use sqlx::PgPool; use std::collections::HashMap; use std::net::Ipv4Addr; @@ -28,7 +22,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("admin") .service(count_download) - .service(trolley_webhook) .service(force_reindex), ); } @@ -143,174 +136,6 @@ pub async fn count_download( Ok(HttpResponse::NoContent().body("")) } -#[derive(Deserialize)] -pub struct TrolleyWebhook { - model: String, - action: String, - body: HashMap, -} - -#[post("/_trolley")] -#[allow(clippy::too_many_arguments)] -pub async fn trolley_webhook( - req: HttpRequest, - pool: web::Data, - redis: web::Data, - mut payload: web::Payload, -) -> Result { - if let Some(signature) = req.headers().get("X-PaymentRails-Signature") { - let payload = read_from_payload( - &mut payload, - 1 << 20, - "Webhook payload exceeds the maximum of 1MiB.", - ) - .await?; - - let mut signature = signature.to_str().ok().unwrap_or_default().split(','); - let timestamp = signature - .next() - .and_then(|x| x.split('=').nth(1)) - .unwrap_or_default(); - let v1 = signature - .next() - .and_then(|x| x.split('=').nth(1)) - .unwrap_or_default(); - - let mut mac: Hmac = - Hmac::new_from_slice(dotenvy::var("TROLLEY_WEBHOOK_SIGNATURE")?.as_bytes()) - .map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?; - mac.update(timestamp.as_bytes()); - mac.update(&payload); - let request_signature = mac.finalize().into_bytes().encode_hex::(); - - if &*request_signature == v1 { - let webhook = serde_json::from_slice::(&payload)?; - - if webhook.model == "recipient" { - #[derive(Deserialize)] - struct Recipient { - pub id: String, - pub email: Option, - pub status: Option, - } - - if let Some(body) = webhook.body.get("recipient") { - if let Ok(recipient) = serde_json::from_value::(body.clone()) { - let value = sqlx::query!( - "SELECT id FROM users WHERE trolley_id = $1", - recipient.id - ) - .fetch_optional(&**pool) - .await?; - - if let Some(user) = value { - let user = User::get_id( - crate::database::models::UserId(user.id), - &**pool, - &redis, - ) - .await?; - - if let Some(user) = user { - let mut transaction = pool.begin().await?; - - if webhook.action == "deleted" { - sqlx::query!( - " - UPDATE users - SET trolley_account_status = NULL, trolley_id = NULL - WHERE id = $1 - ", - user.id.0 - ) - .execute(&mut *transaction) - .await?; - } else { - sqlx::query!( - " - UPDATE users - SET email = $1, email_verified = $2, trolley_account_status = $3 - WHERE id = $4 - ", - recipient.email.clone(), - user.email_verified && recipient.email == user.email, - recipient.status.map(|x| x.as_str()), - user.id.0 - ) - .execute(&mut *transaction).await?; - } - - transaction.commit().await?; - User::clear_caches(&[(user.id, None)], &redis).await?; - } - } - } - } - } - - if webhook.model == "payment" { - #[derive(Deserialize)] - struct Payment { - pub id: String, - pub status: PayoutStatus, - } - - if let Some(body) = webhook.body.get("payment") { - if let Ok(payment) = serde_json::from_value::(body.clone()) { - let value = sqlx::query!( - "SELECT id, amount, user_id, status FROM historical_payouts WHERE payment_id = $1", - payment.id - ) - .fetch_optional(&**pool) - .await?; - - if let Some(payout) = value { - let mut transaction = pool.begin().await?; - - if payment.status.is_failed() - && !PayoutStatus::from_string(&payout.status).is_failed() - { - sqlx::query!( - " - UPDATE users - SET balance = balance + $1 - WHERE id = $2 - ", - payout.amount, - payout.user_id, - ) - .execute(&mut *transaction) - .await?; - } - - sqlx::query!( - " - UPDATE historical_payouts - SET status = $1 - WHERE payment_id = $2 - ", - payment.status.as_str(), - payment.id, - ) - .execute(&mut *transaction) - .await?; - - transaction.commit().await?; - User::clear_caches( - &[(crate::database::models::UserId(payout.user_id), None)], - &redis, - ) - .await?; - } - } - } - } - } - } - - Ok(HttpResponse::NoContent().finish()) -} - #[post("/_force_reindex", guard = "admin_key_guard")] pub async fn force_reindex( pool: web::Data, diff --git a/src/routes/v3/mod.rs b/src/routes/v3/mod.rs index 7616d095..7dd63ccf 100644 --- a/src/routes/v3/mod.rs +++ b/src/routes/v3/mod.rs @@ -10,6 +10,7 @@ pub mod images; pub mod moderation; pub mod notifications; pub mod organizations; +pub mod payouts; pub mod project_creation; pub mod projects; pub mod reports; @@ -49,6 +50,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(threads::config) .configure(users::config) .configure(version_file::config) + .configure(payouts::config) .configure(versions::config), ); } diff --git a/src/routes/v3/payouts.rs b/src/routes/v3/payouts.rs new file mode 100644 index 00000000..008a8e21 --- /dev/null +++ b/src/routes/v3/payouts.rs @@ -0,0 +1,745 @@ +use crate::auth::validate::get_user_record_from_bearer_token; +use crate::auth::{get_user_from_headers, AuthenticationError}; +use crate::database::models::generate_payout_id; +use crate::database::redis::RedisPool; +use crate::models::ids::PayoutId; +use crate::models::pats::Scopes; +use crate::models::payouts::{PayoutMethodType, PayoutStatus}; +use crate::queue::payouts::PayoutsQueue; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; +use chrono::Utc; +use hex::ToHex; +use hmac::{Hmac, Mac, NewMac}; +use hyper::Method; +use rust_decimal::Decimal; +use serde::Deserialize; +use serde_json::json; +use sha2::Sha256; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("payout") + .service(paypal_webhook) + .service(tremendous_webhook) + .service(user_payouts) + .service(create_payout) + .service(cancel_payout) + .service(payment_methods), + ); +} + +#[post("_paypal")] +pub async fn paypal_webhook( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + payouts: web::Data, + body: String, +) -> Result { + let auth_algo = req + .headers() + .get("PAYPAL-AUTH-ALGO") + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| ApiError::InvalidInput("missing auth algo".to_string()))?; + let cert_url = req + .headers() + .get("PAYPAL-CERT-URL") + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| ApiError::InvalidInput("missing cert url".to_string()))?; + let transmission_id = req + .headers() + .get("PAYPAL-TRANSMISSION-ID") + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| ApiError::InvalidInput("missing transmission ID".to_string()))?; + let transmission_sig = req + .headers() + .get("PAYPAL-TRANSMISSION-SIG") + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| ApiError::InvalidInput("missing transmission sig".to_string()))?; + let transmission_time = req + .headers() + .get("PAYPAL-TRANSMISSION-TIME") + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| ApiError::InvalidInput("missing transmission time".to_string()))?; + + #[derive(Deserialize)] + struct WebHookResponse { + verification_status: String, + } + + let webhook_res = payouts + .make_paypal_request::<(), WebHookResponse>( + Method::POST, + "notifications/verify-webhook-signature", + None, + // This is needed as serde re-orders fields, which causes the validation to fail for PayPal. + Some(format!( + "{{ + \"auth_algo\": \"{auth_algo}\", + \"cert_url\": \"{cert_url}\", + \"transmission_id\": \"{transmission_id}\", + \"transmission_sig\": \"{transmission_sig}\", + \"transmission_time\": \"{transmission_time}\", + \"webhook_id\": \"{}\", + \"webhook_event\": {body} + }}", + dotenvy::var("PAYPAL_WEBHOOK_ID")? + )), + None, + ) + .await?; + + if &webhook_res.verification_status != "SUCCESS" { + return Err(ApiError::InvalidInput( + "Invalid webhook signature".to_string(), + )); + } + + #[derive(Deserialize)] + struct PayPalResource { + pub payout_item_id: String, + } + + #[derive(Deserialize)] + struct PayPalWebhook { + pub event_type: String, + pub resource: PayPalResource, + } + + let webhook = serde_json::from_str::(&body)?; + + match &*webhook.event_type { + "PAYMENT.PAYOUTS-ITEM.BLOCKED" + | "PAYMENT.PAYOUTS-ITEM.DENIED" + | "PAYMENT.PAYOUTS-ITEM.REFUNDED" + | "PAYMENT.PAYOUTS-ITEM.RETURNED" + | "PAYMENT.PAYOUTS-ITEM.CANCELED" => { + let mut transaction = pool.begin().await?; + + let result = sqlx::query!( + "SELECT user_id, amount, fee FROM payouts WHERE platform_id = $1 AND status = $2", + webhook.resource.payout_item_id, + PayoutStatus::InTransit.as_str() + ) + .fetch_optional(&mut *transaction) + .await?; + + if let Some(result) = result { + let mtx = + payouts.lock_user_payouts(crate::models::ids::UserId(result.user_id as u64)); + let _guard = mtx.lock().await; + + sqlx::query!( + " + UPDATE users + SET balance = balance + $1 + WHERE id = $2 + ", + result.amount + result.fee.unwrap_or(Decimal::ZERO), + result.user_id + ) + .execute(&mut *transaction) + .await?; + + crate::database::models::user_item::User::clear_caches( + &[(crate::database::models::UserId(result.user_id), None)], + &redis, + ) + .await?; + + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + if &*webhook.event_type == "PAYMENT.PAYOUTS-ITEM.CANCELED" { + PayoutStatus::Cancelled + } else { + PayoutStatus::Failed + } + .as_str(), + webhook.resource.payout_item_id + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + } + } + "PAYMENT.PAYOUTS-ITEM.SUCCEEDED" => { + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + PayoutStatus::Success.as_str(), + webhook.resource.payout_item_id + ) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + } + _ => {} + } + + Ok(HttpResponse::NoContent().finish()) +} + +#[post("_tremendous")] +pub async fn tremendous_webhook( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + payouts: web::Data, + body: String, +) -> Result { + let signature = req + .headers() + .get("Tremendous-Webhook-Signature") + .and_then(|x| x.to_str().ok()) + .and_then(|x| x.split('=').next_back()) + .ok_or_else(|| ApiError::InvalidInput("missing webhook signature".to_string()))?; + + let mut mac: Hmac = + Hmac::new_from_slice(dotenvy::var("TREMENDOUS_PRIVATE_KEY")?.as_bytes()) + .map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?; + mac.update(body.as_bytes()); + let request_signature = mac.finalize().into_bytes().encode_hex::(); + + if &*request_signature != signature { + return Err(ApiError::InvalidInput( + "Invalid webhook signature".to_string(), + )); + } + + #[derive(Deserialize)] + pub struct TremendousResource { + pub id: String, + } + + #[derive(Deserialize)] + struct TremendousPayload { + pub resource: TremendousResource, + } + + #[derive(Deserialize)] + struct TremendousWebhook { + pub event: String, + pub payload: TremendousPayload, + } + + let webhook = serde_json::from_str::(&body)?; + + match &*webhook.event { + "REWARDS.CANCELED" | "REWARDS.DELIVERY.FAILED" => { + let mut transaction = pool.begin().await?; + + let result = sqlx::query!( + "SELECT user_id, amount, fee FROM payouts WHERE platform_id = $1 AND status = $2", + webhook.payload.resource.id, + PayoutStatus::InTransit.as_str() + ) + .fetch_optional(&mut *transaction) + .await?; + + if let Some(result) = result { + let mtx = + payouts.lock_user_payouts(crate::models::ids::UserId(result.user_id as u64)); + let _guard = mtx.lock().await; + + sqlx::query!( + " + UPDATE users + SET balance = balance + $1 + WHERE id = $2 + ", + result.amount + result.fee.unwrap_or(Decimal::ZERO), + result.user_id + ) + .execute(&mut *transaction) + .await?; + + crate::database::models::user_item::User::clear_caches( + &[(crate::database::models::UserId(result.user_id), None)], + &redis, + ) + .await?; + + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + if &*webhook.event == "REWARDS.CANCELED" { + PayoutStatus::Cancelled + } else { + PayoutStatus::Failed + } + .as_str(), + webhook.payload.resource.id + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + } + } + "REWARDS.DELIVERY.SUCCEEDED" => { + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + PayoutStatus::Success.as_str(), + webhook.payload.resource.id + ) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + } + _ => {} + } + + Ok(HttpResponse::NoContent().finish()) +} + +#[get("")] +pub async fn user_payouts( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PAYOUTS_READ]), + ) + .await? + .1; + + let payout_ids = + crate::database::models::payout_item::Payout::get_all_for_user(user.id.into(), &**pool) + .await?; + let payouts = + crate::database::models::payout_item::Payout::get_many(&payout_ids, &**pool).await?; + + Ok(HttpResponse::Ok().json( + payouts + .into_iter() + .map(crate::models::payouts::Payout::from) + .collect::>(), + )) +} + +#[derive(Deserialize)] +pub struct Withdrawal { + #[serde(with = "rust_decimal::serde::float")] + amount: Decimal, + method: PayoutMethodType, + method_id: String, +} + +#[post("")] +pub async fn create_payout( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + body: web::Json, + session_queue: web::Data, + payouts_queue: web::Data, +) -> Result { + let (scopes, user) = + get_user_record_from_bearer_token(&req, None, &**pool, &redis, &session_queue) + .await? + .ok_or_else(|| ApiError::Authentication(AuthenticationError::InvalidCredentials))?; + + if !scopes.contains(Scopes::PAYOUTS_WRITE) { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + + let mtx = payouts_queue.lock_user_payouts(user.id.into()); + let _guard = mtx.lock().await; + + if user.balance < body.amount || body.amount < Decimal::ZERO { + return Err(ApiError::InvalidInput( + "You do not have enough funds to make this payout!".to_string(), + )); + } + + let payout_method = payouts_queue + .get_payout_methods() + .await? + .into_iter() + .find(|x| x.id == body.method_id) + .ok_or_else(|| ApiError::InvalidInput("Invalid payment method specified!".to_string()))?; + + let fee = std::cmp::min( + std::cmp::max( + payout_method.fee.min, + payout_method.fee.percentage * body.amount, + ), + payout_method.fee.max.unwrap_or(Decimal::MAX), + ); + + let transfer = (body.amount - fee).round_dp(2); + if transfer <= Decimal::ZERO { + return Err(ApiError::InvalidInput( + "You need to withdraw more to cover the fee!".to_string(), + )); + } + + let mut transaction = pool.begin().await?; + let payout_id = generate_payout_id(&mut transaction).await?; + + let payout_item = match body.method { + PayoutMethodType::Venmo | PayoutMethodType::PayPal => { + let (wallet, wallet_type, address, display_address) = + if body.method == PayoutMethodType::Venmo { + if let Some(venmo) = user.venmo_handle { + ("Venmo", "user_handle", venmo.clone(), venmo) + } else { + return Err(ApiError::InvalidInput( + "Venmo address has not been set for account!".to_string(), + )); + } + } else if let Some(paypal_id) = user.paypal_id { + if let Some(paypal_country) = user.paypal_country { + if &*paypal_country == "US" && &*body.method_id != "paypal_us" { + return Err(ApiError::InvalidInput( + "Please use the US PayPal transfer option!".to_string(), + )); + } else if &*paypal_country != "US" && &*body.method_id == "paypal_us" { + return Err(ApiError::InvalidInput( + "Please use the International PayPal transfer option!".to_string(), + )); + } + + ( + "PayPal", + "paypal_id", + paypal_id.clone(), + user.paypal_email.unwrap_or(paypal_id), + ) + } else { + return Err(ApiError::InvalidInput( + "Please re-link your PayPal account!".to_string(), + )); + } + } else { + return Err(ApiError::InvalidInput( + "You have not linked a PayPal account!".to_string(), + )); + }; + + #[derive(Deserialize)] + struct PayPalLink { + href: String, + } + + #[derive(Deserialize)] + struct PayoutsResponse { + pub links: Vec, + } + + let mut payout_item = crate::database::models::payout_item::Payout { + id: payout_id, + user_id: user.id, + created: Utc::now(), + status: PayoutStatus::InTransit, + amount: transfer, + fee: Some(fee), + method: Some(body.method), + method_address: Some(display_address), + platform_id: None, + }; + + let res: PayoutsResponse = payouts_queue.make_paypal_request( + Method::POST, + "payments/payouts", + Some( + json! ({ + "sender_batch_header": { + "sender_batch_id": format!("{}-payouts", Utc::now().to_rfc3339()), + "email_subject": "You have received a payment from Modrinth!", + "email_message": "Thank you for creating projects on Modrinth. Please claim this payment within 30 days.", + }, + "items": [{ + "amount": { + "currency": "USD", + "value": transfer.to_string() + }, + "receiver": address, + "note": "Payment from Modrinth creator monetization program", + "recipient_type": wallet_type, + "recipient_wallet": wallet, + "sender_item_id": crate::models::ids::PayoutId::from(payout_id), + }] + }) + ), + None, + None + ).await?; + + if let Some(link) = res.links.first() { + #[derive(Deserialize)] + struct PayoutItem { + pub payout_item_id: String, + } + + #[derive(Deserialize)] + struct PayoutData { + pub items: Vec, + } + + if let Ok(res) = payouts_queue + .make_paypal_request::<(), PayoutData>( + Method::GET, + &link.href, + None, + None, + Some(true), + ) + .await + { + if let Some(data) = res.items.first() { + payout_item.platform_id = Some(data.payout_item_id.clone()); + } + } + } + + payout_item + } + PayoutMethodType::Tremendous => { + if let Some(email) = user.email { + if user.email_verified { + let mut payout_item = crate::database::models::payout_item::Payout { + id: payout_id, + user_id: user.id, + created: Utc::now(), + status: PayoutStatus::InTransit, + amount: transfer, + fee: Some(fee), + method: Some(PayoutMethodType::Tremendous), + method_address: Some(email.clone()), + platform_id: None, + }; + + #[derive(Deserialize)] + struct Reward { + pub id: String, + } + + #[derive(Deserialize)] + struct Order { + pub rewards: Vec, + } + + #[derive(Deserialize)] + struct TremendousResponse { + pub order: Order, + } + + let res: TremendousResponse = payouts_queue + .make_tremendous_request( + Method::POST, + "orders", + Some(json! ({ + "payment": { + "funding_source_id": "BALANCE", + }, + "rewards": [{ + "value": { + "denomination": transfer + }, + "delivery": { + "method": "EMAIL" + }, + "recipient": { + "name": user.username, + "email": email + }, + "products": [ + &body.method_id, + ], + "campaign_id": dotenvy::var("TREMENDOUS_CAMPAIGN_ID")?, + }] + })), + ) + .await?; + + if let Some(reward) = res.order.rewards.first() { + payout_item.platform_id = Some(reward.id.clone()) + } + + payout_item + } else { + return Err(ApiError::InvalidInput( + "You must verify your account email to proceed!".to_string(), + )); + } + } else { + return Err(ApiError::InvalidInput( + "You must add an email to your account to proceed!".to_string(), + )); + } + } + PayoutMethodType::Unknown => { + return Err(ApiError::Payments( + "Invalid payment method specified!".to_string(), + )) + } + }; + + sqlx::query!( + " + UPDATE users + SET balance = balance - $1 + WHERE id = $2 + ", + body.amount, + user.id as crate::database::models::ids::UserId + ) + .execute(&mut *transaction) + .await?; + payout_item.insert(&mut transaction).await?; + crate::database::models::User::clear_caches(&[(user.id, None)], &redis).await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().finish()) +} + +#[delete("{id}")] +pub async fn cancel_payout( + info: web::Path<(PayoutId,)>, + req: HttpRequest, + pool: web::Data, + redis: web::Data, + payouts: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PAYOUTS_WRITE]), + ) + .await? + .1; + + let id = info.into_inner().0; + let payout = crate::database::models::payout_item::Payout::get(id.into(), &**pool).await?; + + if let Some(payout) = payout { + if payout.user_id != user.id.into() && !user.role.is_admin() { + return Ok(HttpResponse::NotFound().finish()); + } + + if let Some(platform_id) = payout.platform_id { + if let Some(method) = payout.method { + if payout.status != PayoutStatus::InTransit { + return Err(ApiError::InvalidInput( + "Payout cannot be cancelled!".to_string(), + )); + } + + match method { + PayoutMethodType::Venmo | PayoutMethodType::PayPal => { + payouts + .make_paypal_request::<(), ()>( + Method::POST, + &format!("payments/payouts-item/{}/cancel", platform_id), + None, + None, + None, + ) + .await?; + } + PayoutMethodType::Tremendous => { + payouts + .make_tremendous_request::<(), ()>( + Method::POST, + &format!("rewards/{}/cancel", platform_id), + None, + ) + .await?; + } + PayoutMethodType::Unknown => { + return Err(ApiError::InvalidInput( + "Payout cannot be cancelled!".to_string(), + )) + } + } + + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + PayoutStatus::Cancelling.as_str(), + platform_id + ) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + + Ok(HttpResponse::NoContent().finish()) + } else { + Err(ApiError::InvalidInput( + "Payout cannot be cancelled!".to_string(), + )) + } + } else { + Err(ApiError::InvalidInput( + "Payout cannot be cancelled!".to_string(), + )) + } + } else { + Ok(HttpResponse::NotFound().finish()) + } +} + +#[derive(Deserialize)] +pub struct MethodFilter { + pub country: Option, +} + +#[get("methods")] +pub async fn payment_methods( + payouts_queue: web::Data, + filter: web::Query, +) -> Result { + let methods = payouts_queue + .get_payout_methods() + .await? + .into_iter() + .filter(|x| { + let mut val = true; + + if let Some(country) = &filter.country { + val &= x.supported_countries.contains(country); + } + + val + }) + .collect::>(); + + Ok(HttpResponse::Ok().json(methods)) +} diff --git a/src/routes/v3/teams.rs b/src/routes/v3/teams.rs index 05f19c69..fef51474 100644 --- a/src/routes/v3/teams.rs +++ b/src/routes/v3/teams.rs @@ -378,6 +378,7 @@ pub struct NewTeamMember { #[serde(default)] pub organization_permissions: Option, #[serde(default)] + #[serde(with = "rust_decimal::serde::float")] pub payouts_split: Decimal, #[serde(default = "default_ordering")] pub ordering: i64, diff --git a/src/routes/v3/users.rs b/src/routes/v3/users.rs index 093cc1df..1afbc1b7 100644 --- a/src/routes/v3/users.rs +++ b/src/routes/v3/users.rs @@ -3,11 +3,8 @@ use std::{collections::HashMap, sync::Arc}; use actix_web::{web, HttpRequest, HttpResponse}; use lazy_static::lazy_static; use regex::Regex; -use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; -use serde_json::json; use sqlx::PgPool; -use tokio::sync::Mutex; use validator::Validate; use crate::{ @@ -20,9 +17,9 @@ use crate::{ notifications::Notification, pats::Scopes, projects::Project, - users::{Badges, Payout, PayoutStatus, RecipientStatus, Role, UserPayoutData}, + users::{Badges, Role}, }, - queue::{payouts::PayoutsQueue, session::AuthQueue}, + queue::session::AuthQueue, util::{routes::read_from_payload, validate::validation_errors_to_string}, }; @@ -43,9 +40,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { .route("{id}", web::delete().to(user_delete)) .route("{id}/follows", web::get().to(user_follows)) .route("{id}/notifications", web::get().to(user_notifications)) - .route("{id}/payouts", web::get().to(user_payouts)) - .route("{id}/payouts_fees", web::get().to(user_payouts_fees)) - .route("{id}/payouts", web::post().to(user_payouts_request)) .route("{id}/oauth_apps", web::get().to(get_user_clients)), ); } @@ -302,6 +296,8 @@ pub struct EditUser { pub bio: Option>, pub role: Option, pub badges: Option, + #[validate(length(max = 160))] + pub venmo_handle: Option, } pub async fn user_edit( @@ -312,7 +308,7 @@ pub async fn user_edit( redis: web::Data, session_queue: web::Data, ) -> Result { - let (_scopes, user) = get_user_from_headers( + let (scopes, user) = get_user_from_headers( &req, &**pool, &redis, @@ -432,6 +428,27 @@ pub async fn user_edit( .await?; } + if let Some(venmo_handle) = &new_user.venmo_handle { + if !scopes.contains(Scopes::PAYOUTS_WRITE) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the venmo handle of this user!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE users + SET venmo_handle = $1 + WHERE (id = $2) + ", + venmo_handle, + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + User::clear_caches(&[(id, Some(actual_user.username))], &redis).await?; transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) @@ -682,233 +699,3 @@ pub async fn user_notifications( Ok(HttpResponse::NotFound().body("")) } } - -pub async fn user_payouts( - req: HttpRequest, - info: web::Path<(String,)>, - pool: web::Data, - redis: web::Data, - session_queue: web::Data, -) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PAYOUTS_READ]), - ) - .await? - .1; - let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(id) = id_option.map(|x| x.id) { - if !user.role.is_admin() && user.id != id.into() { - return Err(ApiError::CustomAuthentication( - "You do not have permission to see the payouts of this user!".to_string(), - )); - } - - let (all_time, last_month, payouts) = futures::future::try_join3( - sqlx::query!( - " - SELECT SUM(pv.amount) amount - FROM payouts_values pv - WHERE pv.user_id = $1 - ", - id as crate::database::models::UserId - ) - .fetch_one(&**pool), - sqlx::query!( - " - SELECT SUM(pv.amount) amount - FROM payouts_values pv - WHERE pv.user_id = $1 AND created > NOW() - '1 month'::interval - ", - id as crate::database::models::UserId - ) - .fetch_one(&**pool), - sqlx::query!( - " - SELECT hp.created, hp.amount, hp.status - FROM historical_payouts hp - WHERE hp.user_id = $1 - ORDER BY hp.created DESC - ", - id as crate::database::models::UserId - ) - .fetch_many(&**pool) - .try_filter_map(|e| async { - Ok(e.right().map(|row| Payout { - created: row.created, - amount: row.amount, - status: PayoutStatus::from_string(&row.status), - })) - }) - .try_collect::>(), - ) - .await?; - - use futures::TryStreamExt; - - Ok(HttpResponse::Ok().json(json!({ - "all_time": all_time.amount, - "last_month": last_month.amount, - "payouts": payouts, - }))) - } else { - Ok(HttpResponse::NotFound().body("")) - } -} - -#[derive(Deserialize)] -pub struct FeeEstimateAmount { - pub amount: Decimal, -} - -pub async fn user_payouts_fees( - req: HttpRequest, - info: web::Path<(String,)>, - web::Query(amount): web::Query, - pool: web::Data, - redis: web::Data, - session_queue: web::Data, - payouts_queue: web::Data>, -) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PAYOUTS_READ]), - ) - .await? - .1; - let actual_user = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(actual_user) = actual_user { - if !user.role.is_admin() && user.id != actual_user.id.into() { - return Err(ApiError::CustomAuthentication( - "You do not have permission to request payouts of this user!".to_string(), - )); - } - - if let Some(UserPayoutData { - trolley_id: Some(trolley_id), - .. - }) = user.payout_data - { - let payouts = payouts_queue - .lock() - .await - .get_estimated_fees(&trolley_id, amount.amount) - .await?; - - Ok(HttpResponse::Ok().json(payouts)) - } else { - Err(ApiError::InvalidInput( - "You must set up your trolley account first!".to_string(), - )) - } - } else { - Ok(HttpResponse::NotFound().body("")) - } -} - -#[derive(Deserialize)] -pub struct PayoutData { - pub amount: Decimal, -} - -pub async fn user_payouts_request( - req: HttpRequest, - info: web::Path<(String,)>, - pool: web::Data, - data: web::Json, - payouts_queue: web::Data>, - redis: web::Data, - session_queue: web::Data, -) -> Result { - let mut payouts_queue = payouts_queue.lock().await; - - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Some(&[Scopes::PAYOUTS_WRITE]), - ) - .await? - .1; - let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; - - if let Some(id) = id_option.map(|x| x.id) { - if !user.role.is_admin() && user.id != id.into() { - return Err(ApiError::CustomAuthentication( - "You do not have permission to request payouts of this user!".to_string(), - )); - } - - if let Some(UserPayoutData { - trolley_id: Some(trolley_id), - trolley_status: Some(trolley_status), - balance, - .. - }) = user.payout_data - { - if trolley_status == RecipientStatus::Active { - return if data.amount < balance { - let mut transaction = pool.begin().await?; - - let (batch_id, payment_id) = - payouts_queue.send_payout(&trolley_id, data.amount).await?; - - sqlx::query!( - " - INSERT INTO historical_payouts (user_id, amount, status, batch_id, payment_id) - VALUES ($1, $2, $3, $4, $5) - ", - id as crate::database::models::ids::UserId, - data.amount, - "processing", - batch_id, - payment_id, - ) - .execute(&mut *transaction) - .await?; - - sqlx::query!( - " - UPDATE users - SET balance = balance - $1 - WHERE id = $2 - ", - data.amount, - id as crate::database::models::ids::UserId - ) - .execute(&mut *transaction) - .await?; - - User::clear_caches(&[(id, None)], &redis).await?; - - transaction.commit().await?; - - Ok(HttpResponse::NoContent().body("")) - } else { - Err(ApiError::InvalidInput( - "You do not have enough funds to make this payout!".to_string(), - )) - }; - } else { - return Err(ApiError::InvalidInput( - "Please complete payout information via the trolley dashboard!".to_string(), - )); - } - } - - Err(ApiError::InvalidInput( - "You are not enrolled in the payouts program yet!".to_string(), - )) - } else { - Ok(HttpResponse::NotFound().body("")) - } -}