You've already forked AstralRinth
forked from didirus/AstralRinth
Payouts code (#765)
* push to rebase * finish most * finish most * Finish impl * Finish paypal * run prep * Fix comp err
This commit is contained in:
16
.env
16
.env
@@ -18,7 +18,7 @@ REDIS_URL=redis://localhost
|
|||||||
REDIS_MAX_CONNECTIONS=10000
|
REDIS_MAX_CONNECTIONS=10000
|
||||||
|
|
||||||
BIND_ADDR=127.0.0.1:8000
|
BIND_ADDR=127.0.0.1:8000
|
||||||
SELF_ADDR=http://localhost:8000
|
SELF_ADDR=http://127.0.0.1:8000
|
||||||
|
|
||||||
MODERATION_DISCORD_WEBHOOK=
|
MODERATION_DISCORD_WEBHOOK=
|
||||||
PUBLIC_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"]'
|
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_ID=none
|
||||||
GITHUB_CLIENT_SECRET=none
|
GITHUB_CLIENT_SECRET=none
|
||||||
|
|
||||||
@@ -68,8 +64,18 @@ MICROSOFT_CLIENT_SECRET=none
|
|||||||
GOOGLE_CLIENT_ID=none
|
GOOGLE_CLIENT_ID=none
|
||||||
GOOGLE_CLIENT_SECRET=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
|
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
|
TURNSTILE_SECRET=none
|
||||||
|
|
||||||
SMTP_USERNAME=none
|
SMTP_USERNAME=none
|
||||||
|
|||||||
14
.sqlx/query-06bfc01bafa2db73eb049f268eb9f333aeeb23a048ee524b278fe184e2d3ae45.json
generated
Normal file
14
.sqlx/query-06bfc01bafa2db73eb049f268eb9f333aeeb23a048ee524b278fe184e2d3ae45.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
17
.sqlx/query-0d23c47e3f6803078016c4ae5d52c47b7a0d23ca747c753a5710b3eb3cf7c621.json
generated
Normal file
17
.sqlx/query-0d23c47e3f6803078016c4ae5d52c47b7a0d23ca747c753a5710b3eb3cf7c621.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
15
.sqlx/query-105c44658c58739b933ae3ef0504c66b7926390c9f30e09c635161544177762a.json
generated
Normal file
15
.sqlx/query-105c44658c58739b933ae3ef0504c66b7926390c9f30e09c635161544177762a.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
21
.sqlx/query-285c089b43bf0225ba03e279f7a227c3483bae818d077efdc54e588b858c8760.json
generated
Normal file
21
.sqlx/query-285c089b43bf0225ba03e279f7a227c3483bae818d077efdc54e588b858c8760.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -19,10 +19,14 @@
|
|||||||
"Int8",
|
"Int8",
|
||||||
"Varchar",
|
"Varchar",
|
||||||
"Bool",
|
"Bool",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
"Text"
|
"Text"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "2e5ddc7876d8041fec781893027f84b49b5794c85fa442296c35156d0a72464a"
|
"hash": "36c8a2fe704197539ee5010e94a03a48637ac9227d683e0c75eb2603ba156610"
|
||||||
}
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
15
.sqlx/query-3db83286f5aea07f399db451bfd6d90c9bb2bd94b6d8accb3d2e0906bb289798.json
generated
Normal file
15
.sqlx/query-3db83286f5aea07f399db451bfd6d90c9bb2bd94b6d8accb3d2e0906bb289798.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
14
.sqlx/query-4de9fdac1831320d744cb6edd0cfe6e1fd670348f0ef881f67d7aec1acd6571f.json
generated
Normal file
14
.sqlx/query-4de9fdac1831320d744cb6edd0cfe6e1fd670348f0ef881f67d7aec1acd6571f.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
15
.sqlx/query-5722c001ba705d5d6237037512081b0a006aca2fc6f1db71e5b47ec9d68a82b9.json
generated
Normal file
15
.sqlx/query-5722c001ba705d5d6237037512081b0a006aca2fc6f1db71e5b47ec9d68a82b9.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -100,12 +100,22 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 19,
|
"ordinal": 19,
|
||||||
"name": "trolley_id",
|
"name": "paypal_id",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 20,
|
"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"
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -136,8 +146,10 @@
|
|||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "faec0a606ccaeb3f21c81e60a1749640b929e97db40252118fb72610df64a457"
|
"hash": "5e7e85c8c1f4b4e600c51669b6591b5cc279bd7482893ec687e83ee22d00a3a0"
|
||||||
}
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
70
.sqlx/query-83f8d3fcc4ba1544f593abaf29c79157bc35e3fca79cc93f6512ca01acd8e5ce.json
generated
Normal file
70
.sqlx/query-83f8d3fcc4ba1544f593abaf29c79157bc35e3fca79cc93f6512ca01acd8e5ce.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
15
.sqlx/query-aab77a2364f08b81d3e01bf396da0ea5162f0c11465dfeb50eb17ca6c8bd337b.json
generated
Normal file
15
.sqlx/query-aab77a2364f08b81d3e01bf396da0ea5162f0c11465dfeb50eb17ca6c8bd337b.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
15
.sqlx/query-bbc96f470cb7819a5c8aa0725e630311cfa8da5b3b119b9ce791e8e978250c6e.json
generated
Normal file
15
.sqlx/query-bbc96f470cb7819a5c8aa0725e630311cfa8da5b3b119b9ce791e8e978250c6e.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT id FROM users WHERE trolley_id = $1",
|
"query": "SELECT id FROM users WHERE paypal_id = $1",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -18,5 +18,5 @@
|
|||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "04128dd06489004e0d0305bfd0f4ca5ee4b4a6b9f610de6e1b9ef9c8543cc025"
|
"hash": "c03c8c00fe93569d0f464b7a058a903d9b3bbd09fc7f70136c3c09e1c15a9062"
|
||||||
}
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"name": "id",
|
"name": "user_id",
|
||||||
"type_info": "Int8"
|
"type_info": "Int8"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -15,26 +15,21 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "user_id",
|
"name": "fee",
|
||||||
"type_info": "Int8"
|
"type_info": "Numeric"
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 3,
|
|
||||||
"name": "status",
|
|
||||||
"type_info": "Varchar"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
|
"Text",
|
||||||
"Text"
|
"Text"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
true
|
||||||
false
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "e5adaf219c52ec828b72bd89c6b86a475f73181abf180a024dfe05f918e58edb"
|
"hash": "f1441ead59f221901f34d3f7c8e2b27bb020c083c333a1be518827d6df79846e"
|
||||||
}
|
}
|
||||||
22
.sqlx/query-f8ede4a0cd843e8f5622d6e0fb26df14fbc3a47b17c4d628a1bb21cff387238e.json
generated
Normal file
22
.sqlx/query-f8ede4a0cd843e8f5622d6e0fb26df14fbc3a47b17c4d628a1bb21cff387238e.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
15
.sqlx/query-ff474fba4d18f7788b1a1900a6e5549f0899689e857634cfc5d85bd7b8718c46.json
generated
Normal file
15
.sqlx/query-ff474fba4d18f7788b1a1900a6e5549f0899689e857634cfc5d85bd7b8718c46.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
140
Cargo.lock
generated
140
Cargo.lock
generated
@@ -1107,6 +1107,27 @@ dependencies = [
|
|||||||
"subtle",
|
"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]]
|
[[package]]
|
||||||
name = "curl"
|
name = "curl"
|
||||||
version = "0.4.44"
|
version = "0.4.44"
|
||||||
@@ -1368,6 +1389,16 @@ dependencies = [
|
|||||||
"dirs-sys",
|
"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]]
|
[[package]]
|
||||||
name = "dirs-sys"
|
name = "dirs-sys"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -1379,6 +1410,17 @@ dependencies = [
|
|||||||
"winapi",
|
"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]]
|
[[package]]
|
||||||
name = "dlv-list"
|
name = "dlv-list"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -1416,6 +1458,12 @@ version = "0.2.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112"
|
checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encode_unicode"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.33"
|
version = "0.8.33"
|
||||||
@@ -2292,6 +2340,7 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"rust-s3",
|
"rust-s3",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
|
"rust_iso3166",
|
||||||
"sentry",
|
"sentry",
|
||||||
"sentry-actix",
|
"sentry-actix",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2958,6 +3007,48 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
|
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]]
|
[[package]]
|
||||||
name = "phonenumber"
|
name = "phonenumber"
|
||||||
version = "0.3.3+8.13.9"
|
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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
|
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]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -3565,6 +3670,18 @@ dependencies = [
|
|||||||
"serde_json",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-demangle"
|
name = "rustc-demangle"
|
||||||
version = "0.1.23"
|
version = "0.1.23"
|
||||||
@@ -4025,6 +4142,12 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a"
|
checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "0.3.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.9"
|
version = "0.4.9"
|
||||||
@@ -4457,6 +4580,17 @@ dependencies = [
|
|||||||
"windows-sys",
|
"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]]
|
[[package]]
|
||||||
name = "termcolor"
|
name = "termcolor"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -4753,6 +4887,12 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
|
checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode_categories"
|
name = "unicode_categories"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ woothee = "0.13.0"
|
|||||||
lettre = "0.10.4"
|
lettre = "0.10.4"
|
||||||
|
|
||||||
derive-new = "0.5.9"
|
derive-new = "0.5.9"
|
||||||
|
rust_iso3166 = "0.1.11"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
actix-http = "3.4.0"
|
actix-http = "3.4.0"
|
||||||
|
|||||||
@@ -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'
|
|
||||||
28
migrations/20231114175920_new-payment-methods.sql
Normal file
28
migrations/20231114175920_new-payment-methods.sql
Normal file
@@ -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;
|
||||||
@@ -8,8 +8,7 @@ use crate::file_hosting::FileHost;
|
|||||||
use crate::models::ids::base62_impl::{parse_base62, to_base62};
|
use crate::models::ids::base62_impl::{parse_base62, to_base62};
|
||||||
use crate::models::ids::random_base62_rng;
|
use crate::models::ids::random_base62_rng;
|
||||||
use crate::models::pats::Scopes;
|
use crate::models::pats::Scopes;
|
||||||
use crate::models::users::{Badges, RecipientStatus, Role, UserPayoutData};
|
use crate::models::users::{Badges, Role};
|
||||||
use crate::queue::payouts::{AccountUser, PayoutsQueue};
|
|
||||||
use crate::queue::session::AuthQueue;
|
use crate::queue::session::AuthQueue;
|
||||||
use crate::queue::socket::ActiveSockets;
|
use crate::queue::socket::ActiveSockets;
|
||||||
use crate::routes::ApiError;
|
use crate::routes::ApiError;
|
||||||
@@ -22,6 +21,7 @@ use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
|||||||
use actix_ws::Closed;
|
use actix_ws::Closed;
|
||||||
use argon2::password_hash::SaltString;
|
use argon2::password_hash::SaltString;
|
||||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||||
|
use base64::Engine;
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use rand_chacha::rand_core::SeedableRng;
|
use rand_chacha::rand_core::SeedableRng;
|
||||||
use rand_chacha::ChaCha20Rng;
|
use rand_chacha::ChaCha20Rng;
|
||||||
@@ -31,7 +31,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use sqlx::postgres::PgPool;
|
use sqlx::postgres::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::RwLock;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
pub fn config(cfg: &mut ServiceConfig) {
|
pub fn config(cfg: &mut ServiceConfig) {
|
||||||
@@ -52,8 +52,7 @@ pub fn config(cfg: &mut ServiceConfig) {
|
|||||||
.service(resend_verify_email)
|
.service(resend_verify_email)
|
||||||
.service(set_email)
|
.service(set_email)
|
||||||
.service(verify_email)
|
.service(verify_email)
|
||||||
.service(subscribe_newsletter)
|
.service(subscribe_newsletter),
|
||||||
.service(link_trolley),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +66,7 @@ pub enum AuthProvider {
|
|||||||
GitLab,
|
GitLab,
|
||||||
Google,
|
Google,
|
||||||
Steam,
|
Steam,
|
||||||
|
PayPal,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -78,6 +78,8 @@ pub struct TempUser {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
|
||||||
|
pub country: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TempUser {
|
impl TempUser {
|
||||||
@@ -211,11 +213,23 @@ impl TempUser {
|
|||||||
None
|
None
|
||||||
},
|
},
|
||||||
microsoft_id: if provider == AuthProvider::Microsoft {
|
microsoft_id: if provider == AuthProvider::Microsoft {
|
||||||
Some(self.id)
|
Some(self.id.clone())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
password: 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,
|
totp_secret: None,
|
||||||
username,
|
username,
|
||||||
name: self.name,
|
name: self.name,
|
||||||
@@ -227,8 +241,6 @@ impl TempUser {
|
|||||||
role: Role::Developer.to_string(),
|
role: Role::Developer.to_string(),
|
||||||
badges: Badges::default(),
|
badges: Badges::default(),
|
||||||
balance: Decimal::ZERO,
|
balance: Decimal::ZERO,
|
||||||
trolley_id: None,
|
|
||||||
trolley_account_status: None,
|
|
||||||
}
|
}
|
||||||
.insert(transaction)
|
.insert(transaction)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -299,6 +311,21 @@ impl AuthProvider {
|
|||||||
"http://specs.openid.net/auth/2.0/identifier_select",
|
"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);
|
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)
|
Ok(res)
|
||||||
@@ -532,6 +590,7 @@ impl AuthProvider {
|
|||||||
avatar_url: Some(github_user.avatar_url),
|
avatar_url: Some(github_user.avatar_url),
|
||||||
bio: github_user.bio,
|
bio: github_user.bio,
|
||||||
name: github_user.name,
|
name: github_user.name,
|
||||||
|
country: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AuthProvider::Discord => {
|
AuthProvider::Discord => {
|
||||||
@@ -563,6 +622,7 @@ impl AuthProvider {
|
|||||||
.map(|x| format!("https://cdn.discordapp.com/avatars/{}/{}.webp", id, x)),
|
.map(|x| format!("https://cdn.discordapp.com/avatars/{}/{}.webp", id, x)),
|
||||||
bio: None,
|
bio: None,
|
||||||
name: discord_user.global_name,
|
name: discord_user.global_name,
|
||||||
|
country: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AuthProvider::Microsoft => {
|
AuthProvider::Microsoft => {
|
||||||
@@ -594,6 +654,7 @@ impl AuthProvider {
|
|||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
bio: None,
|
bio: None,
|
||||||
name: microsoft_user.display_name,
|
name: microsoft_user.display_name,
|
||||||
|
country: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AuthProvider::GitLab => {
|
AuthProvider::GitLab => {
|
||||||
@@ -623,6 +684,7 @@ impl AuthProvider {
|
|||||||
avatar_url: gitlab_user.avatar_url,
|
avatar_url: gitlab_user.avatar_url,
|
||||||
bio: gitlab_user.bio,
|
bio: gitlab_user.bio,
|
||||||
name: gitlab_user.name,
|
name: gitlab_user.name,
|
||||||
|
country: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AuthProvider::Google => {
|
AuthProvider::Google => {
|
||||||
@@ -656,6 +718,7 @@ impl AuthProvider {
|
|||||||
avatar_url: google_user.picture,
|
avatar_url: google_user.picture,
|
||||||
bio: None,
|
bio: None,
|
||||||
name: google_user.name,
|
name: google_user.name,
|
||||||
|
country: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AuthProvider::Steam => {
|
AuthProvider::Steam => {
|
||||||
@@ -707,11 +770,54 @@ impl AuthProvider {
|
|||||||
avatar_url: player.avatar,
|
avatar_url: player.avatar,
|
||||||
bio: None,
|
bio: None,
|
||||||
name: Some(player.personaname),
|
name: Some(player.personaname),
|
||||||
|
country: None,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Err(AuthenticationError::InvalidCredentials);
|
return Err(AuthenticationError::InvalidCredentials);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AuthProvider::PayPal => {
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct PayPalUser {
|
||||||
|
pub payer_id: String,
|
||||||
|
pub email: String,
|
||||||
|
pub picture: Option<String>,
|
||||||
|
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)
|
Ok(res)
|
||||||
@@ -782,6 +888,13 @@ impl AuthProvider {
|
|||||||
.fetch_optional(executor)
|
.fetch_optional(executor)
|
||||||
.await?;
|
.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))
|
value.map(|x| crate::database::models::UserId(x.id))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -872,6 +985,32 @@ impl AuthProvider {
|
|||||||
.execute(&mut **transaction)
|
.execute(&mut **transaction)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
@@ -885,6 +1024,7 @@ impl AuthProvider {
|
|||||||
AuthProvider::GitLab => "GitLab",
|
AuthProvider::GitLab => "GitLab",
|
||||||
AuthProvider::Google => "Google",
|
AuthProvider::Google => "Google",
|
||||||
AuthProvider::Steam => "Steam",
|
AuthProvider::Steam => "Steam",
|
||||||
|
AuthProvider::PayPal => "PayPal",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1043,7 +1183,22 @@ pub async fn auth_callback(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let user = crate::database::models::User::get_id(id, &**client, &redis).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(
|
send_email(
|
||||||
email,
|
email,
|
||||||
"Authentication method added",
|
"Authentication method added",
|
||||||
@@ -1241,14 +1396,16 @@ pub async fn delete_auth_provider(
|
|||||||
.update_user_id(user.id.into(), None, &mut transaction)
|
.update_user_id(user.id.into(), None, &mut transaction)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(email) = user.email {
|
if delete_provider.provider != AuthProvider::PayPal {
|
||||||
send_email(
|
if let Some(email) = user.email {
|
||||||
email,
|
send_email(
|
||||||
"Authentication method removed",
|
email,
|
||||||
&format!("When logging into Modrinth, you can no longer log in using the {} authentication provider.", delete_provider.provider.as_str()),
|
"Authentication method removed",
|
||||||
"If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).",
|
&format!("When logging into Modrinth, you can no longer log in using the {} authentication provider.", delete_provider.provider.as_str()),
|
||||||
None,
|
"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?;
|
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,
|
steam_id: None,
|
||||||
microsoft_id: None,
|
microsoft_id: None,
|
||||||
password: Some(password_hash),
|
password: Some(password_hash),
|
||||||
|
paypal_id: None,
|
||||||
|
paypal_country: None,
|
||||||
|
paypal_email: None,
|
||||||
|
venmo_handle: None,
|
||||||
totp_secret: None,
|
totp_secret: None,
|
||||||
username: new_account.username.clone(),
|
username: new_account.username.clone(),
|
||||||
name: Some(new_account.username),
|
name: Some(new_account.username),
|
||||||
@@ -1386,8 +1547,6 @@ pub async fn create_account_with_password(
|
|||||||
role: Role::Developer.to_string(),
|
role: Role::Developer.to_string(),
|
||||||
badges: Badges::default(),
|
badges: Badges::default(),
|
||||||
balance: Decimal::ZERO,
|
balance: Decimal::ZERO,
|
||||||
trolley_id: None,
|
|
||||||
trolley_account_status: None,
|
|
||||||
}
|
}
|
||||||
.insert(&mut transaction)
|
.insert(&mut transaction)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -2011,7 +2170,6 @@ pub async fn set_email(
|
|||||||
redis: Data<RedisPool>,
|
redis: Data<RedisPool>,
|
||||||
email: web::Json<SetEmail>,
|
email: web::Json<SetEmail>,
|
||||||
session_queue: Data<AuthQueue>,
|
session_queue: Data<AuthQueue>,
|
||||||
payouts_queue: Data<Mutex<PayoutsQueue>>,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
) -> Result<HttpResponse, ApiError> {
|
||||||
email
|
email
|
||||||
.0
|
.0
|
||||||
@@ -2065,17 +2223,6 @@ pub async fn set_email(
|
|||||||
"We need to verify your email address.",
|
"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?;
|
crate::database::models::User::clear_caches(&[(user.id.into(), None)], &redis).await?;
|
||||||
transaction.commit().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))),
|
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<PgPool>,
|
|
||||||
redis: Data<RedisPool>,
|
|
||||||
session_queue: Data<AuthQueue>,
|
|
||||||
payouts_queue: Data<Mutex<PayoutsQueue>>,
|
|
||||||
body: web::Json<AccountUser>,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
|
||||||
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(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ where
|
|||||||
if db_user.steam_id.is_some() {
|
if db_user.steam_id.is_some() {
|
||||||
auth_providers.push(AuthProvider::Steam)
|
auth_providers.push(AuthProvider::Steam)
|
||||||
}
|
}
|
||||||
|
if db_user.paypal_id.is_some() {
|
||||||
|
auth_providers.push(AuthProvider::PayPal)
|
||||||
|
}
|
||||||
|
|
||||||
let user = User {
|
let user = User {
|
||||||
id: UserId::from(db_user.id),
|
id: UserId::from(db_user.id),
|
||||||
@@ -61,9 +64,10 @@ where
|
|||||||
has_totp: Some(db_user.totp_secret.is_some()),
|
has_totp: Some(db_user.totp_secret.is_some()),
|
||||||
github_id: None,
|
github_id: None,
|
||||||
payout_data: Some(UserPayoutData {
|
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,
|
balance: db_user.balance,
|
||||||
trolley_id: db_user.trolley_id,
|
|
||||||
trolley_status: db_user.trolley_account_status,
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,14 @@ generate_ids!(
|
|||||||
OAuthAccessTokenId
|
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)]
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Type, Hash, Serialize, Deserialize)]
|
||||||
#[sqlx(transparent)]
|
#[sqlx(transparent)]
|
||||||
pub struct UserId(pub i64);
|
pub struct UserId(pub i64);
|
||||||
@@ -298,6 +306,10 @@ pub struct OAuthRedirectUriId(pub i64);
|
|||||||
#[sqlx(transparent)]
|
#[sqlx(transparent)]
|
||||||
pub struct OAuthAccessTokenId(pub i64);
|
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;
|
use crate::models::ids;
|
||||||
|
|
||||||
impl From<ids::ProjectId> for ProjectId {
|
impl From<ids::ProjectId> for ProjectId {
|
||||||
@@ -440,3 +452,14 @@ impl From<OAuthClientAuthorizationId> for ids::OAuthClientAuthorizationId {
|
|||||||
ids::OAuthClientAuthorizationId(id.0 as u64)
|
ids::OAuthClientAuthorizationId(id.0 as u64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<ids::PayoutId> for PayoutId {
|
||||||
|
fn from(id: ids::PayoutId) -> Self {
|
||||||
|
PayoutId(id.0 as i64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<PayoutId> for ids::PayoutId {
|
||||||
|
fn from(id: PayoutId) -> Self {
|
||||||
|
ids::PayoutId(id.0 as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub mod oauth_client_item;
|
|||||||
pub mod oauth_token_item;
|
pub mod oauth_token_item;
|
||||||
pub mod organization_item;
|
pub mod organization_item;
|
||||||
pub mod pat_item;
|
pub mod pat_item;
|
||||||
|
pub mod payout_item;
|
||||||
pub mod project_item;
|
pub mod project_item;
|
||||||
pub mod report_item;
|
pub mod report_item;
|
||||||
pub mod session_item;
|
pub mod session_item;
|
||||||
|
|||||||
117
src/database/models/payout_item.rs
Normal file
117
src/database/models/payout_item.rs
Normal file
@@ -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<Utc>,
|
||||||
|
pub status: PayoutStatus,
|
||||||
|
pub amount: Decimal,
|
||||||
|
|
||||||
|
pub fee: Option<Decimal>,
|
||||||
|
pub method: Option<PayoutMethodType>,
|
||||||
|
pub method_address: Option<String>,
|
||||||
|
pub platform_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<Payout>, 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<Vec<Payout>, 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::<Vec<_>>()
|
||||||
|
)
|
||||||
|
.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::<Vec<Payout>>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_for_user(
|
||||||
|
user_id: UserId,
|
||||||
|
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||||
|
) -> Result<Vec<PayoutId>, 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::<Vec<_>>())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ use super::CollectionId;
|
|||||||
use crate::database::models::{DatabaseError, OrganizationId};
|
use crate::database::models::{DatabaseError, OrganizationId};
|
||||||
use crate::database::redis::RedisPool;
|
use crate::database::redis::RedisPool;
|
||||||
use crate::models::ids::base62_impl::{parse_base62, to_base62};
|
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 chrono::{DateTime, Utc};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -24,6 +24,11 @@ pub struct User {
|
|||||||
pub microsoft_id: Option<String>,
|
pub microsoft_id: Option<String>,
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
|
|
||||||
|
pub paypal_id: Option<String>,
|
||||||
|
pub paypal_country: Option<String>,
|
||||||
|
pub paypal_email: Option<String>,
|
||||||
|
pub venmo_handle: Option<String>,
|
||||||
|
|
||||||
pub totp_secret: Option<String>,
|
pub totp_secret: Option<String>,
|
||||||
|
|
||||||
pub username: String,
|
pub username: String,
|
||||||
@@ -37,8 +42,6 @@ pub struct User {
|
|||||||
pub badges: Badges,
|
pub badges: Badges,
|
||||||
|
|
||||||
pub balance: Decimal,
|
pub balance: Decimal,
|
||||||
pub trolley_id: Option<String>,
|
|
||||||
pub trolley_account_status: Option<RecipientStatus>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
@@ -52,13 +55,14 @@ impl User {
|
|||||||
id, username, name, email,
|
id, username, name, email,
|
||||||
avatar_url, bio, created,
|
avatar_url, bio, created,
|
||||||
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
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 (
|
VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1, $2, $3, $4, $5,
|
||||||
$6, $7,
|
$6, $7,
|
||||||
$8, $9, $10, $11, $12, $13,
|
$8, $9, $10, $11, $12, $13,
|
||||||
$14, $15
|
$14, $15, $16, $17, $18, $19
|
||||||
)
|
)
|
||||||
",
|
",
|
||||||
self.id as UserId,
|
self.id as UserId,
|
||||||
@@ -76,6 +80,10 @@ impl User {
|
|||||||
self.microsoft_id,
|
self.microsoft_id,
|
||||||
self.email_verified,
|
self.email_verified,
|
||||||
self.password,
|
self.password,
|
||||||
|
self.paypal_id,
|
||||||
|
self.paypal_country,
|
||||||
|
self.paypal_email,
|
||||||
|
self.venmo_handle
|
||||||
)
|
)
|
||||||
.execute(&mut **transaction)
|
.execute(&mut **transaction)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -192,7 +200,8 @@ impl User {
|
|||||||
created, role, badges,
|
created, role, badges,
|
||||||
balance,
|
balance,
|
||||||
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
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
|
FROM users
|
||||||
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
|
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(),
|
badges: Badges::from_bits(u.badges as u64).unwrap_or_default(),
|
||||||
balance: u.balance,
|
balance: u.balance,
|
||||||
password: u.password,
|
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,
|
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::<Vec<User>>()
|
.try_collect::<Vec<User>>()
|
||||||
@@ -559,7 +567,7 @@ impl User {
|
|||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
DELETE FROM historical_payouts
|
DELETE FROM payouts
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
",
|
",
|
||||||
id as UserId,
|
id as UserId,
|
||||||
|
|||||||
19
src/lib.rs
19
src/lib.rs
@@ -8,7 +8,7 @@ use queue::{
|
|||||||
};
|
};
|
||||||
use scheduler::Scheduler;
|
use scheduler::Scheduler;
|
||||||
use sqlx::Postgres;
|
use sqlx::Postgres;
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
extern crate clickhouse as clickhouse_crate;
|
extern crate clickhouse as clickhouse_crate;
|
||||||
use clickhouse_crate::Client;
|
use clickhouse_crate::Client;
|
||||||
@@ -49,7 +49,7 @@ pub struct LabrinthConfig {
|
|||||||
pub ip_salt: Pepper,
|
pub ip_salt: Pepper,
|
||||||
pub search_config: search::SearchConfig,
|
pub search_config: search::SearchConfig,
|
||||||
pub session_queue: web::Data<AuthQueue>,
|
pub session_queue: web::Data<AuthQueue>,
|
||||||
pub payouts_queue: web::Data<Mutex<PayoutsQueue>>,
|
pub payouts_queue: web::Data<PayoutsQueue>,
|
||||||
pub analytics_queue: Arc<AnalyticsQueue>,
|
pub analytics_queue: Arc<AnalyticsQueue>,
|
||||||
pub active_sockets: web::Data<RwLock<ActiveSockets>>,
|
pub active_sockets: web::Data<RwLock<ActiveSockets>>,
|
||||||
}
|
}
|
||||||
@@ -227,7 +227,7 @@ pub fn app_setup(
|
|||||||
pepper: models::ids::Base62Id(models::ids::random_base62(11)).to_string(),
|
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()));
|
let active_sockets = web::Data::new(RwLock::new(ActiveSockets::default()));
|
||||||
|
|
||||||
LabrinthConfig {
|
LabrinthConfig {
|
||||||
@@ -349,10 +349,6 @@ pub fn check_env_vars() -> bool {
|
|||||||
failed |= true;
|
failed |= true;
|
||||||
}
|
}
|
||||||
|
|
||||||
failed |= check_var::<String>("TROLLEY_ACCESS_KEY");
|
|
||||||
failed |= check_var::<String>("TROLLEY_SECRET_KEY");
|
|
||||||
failed |= check_var::<String>("TROLLEY_WEBHOOK_SIGNATURE");
|
|
||||||
|
|
||||||
failed |= check_var::<String>("GITHUB_CLIENT_ID");
|
failed |= check_var::<String>("GITHUB_CLIENT_ID");
|
||||||
failed |= check_var::<String>("GITHUB_CLIENT_SECRET");
|
failed |= check_var::<String>("GITHUB_CLIENT_SECRET");
|
||||||
failed |= check_var::<String>("GITLAB_CLIENT_ID");
|
failed |= check_var::<String>("GITLAB_CLIENT_ID");
|
||||||
@@ -365,6 +361,15 @@ pub fn check_env_vars() -> bool {
|
|||||||
failed |= check_var::<String>("GOOGLE_CLIENT_SECRET");
|
failed |= check_var::<String>("GOOGLE_CLIENT_SECRET");
|
||||||
failed |= check_var::<String>("STEAM_API_KEY");
|
failed |= check_var::<String>("STEAM_API_KEY");
|
||||||
|
|
||||||
|
failed |= check_var::<String>("TREMENDOUS_API_URL");
|
||||||
|
failed |= check_var::<String>("TREMENDOUS_API_KEY");
|
||||||
|
failed |= check_var::<String>("TREMENDOUS_PRIVATE_KEY");
|
||||||
|
|
||||||
|
failed |= check_var::<String>("PAYPAL_API_URL");
|
||||||
|
failed |= check_var::<String>("PAYPAL_WEBHOOK_ID");
|
||||||
|
failed |= check_var::<String>("PAYPAL_CLIENT_ID");
|
||||||
|
failed |= check_var::<String>("PAYPAL_CLIENT_SECRET");
|
||||||
|
|
||||||
failed |= check_var::<String>("TURNSTILE_SECRET");
|
failed |= check_var::<String>("TURNSTILE_SECRET");
|
||||||
|
|
||||||
failed |= check_var::<String>("SMTP_USERNAME");
|
failed |= check_var::<String>("SMTP_USERNAME");
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
pub mod error;
|
||||||
pub mod v2;
|
pub mod v2;
|
||||||
pub mod v3;
|
pub mod v3;
|
||||||
|
|
||||||
pub use v3::analytics;
|
pub use v3::analytics;
|
||||||
pub use v3::collections;
|
pub use v3::collections;
|
||||||
pub use v3::error;
|
|
||||||
pub use v3::ids;
|
pub use v3::ids;
|
||||||
pub use v3::images;
|
pub use v3::images;
|
||||||
pub use v3::notifications;
|
pub use v3::notifications;
|
||||||
@@ -11,6 +11,7 @@ pub use v3::oauth_clients;
|
|||||||
pub use v3::organizations;
|
pub use v3::organizations;
|
||||||
pub use v3::pack;
|
pub use v3::pack;
|
||||||
pub use v3::pats;
|
pub use v3::pats;
|
||||||
|
pub use v3::payouts;
|
||||||
pub use v3::projects;
|
pub use v3::projects;
|
||||||
pub use v3::reports;
|
pub use v3::reports;
|
||||||
pub use v3::sessions;
|
pub use v3::sessions;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub use super::oauth_clients::OAuthClientAuthorizationId;
|
|||||||
pub use super::oauth_clients::{OAuthClientId, OAuthRedirectUriId};
|
pub use super::oauth_clients::{OAuthClientId, OAuthRedirectUriId};
|
||||||
pub use super::organizations::OrganizationId;
|
pub use super::organizations::OrganizationId;
|
||||||
pub use super::pats::PatId;
|
pub use super::pats::PatId;
|
||||||
|
pub use super::payouts::PayoutId;
|
||||||
pub use super::projects::{ProjectId, VersionId};
|
pub use super::projects::{ProjectId, VersionId};
|
||||||
pub use super::reports::ReportId;
|
pub use super::reports::ReportId;
|
||||||
pub use super::sessions::SessionId;
|
pub use super::sessions::SessionId;
|
||||||
@@ -127,6 +128,7 @@ base62_id_impl!(ImageId, ImageId);
|
|||||||
base62_id_impl!(OAuthClientId, OAuthClientId);
|
base62_id_impl!(OAuthClientId, OAuthClientId);
|
||||||
base62_id_impl!(OAuthRedirectUriId, OAuthRedirectUriId);
|
base62_id_impl!(OAuthRedirectUriId, OAuthRedirectUriId);
|
||||||
base62_id_impl!(OAuthClientAuthorizationId, OAuthClientAuthorizationId);
|
base62_id_impl!(OAuthClientAuthorizationId, OAuthClientAuthorizationId);
|
||||||
|
base62_id_impl!(PayoutId, PayoutId);
|
||||||
|
|
||||||
pub mod base62_impl {
|
pub mod base62_impl {
|
||||||
use serde::de::{self, Deserializer, Visitor};
|
use serde::de::{self, Deserializer, Visitor};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
pub mod collections;
|
pub mod collections;
|
||||||
pub mod error;
|
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub mod images;
|
pub mod images;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
@@ -8,6 +7,7 @@ pub mod oauth_clients;
|
|||||||
pub mod organizations;
|
pub mod organizations;
|
||||||
pub mod pack;
|
pub mod pack;
|
||||||
pub mod pats;
|
pub mod pats;
|
||||||
|
pub mod payouts;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod reports;
|
pub mod reports;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
|
|||||||
176
src/models/v3/payouts.rs
Normal file
176
src/models/v3/payouts.rs
Normal file
@@ -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<Utc>,
|
||||||
|
#[serde(with = "rust_decimal::serde::float")]
|
||||||
|
pub amount: Decimal,
|
||||||
|
|
||||||
|
#[serde(with = "rust_decimal::serde::float_option")]
|
||||||
|
pub fee: Option<Decimal>,
|
||||||
|
pub method: Option<PayoutMethodType>,
|
||||||
|
/// the address this payout was sent to: ex: email, paypal email, venmo handle
|
||||||
|
pub method_address: Option<String>,
|
||||||
|
pub platform_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
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<Decimal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PayoutDecimal(pub Decimal);
|
||||||
|
|
||||||
|
impl Serialize for PayoutDecimal {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
rust_decimal::serde::float::serialize(&self.0, serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for PayoutDecimal {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
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<PayoutDecimal>,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use serde::{Deserialize, Serialize};
|
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(from = "Base62Id")]
|
||||||
#[serde(into = "Base62Id")]
|
#[serde(into = "Base62Id")]
|
||||||
pub struct UserId(pub u64);
|
pub struct UserId(pub u64);
|
||||||
@@ -61,9 +61,11 @@ pub struct User {
|
|||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct UserPayoutData {
|
pub struct UserPayoutData {
|
||||||
|
pub paypal_address: Option<String>,
|
||||||
|
pub paypal_country: Option<String>,
|
||||||
|
pub venmo_handle: Option<String>,
|
||||||
|
#[serde(with = "rust_decimal::serde::float")]
|
||||||
pub balance: Decimal,
|
pub balance: Decimal,
|
||||||
pub trolley_id: Option<String>,
|
|
||||||
pub trolley_status: Option<RecipientStatus>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::database::models::user_item::User as DBUser;
|
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<Utc>,
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +1,40 @@
|
|||||||
|
use crate::models::ids::UserId;
|
||||||
|
use crate::models::payouts::{
|
||||||
|
PayoutDecimal, PayoutInterval, PayoutMethod, PayoutMethodFee, PayoutMethodType,
|
||||||
|
};
|
||||||
use crate::routes::ApiError;
|
use crate::routes::ApiError;
|
||||||
use crate::util::env::parse_var;
|
use crate::util::env::parse_var;
|
||||||
use crate::{database::redis::RedisPool, models::projects::MonetizationStatus};
|
use crate::{database::redis::RedisPool, models::projects::MonetizationStatus};
|
||||||
|
use base64::Engine;
|
||||||
use chrono::{DateTime, Datelike, Duration, Utc, Weekday};
|
use chrono::{DateTime, Datelike, Duration, Utc, Weekday};
|
||||||
use hex::ToHex;
|
use dashmap::DashMap;
|
||||||
use hmac::{Hmac, Mac, NewMac};
|
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::Value;
|
||||||
use sha2::Sha256;
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
pub struct PayoutsQueue {
|
pub struct PayoutsQueue {
|
||||||
access_key: String,
|
credential: RwLock<Option<PayPalCredentials>>,
|
||||||
secret_key: String,
|
payout_options: RwLock<Option<PayoutMethods>>,
|
||||||
|
payouts_locks: DashMap<UserId, Arc<Mutex<()>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PayPalCredentials {
|
||||||
|
access_token: String,
|
||||||
|
token_type: String,
|
||||||
|
expires: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct PayoutMethods {
|
||||||
|
options: Vec<PayoutMethod>,
|
||||||
|
expires: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PayoutsQueue {
|
impl Default for PayoutsQueue {
|
||||||
@@ -23,67 +42,178 @@ impl Default for PayoutsQueue {
|
|||||||
Self::new()
|
Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
|
||||||
pub enum AccountUser {
|
|
||||||
Business { name: String },
|
|
||||||
Individual { first: String, last: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct PaymentInfo {
|
|
||||||
country: String,
|
|
||||||
payout_method: String,
|
|
||||||
route_minimum: Decimal,
|
|
||||||
estimated_fees: Decimal,
|
|
||||||
deduct_fees: Decimal,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batches payouts and handles token refresh
|
// Batches payouts and handles token refresh
|
||||||
impl PayoutsQueue {
|
impl PayoutsQueue {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
PayoutsQueue {
|
PayoutsQueue {
|
||||||
access_key: dotenvy::var("TROLLEY_ACCESS_KEY").expect("missing trolley access key"),
|
credential: RwLock::new(None),
|
||||||
secret_key: dotenvy::var("TROLLEY_SECRET_KEY").expect("missing trolley secret key"),
|
payout_options: RwLock::new(None),
|
||||||
|
payouts_locks: DashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn make_trolley_request<T: Serialize, X: DeserializeOwned>(
|
async fn refresh_token(&self) -> Result<PayPalCredentials, ApiError> {
|
||||||
|
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_paypal_request<T: Serialize, X: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<T>,
|
||||||
|
raw_text: Option<String>,
|
||||||
|
no_api_prefix: Option<bool>,
|
||||||
|
) -> Result<X, ApiError> {
|
||||||
|
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 {
|
||||||
|
credentials.clone()
|
||||||
|
}
|
||||||
|
} 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,
|
||||||
|
if no_api_prefix.unwrap_or(false) {
|
||||||
|
path.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}{path}", dotenvy::var("PAYPAL_API_URL")?)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
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 PayPal".to_string()))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
|
||||||
|
let value = resp.json::<Value>().await.map_err(|_| {
|
||||||
|
ApiError::Payments("could not retrieve PayPal response body".to_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::<PayPalError>(value.clone()) {
|
||||||
|
return Err(ApiError::Payments(format!(
|
||||||
|
"error name: {}, message: {}",
|
||||||
|
error.name, error.message
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(error) = serde_json::from_value::<PayPalIdentityError>(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<T: Serialize, X: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
method: Method,
|
method: Method,
|
||||||
path: &str,
|
path: &str,
|
||||||
body: Option<T>,
|
body: Option<T>,
|
||||||
) -> Result<X, ApiError> {
|
) -> Result<X, ApiError> {
|
||||||
let timestamp = Utc::now().timestamp();
|
|
||||||
|
|
||||||
let mut mac: Hmac<Sha256> = 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)?
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!("{}\n{}\n{}\n\n", timestamp, method.as_str(), path)
|
|
||||||
}
|
|
||||||
.as_bytes(),
|
|
||||||
);
|
|
||||||
let request_signature = mac.finalize().into_bytes().encode_hex::<String>();
|
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
let mut request = client
|
let mut request = client
|
||||||
.request(method, format!("https://api.trolley.com{path}"))
|
.request(
|
||||||
|
method,
|
||||||
|
format!("{}{path}", dotenvy::var("TREMENDOUS_API_URL")?),
|
||||||
|
)
|
||||||
.header(
|
.header(
|
||||||
"Authorization",
|
"Authorization",
|
||||||
format!("prsign {}:{}", self.access_key, request_signature),
|
format!("Bearer {}", dotenvy::var("TREMENDOUS_API_KEY")?),
|
||||||
)
|
);
|
||||||
.header("X-PR-Timestamp", timestamp);
|
|
||||||
|
|
||||||
if let Some(body) = body {
|
if let Some(body) = body {
|
||||||
request = request.json(&body);
|
request = request.json(&body);
|
||||||
@@ -92,40 +222,34 @@ impl PayoutsQueue {
|
|||||||
let resp = request
|
let resp = request
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::Payments("could not communicate with Trolley".to_string()))?;
|
.map_err(|_| ApiError::Payments("could not communicate with Tremendous".to_string()))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
|
||||||
let value = resp.json::<Value>().await.map_err(|_| {
|
let value = resp.json::<Value>().await.map_err(|_| {
|
||||||
ApiError::Payments("could not retrieve Trolley response body".to_string())
|
ApiError::Payments("could not retrieve Tremendous response body".to_string())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Some(obj) = value.as_object() {
|
if !status.is_success() {
|
||||||
if !obj.get("ok").and_then(|x| x.as_bool()).unwrap_or(true) {
|
if let Some(obj) = value.as_object() {
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct TrolleyError {
|
|
||||||
field: Option<String>,
|
|
||||||
message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(array) = obj.get("errors") {
|
if let Some(array) = obj.get("errors") {
|
||||||
let err = serde_json::from_value::<Vec<TrolleyError>>(array.clone()).map_err(
|
#[derive(Deserialize)]
|
||||||
|_| {
|
struct TremendousError {
|
||||||
ApiError::Payments(
|
message: String,
|
||||||
"could not retrieve Trolley 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
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let err =
|
||||||
|
serde_json::from_value::<TremendousError>(array.clone()).map_err(|_| {
|
||||||
|
ApiError::Payments(
|
||||||
|
"could not retrieve Tremendous error json body".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
return Err(ApiError::Payments(err.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Err(ApiError::Payments(
|
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)?)
|
Ok(serde_json::from_value(value)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_payout(
|
pub async fn get_payout_methods(&self) -> Result<Vec<PayoutMethod>, ApiError> {
|
||||||
&mut self,
|
async fn refresh_payout_methods(queue: &PayoutsQueue) -> Result<PayoutMethods, ApiError> {
|
||||||
recipient: &str,
|
let mut options = queue.payout_options.write().await;
|
||||||
amount: Decimal,
|
|
||||||
) -> Result<(String, Option<String>), ApiError> {
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct TrolleyRes {
|
|
||||||
batch: Batch,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
let mut methods = Vec::new();
|
||||||
struct Batch {
|
|
||||||
id: String,
|
|
||||||
payments: BatchPayments,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Payment {
|
pub struct Sku {
|
||||||
id: String,
|
pub min: Decimal,
|
||||||
}
|
pub max: Decimal,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, Eq, PartialEq)]
|
||||||
struct BatchPayments {
|
#[serde(rename_all = "snake_case")]
|
||||||
payments: Vec<Payment>,
|
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 {
|
#[derive(Deserialize)]
|
||||||
return Err(ApiError::Payments(
|
pub struct ProductCountry {
|
||||||
"Account balance is too low to withdraw funds".to_string(),
|
pub abbr: String,
|
||||||
));
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let send_amount = amount - fee.deduct_fees;
|
#[derive(Deserialize)]
|
||||||
|
pub struct Product {
|
||||||
|
pub id: String,
|
||||||
|
pub category: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub disclosure: String,
|
||||||
|
pub skus: Vec<Sku>,
|
||||||
|
pub currency_codes: Vec<String>,
|
||||||
|
pub countries: Vec<ProductCountry>,
|
||||||
|
pub images: Vec<ProductImage>,
|
||||||
|
}
|
||||||
|
|
||||||
let res = self
|
#[derive(Deserialize)]
|
||||||
.make_trolley_request::<_, TrolleyRes>(
|
pub struct TremendousResponse {
|
||||||
Method::POST,
|
pub products: Vec<Product>,
|
||||||
"/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?;
|
|
||||||
|
|
||||||
self.make_trolley_request::<Value, Value>(
|
let response = queue
|
||||||
Method::POST,
|
.make_tremendous_request::<(), TremendousResponse>(Method::GET, "products", None)
|
||||||
&format!("/v1/batches/{}/start-processing", res.batch.id),
|
.await?;
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let payment_id = res.batch.payments.payments.into_iter().next().map(|x| x.id);
|
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"];
|
||||||
|
|
||||||
Ok((res.batch.id, payment_id))
|
if !SUPPORTED_METHODS.contains(&&*product.category)
|
||||||
}
|
|| BLACKLISTED_IDS.contains(&&*product.id)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
pub async fn register_recipient(
|
let method = PayoutMethod {
|
||||||
&self,
|
id: product.id,
|
||||||
email: &str,
|
type_: PayoutMethodType::Tremendous,
|
||||||
user: AccountUser,
|
name: product.name.clone(),
|
||||||
) -> Result<String, ApiError> {
|
supported_countries: product.countries.into_iter().map(|x| x.abbr).collect(),
|
||||||
#[derive(Deserialize)]
|
image_url: product
|
||||||
struct TrolleyRes {
|
.images
|
||||||
recipient: Recipient,
|
.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::<Vec<_>>();
|
||||||
|
values.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
PayoutInterval::Fixed { values }
|
||||||
struct Recipient {
|
} else if let Some(first) = product.skus.first() {
|
||||||
id: String,
|
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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
let id = self
|
// we do not support interval gift cards with non US based currencies since we cannot do currency conversions properly
|
||||||
.make_trolley_request::<_, TrolleyRes>(
|
if let PayoutInterval::Fixed { .. } = method.interval {
|
||||||
Method::POST,
|
if !product.currency_codes.contains(&"USD".to_string()) {
|
||||||
"/v1/recipients/",
|
continue;
|
||||||
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?;
|
|
||||||
|
|
||||||
Ok(id.recipient.id)
|
methods.push(method);
|
||||||
}
|
}
|
||||||
|
|
||||||
// lhs minimum, rhs estimate
|
const UPRANK_IDS: &[&str] = &["ET0ZVETV5ILN", "Q24BD9EZ332JT", "UIL1ZYJU5MKN"];
|
||||||
pub async fn get_estimated_fees(
|
const DOWNRANK_IDS: &[&str] = &["EIPF8Q00EMM1", "OU2MWXYWPNWQ"];
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
amount: Decimal,
|
|
||||||
) -> Result<PaymentInfo, ApiError> {
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct TrolleyRes {
|
|
||||||
recipient: Recipient,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
methods.sort_by(|a, b| {
|
||||||
#[serde(rename_all = "camelCase")]
|
let a_top = UPRANK_IDS.contains(&&*a.id);
|
||||||
struct Recipient {
|
let a_bottom = DOWNRANK_IDS.contains(&&*a.id);
|
||||||
route_minimum: Option<Decimal>,
|
let b_top = UPRANK_IDS.contains(&&*b.id);
|
||||||
estimated_fees: Option<Decimal>,
|
let b_bottom = DOWNRANK_IDS.contains(&&*b.id);
|
||||||
address: RecipientAddress,
|
|
||||||
payout_method: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
match (a_top, a_bottom, b_top, b_bottom) {
|
||||||
struct RecipientAddress {
|
(true, _, true, _) => a.name.cmp(&b.name), // Both in top_priority: sort alphabetically
|
||||||
country: String,
|
(_, 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 id = self
|
{
|
||||||
.make_trolley_request::<Value, TrolleyRes>(
|
let paypal_us = PayoutMethod {
|
||||||
Method::GET,
|
id: "paypal_us".to_string(),
|
||||||
&format!("/v1/recipients/{id}"),
|
type_: PayoutMethodType::PayPal,
|
||||||
None,
|
name: "PayPal".to_string(),
|
||||||
)
|
supported_countries: vec!["US".to_string()],
|
||||||
.await?;
|
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)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
if &id.recipient.payout_method == "paypal" {
|
let mut venmo = paypal_us.clone();
|
||||||
// based on https://www.paypal.com/us/webapps/mpp/merchant-fees. see paypal payouts section
|
venmo.id = "venmo".to_string();
|
||||||
let fee = if &id.recipient.address.country == "US" {
|
venmo.name = "Venmo".to_string();
|
||||||
std::cmp::min(
|
venmo.type_ = PayoutMethodType::Venmo;
|
||||||
std::cmp::max(
|
|
||||||
Decimal::ONE / Decimal::from(4),
|
methods.insert(0, paypal_us);
|
||||||
(Decimal::from(2) / Decimal::ONE_HUNDRED) * amount,
|
methods.insert(1, venmo)
|
||||||
),
|
}
|
||||||
Decimal::from(1),
|
|
||||||
)
|
methods.insert(
|
||||||
} else {
|
2,
|
||||||
std::cmp::min(
|
PayoutMethod {
|
||||||
(Decimal::from(2) / Decimal::ONE_HUNDRED) * amount,
|
id: "paypal_in".to_string(),
|
||||||
Decimal::from(20),
|
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)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let new_options = PayoutMethods {
|
||||||
|
options: methods,
|
||||||
|
expires: Utc::now() + Duration::hours(6),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(PaymentInfo {
|
*options = Some(new_options.clone());
|
||||||
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 {
|
Ok(new_options)
|
||||||
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,
|
|
||||||
})
|
|
||||||
} 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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
options.clone()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
drop(read);
|
||||||
|
refresh_payout_methods(self).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(options.options)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_recipient_email(&self, id: &str, email: &str) -> Result<(), ApiError> {
|
pub fn lock_user_payouts(&self, user_id: UserId) -> Arc<Mutex<()>> {
|
||||||
self.make_trolley_request::<_, Value>(
|
self.payouts_locks
|
||||||
Method::PATCH,
|
.entry(user_id)
|
||||||
&format!("/v1/recipients/{}", id),
|
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||||
Some(json!({
|
.clone()
|
||||||
"email": email,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
use crate::auth::validate::get_user_record_from_bearer_token;
|
use crate::auth::validate::get_user_record_from_bearer_token;
|
||||||
use crate::database::models::User;
|
|
||||||
use crate::database::redis::RedisPool;
|
use crate::database::redis::RedisPool;
|
||||||
use crate::models::analytics::Download;
|
use crate::models::analytics::Download;
|
||||||
use crate::models::ids::ProjectId;
|
use crate::models::ids::ProjectId;
|
||||||
use crate::models::pats::Scopes;
|
use crate::models::pats::Scopes;
|
||||||
use crate::models::users::{PayoutStatus, RecipientStatus};
|
|
||||||
use crate::queue::analytics::AnalyticsQueue;
|
use crate::queue::analytics::AnalyticsQueue;
|
||||||
use crate::queue::maxmind::MaxMindIndexer;
|
use crate::queue::maxmind::MaxMindIndexer;
|
||||||
use crate::queue::session::AuthQueue;
|
use crate::queue::session::AuthQueue;
|
||||||
@@ -12,12 +10,8 @@ use crate::routes::ApiError;
|
|||||||
use crate::search::SearchConfig;
|
use crate::search::SearchConfig;
|
||||||
use crate::util::date::get_current_tenths_of_ms;
|
use crate::util::date::get_current_tenths_of_ms;
|
||||||
use crate::util::guards::admin_key_guard;
|
use crate::util::guards::admin_key_guard;
|
||||||
use crate::util::routes::read_from_payload;
|
|
||||||
use actix_web::{patch, post, web, HttpRequest, HttpResponse};
|
use actix_web::{patch, post, web, HttpRequest, HttpResponse};
|
||||||
use hex::ToHex;
|
|
||||||
use hmac::{Hmac, Mac, NewMac};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sha2::Sha256;
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
@@ -28,7 +22,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
cfg.service(
|
cfg.service(
|
||||||
web::scope("admin")
|
web::scope("admin")
|
||||||
.service(count_download)
|
.service(count_download)
|
||||||
.service(trolley_webhook)
|
|
||||||
.service(force_reindex),
|
.service(force_reindex),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -143,174 +136,6 @@ pub async fn count_download(
|
|||||||
Ok(HttpResponse::NoContent().body(""))
|
Ok(HttpResponse::NoContent().body(""))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct TrolleyWebhook {
|
|
||||||
model: String,
|
|
||||||
action: String,
|
|
||||||
body: HashMap<String, serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/_trolley")]
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn trolley_webhook(
|
|
||||||
req: HttpRequest,
|
|
||||||
pool: web::Data<PgPool>,
|
|
||||||
redis: web::Data<RedisPool>,
|
|
||||||
mut payload: web::Payload,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
|
||||||
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<Sha256> =
|
|
||||||
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::<String>();
|
|
||||||
|
|
||||||
if &*request_signature == v1 {
|
|
||||||
let webhook = serde_json::from_slice::<TrolleyWebhook>(&payload)?;
|
|
||||||
|
|
||||||
if webhook.model == "recipient" {
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Recipient {
|
|
||||||
pub id: String,
|
|
||||||
pub email: Option<String>,
|
|
||||||
pub status: Option<RecipientStatus>,
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(body) = webhook.body.get("recipient") {
|
|
||||||
if let Ok(recipient) = serde_json::from_value::<Recipient>(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::<Payment>(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")]
|
#[post("/_force_reindex", guard = "admin_key_guard")]
|
||||||
pub async fn force_reindex(
|
pub async fn force_reindex(
|
||||||
pool: web::Data<PgPool>,
|
pool: web::Data<PgPool>,
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(delete_gallery_item)
|
.service(delete_gallery_item)
|
||||||
.service(project_follow)
|
.service(project_follow)
|
||||||
.service(project_unfollow)
|
.service(project_unfollow)
|
||||||
.service(project_schedule)
|
|
||||||
.service(super::teams::team_members_get_project)
|
.service(super::teams::team_members_get_project)
|
||||||
.service(
|
.service(
|
||||||
web::scope("{project_id}")
|
web::scope("{project_id}")
|
||||||
@@ -526,36 +525,6 @@ pub async fn projects_edit(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct SchedulingData {
|
|
||||||
pub time: DateTime<Utc>,
|
|
||||||
pub requested_status: ProjectStatus,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("{id}/schedule")]
|
|
||||||
pub async fn project_schedule(
|
|
||||||
req: HttpRequest,
|
|
||||||
info: web::Path<(String,)>,
|
|
||||||
pool: web::Data<PgPool>,
|
|
||||||
redis: web::Data<RedisPool>,
|
|
||||||
session_queue: web::Data<AuthQueue>,
|
|
||||||
scheduling_data: web::Json<SchedulingData>,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
|
||||||
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)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct Extension {
|
pub struct Extension {
|
||||||
pub ext: String,
|
pub ext: String,
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ pub struct NewTeamMember {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub organization_permissions: Option<OrganizationPermissions>,
|
pub organization_permissions: Option<OrganizationPermissions>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(with = "rust_decimal::serde::float")]
|
||||||
pub payouts_split: Decimal,
|
pub payouts_split: Decimal,
|
||||||
#[serde(default = "default_ordering")]
|
#[serde(default = "default_ordering")]
|
||||||
pub ordering: i64,
|
pub ordering: i64,
|
||||||
|
|||||||
@@ -3,17 +3,14 @@ use crate::file_hosting::FileHost;
|
|||||||
use crate::models::projects::Project;
|
use crate::models::projects::Project;
|
||||||
use crate::models::users::{Badges, Role};
|
use crate::models::users::{Badges, Role};
|
||||||
use crate::models::v2::projects::LegacyProject;
|
use crate::models::v2::projects::LegacyProject;
|
||||||
use crate::queue::payouts::PayoutsQueue;
|
|
||||||
use crate::queue::session::AuthQueue;
|
use crate::queue::session::AuthQueue;
|
||||||
use crate::routes::{v2_reroute, v3, ApiError};
|
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 lazy_static::lazy_static;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||||
@@ -30,10 +27,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(user_edit)
|
.service(user_edit)
|
||||||
.service(user_icon_edit)
|
.service(user_icon_edit)
|
||||||
.service(user_notifications)
|
.service(user_notifications)
|
||||||
.service(user_follows)
|
.service(user_follows),
|
||||||
.service(user_payouts)
|
|
||||||
.service(user_payouts_fees)
|
|
||||||
.service(user_payouts_request),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +152,7 @@ pub async fn user_edit(
|
|||||||
bio: new_user.bio,
|
bio: new_user.bio,
|
||||||
role: new_user.role,
|
role: new_user.role,
|
||||||
badges: new_user.badges,
|
badges: new_user.badges,
|
||||||
|
venmo_handle: None,
|
||||||
}),
|
}),
|
||||||
pool,
|
pool,
|
||||||
redis,
|
redis,
|
||||||
@@ -250,72 +245,3 @@ pub async fn user_notifications(
|
|||||||
) -> Result<HttpResponse, ApiError> {
|
) -> Result<HttpResponse, ApiError> {
|
||||||
v3::users::user_notifications(req, info, pool, redis, session_queue).await
|
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<PgPool>,
|
|
||||||
redis: web::Data<RedisPool>,
|
|
||||||
session_queue: web::Data<AuthQueue>,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
|
||||||
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<FeeEstimateAmount>,
|
|
||||||
pool: web::Data<PgPool>,
|
|
||||||
redis: web::Data<RedisPool>,
|
|
||||||
session_queue: web::Data<AuthQueue>,
|
|
||||||
payouts_queue: web::Data<Mutex<PayoutsQueue>>,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
|
||||||
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<PgPool>,
|
|
||||||
data: web::Json<PayoutData>,
|
|
||||||
payouts_queue: web::Data<Mutex<PayoutsQueue>>,
|
|
||||||
redis: web::Data<RedisPool>,
|
|
||||||
session_queue: web::Data<AuthQueue>,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
|
||||||
v3::users::user_payouts_request(
|
|
||||||
req,
|
|
||||||
info,
|
|
||||||
pool,
|
|
||||||
web::Json(v3::users::PayoutData {
|
|
||||||
amount: data.amount,
|
|
||||||
}),
|
|
||||||
payouts_queue,
|
|
||||||
redis,
|
|
||||||
session_queue,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ use crate::models::projects::{Dependency, FileType, Version, VersionStatus, Vers
|
|||||||
use crate::models::v2::projects::LegacyVersion;
|
use crate::models::v2::projects::LegacyVersion;
|
||||||
use crate::queue::session::AuthQueue;
|
use crate::queue::session::AuthQueue;
|
||||||
use crate::routes::{v2_reroute, v3};
|
use crate::routes::{v2_reroute, v3};
|
||||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse};
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
@@ -23,7 +22,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(version_get)
|
.service(version_get)
|
||||||
.service(version_delete)
|
.service(version_delete)
|
||||||
.service(version_edit)
|
.service(version_edit)
|
||||||
.service(version_schedule)
|
|
||||||
.service(super::version_creation::upload_file_to_version),
|
.service(super::version_creation::upload_file_to_version),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -254,35 +252,6 @@ pub async fn version_edit(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct SchedulingData {
|
|
||||||
pub time: DateTime<Utc>,
|
|
||||||
pub requested_status: VersionStatus,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("{id}/schedule")]
|
|
||||||
pub async fn version_schedule(
|
|
||||||
req: HttpRequest,
|
|
||||||
info: web::Path<(models::ids::VersionId,)>,
|
|
||||||
pool: web::Data<PgPool>,
|
|
||||||
redis: web::Data<RedisPool>,
|
|
||||||
scheduling_data: web::Json<SchedulingData>,
|
|
||||||
session_queue: web::Data<AuthQueue>,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
|
||||||
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}")]
|
#[delete("{version_id}")]
|
||||||
pub async fn version_delete(
|
pub async fn version_delete(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
use crate::auth::validate::get_user_record_from_bearer_token;
|
use crate::auth::validate::get_user_record_from_bearer_token;
|
||||||
use crate::database::models::User;
|
|
||||||
use crate::database::redis::RedisPool;
|
use crate::database::redis::RedisPool;
|
||||||
use crate::models::analytics::Download;
|
use crate::models::analytics::Download;
|
||||||
use crate::models::ids::ProjectId;
|
use crate::models::ids::ProjectId;
|
||||||
use crate::models::pats::Scopes;
|
use crate::models::pats::Scopes;
|
||||||
use crate::models::users::{PayoutStatus, RecipientStatus};
|
|
||||||
use crate::queue::analytics::AnalyticsQueue;
|
use crate::queue::analytics::AnalyticsQueue;
|
||||||
use crate::queue::maxmind::MaxMindIndexer;
|
use crate::queue::maxmind::MaxMindIndexer;
|
||||||
use crate::queue::session::AuthQueue;
|
use crate::queue::session::AuthQueue;
|
||||||
@@ -12,12 +10,8 @@ use crate::routes::ApiError;
|
|||||||
use crate::search::SearchConfig;
|
use crate::search::SearchConfig;
|
||||||
use crate::util::date::get_current_tenths_of_ms;
|
use crate::util::date::get_current_tenths_of_ms;
|
||||||
use crate::util::guards::admin_key_guard;
|
use crate::util::guards::admin_key_guard;
|
||||||
use crate::util::routes::read_from_payload;
|
|
||||||
use actix_web::{patch, post, web, HttpRequest, HttpResponse};
|
use actix_web::{patch, post, web, HttpRequest, HttpResponse};
|
||||||
use hex::ToHex;
|
|
||||||
use hmac::{Hmac, Mac, NewMac};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sha2::Sha256;
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
@@ -28,7 +22,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
cfg.service(
|
cfg.service(
|
||||||
web::scope("admin")
|
web::scope("admin")
|
||||||
.service(count_download)
|
.service(count_download)
|
||||||
.service(trolley_webhook)
|
|
||||||
.service(force_reindex),
|
.service(force_reindex),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -143,174 +136,6 @@ pub async fn count_download(
|
|||||||
Ok(HttpResponse::NoContent().body(""))
|
Ok(HttpResponse::NoContent().body(""))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct TrolleyWebhook {
|
|
||||||
model: String,
|
|
||||||
action: String,
|
|
||||||
body: HashMap<String, serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/_trolley")]
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn trolley_webhook(
|
|
||||||
req: HttpRequest,
|
|
||||||
pool: web::Data<PgPool>,
|
|
||||||
redis: web::Data<RedisPool>,
|
|
||||||
mut payload: web::Payload,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
|
||||||
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<Sha256> =
|
|
||||||
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::<String>();
|
|
||||||
|
|
||||||
if &*request_signature == v1 {
|
|
||||||
let webhook = serde_json::from_slice::<TrolleyWebhook>(&payload)?;
|
|
||||||
|
|
||||||
if webhook.model == "recipient" {
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Recipient {
|
|
||||||
pub id: String,
|
|
||||||
pub email: Option<String>,
|
|
||||||
pub status: Option<RecipientStatus>,
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(body) = webhook.body.get("recipient") {
|
|
||||||
if let Ok(recipient) = serde_json::from_value::<Recipient>(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::<Payment>(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")]
|
#[post("/_force_reindex", guard = "admin_key_guard")]
|
||||||
pub async fn force_reindex(
|
pub async fn force_reindex(
|
||||||
pool: web::Data<PgPool>,
|
pool: web::Data<PgPool>,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub mod images;
|
|||||||
pub mod moderation;
|
pub mod moderation;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod organizations;
|
pub mod organizations;
|
||||||
|
pub mod payouts;
|
||||||
pub mod project_creation;
|
pub mod project_creation;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod reports;
|
pub mod reports;
|
||||||
@@ -49,6 +50,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
.configure(threads::config)
|
.configure(threads::config)
|
||||||
.configure(users::config)
|
.configure(users::config)
|
||||||
.configure(version_file::config)
|
.configure(version_file::config)
|
||||||
|
.configure(payouts::config)
|
||||||
.configure(versions::config),
|
.configure(versions::config),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
745
src/routes/v3/payouts.rs
Normal file
745
src/routes/v3/payouts.rs
Normal file
@@ -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<PgPool>,
|
||||||
|
redis: web::Data<RedisPool>,
|
||||||
|
payouts: web::Data<PayoutsQueue>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
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::<PayPalWebhook>(&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<PgPool>,
|
||||||
|
redis: web::Data<RedisPool>,
|
||||||
|
payouts: web::Data<PayoutsQueue>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
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<Sha256> =
|
||||||
|
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::<String>();
|
||||||
|
|
||||||
|
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::<TremendousWebhook>(&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<PgPool>,
|
||||||
|
redis: web::Data<RedisPool>,
|
||||||
|
session_queue: web::Data<AuthQueue>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<PgPool>,
|
||||||
|
redis: web::Data<RedisPool>,
|
||||||
|
body: web::Json<Withdrawal>,
|
||||||
|
session_queue: web::Data<AuthQueue>,
|
||||||
|
payouts_queue: web::Data<PayoutsQueue>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
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<PayPalLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PayoutItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Reward>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<PgPool>,
|
||||||
|
redis: web::Data<RedisPool>,
|
||||||
|
payouts: web::Data<PayoutsQueue>,
|
||||||
|
session_queue: web::Data<AuthQueue>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("methods")]
|
||||||
|
pub async fn payment_methods(
|
||||||
|
payouts_queue: web::Data<PayoutsQueue>,
|
||||||
|
filter: web::Query<MethodFilter>,
|
||||||
|
) -> Result<HttpResponse, ApiError> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().json(methods))
|
||||||
|
}
|
||||||
@@ -378,6 +378,7 @@ pub struct NewTeamMember {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub organization_permissions: Option<OrganizationPermissions>,
|
pub organization_permissions: Option<OrganizationPermissions>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
#[serde(with = "rust_decimal::serde::float")]
|
||||||
pub payouts_split: Decimal,
|
pub payouts_split: Decimal,
|
||||||
#[serde(default = "default_ordering")]
|
#[serde(default = "default_ordering")]
|
||||||
pub ordering: i64,
|
pub ordering: i64,
|
||||||
|
|||||||
@@ -3,11 +3,8 @@ use std::{collections::HashMap, sync::Arc};
|
|||||||
use actix_web::{web, HttpRequest, HttpResponse};
|
use actix_web::{web, HttpRequest, HttpResponse};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use rust_decimal::Decimal;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -20,9 +17,9 @@ use crate::{
|
|||||||
notifications::Notification,
|
notifications::Notification,
|
||||||
pats::Scopes,
|
pats::Scopes,
|
||||||
projects::Project,
|
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},
|
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}", web::delete().to(user_delete))
|
||||||
.route("{id}/follows", web::get().to(user_follows))
|
.route("{id}/follows", web::get().to(user_follows))
|
||||||
.route("{id}/notifications", web::get().to(user_notifications))
|
.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)),
|
.route("{id}/oauth_apps", web::get().to(get_user_clients)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -302,6 +296,8 @@ pub struct EditUser {
|
|||||||
pub bio: Option<Option<String>>,
|
pub bio: Option<Option<String>>,
|
||||||
pub role: Option<Role>,
|
pub role: Option<Role>,
|
||||||
pub badges: Option<Badges>,
|
pub badges: Option<Badges>,
|
||||||
|
#[validate(length(max = 160))]
|
||||||
|
pub venmo_handle: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_edit(
|
pub async fn user_edit(
|
||||||
@@ -312,7 +308,7 @@ pub async fn user_edit(
|
|||||||
redis: web::Data<RedisPool>,
|
redis: web::Data<RedisPool>,
|
||||||
session_queue: web::Data<AuthQueue>,
|
session_queue: web::Data<AuthQueue>,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
) -> Result<HttpResponse, ApiError> {
|
||||||
let (_scopes, user) = get_user_from_headers(
|
let (scopes, user) = get_user_from_headers(
|
||||||
&req,
|
&req,
|
||||||
&**pool,
|
&**pool,
|
||||||
&redis,
|
&redis,
|
||||||
@@ -432,6 +428,27 @@ pub async fn user_edit(
|
|||||||
.await?;
|
.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?;
|
User::clear_caches(&[(id, Some(actual_user.username))], &redis).await?;
|
||||||
transaction.commit().await?;
|
transaction.commit().await?;
|
||||||
Ok(HttpResponse::NoContent().body(""))
|
Ok(HttpResponse::NoContent().body(""))
|
||||||
@@ -682,233 +699,3 @@ pub async fn user_notifications(
|
|||||||
Ok(HttpResponse::NotFound().body(""))
|
Ok(HttpResponse::NotFound().body(""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_payouts(
|
|
||||||
req: HttpRequest,
|
|
||||||
info: web::Path<(String,)>,
|
|
||||||
pool: web::Data<PgPool>,
|
|
||||||
redis: web::Data<RedisPool>,
|
|
||||||
session_queue: web::Data<AuthQueue>,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
|
||||||
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::<Vec<Payout>>(),
|
|
||||||
)
|
|
||||||
.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<FeeEstimateAmount>,
|
|
||||||
pool: web::Data<PgPool>,
|
|
||||||
redis: web::Data<RedisPool>,
|
|
||||||
session_queue: web::Data<AuthQueue>,
|
|
||||||
payouts_queue: web::Data<Mutex<PayoutsQueue>>,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
|
||||||
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<PgPool>,
|
|
||||||
data: web::Json<PayoutData>,
|
|
||||||
payouts_queue: web::Data<Mutex<PayoutsQueue>>,
|
|
||||||
redis: web::Data<RedisPool>,
|
|
||||||
session_queue: web::Data<AuthQueue>,
|
|
||||||
) -> Result<HttpResponse, ApiError> {
|
|
||||||
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(""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user