Refunds + Upgrading/Downgrading plans (#2983)

* Refunds + Upgrading/Downgrading plans

* Servers list route

* Finish, lint

* add GAM fee to payouts

* Sync payment intent id with stripe

* fix lint, update migrations

* Remove tauri generated files

* Register refund route

* fix refund bugs
This commit is contained in:
Geometrically 2024-12-06 19:37:17 -08:00 committed by GitHub
parent 2cfb637451
commit 2987f507fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1045 additions and 13604 deletions

32
Cargo.lock generated
View File

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "actix-codec" name = "actix-codec"
@ -614,9 +614,9 @@ dependencies = [
[[package]] [[package]]
name = "async-stripe" name = "async-stripe"
version = "0.37.3" version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2f14b5943a52cf051bbbbb68538e93a69d1e291934174121e769f4b181113f5" checksum = "58d670cf4d47a1b8ffef54286a5625382e360a34ee76902fd93ad8c7032a0c30"
dependencies = [ dependencies = [
"chrono", "chrono",
"futures-util", "futures-util",
@ -7467,6 +7467,18 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_tokenstream"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.79",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@ -10727,9 +10739,9 @@ dependencies = [
[[package]] [[package]]
name = "yaserde" name = "yaserde"
version = "0.8.0" version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bf52af554a50b866aaad63d7eabd6fca298db3dfe49afd50b7ba5a33dfa0582" checksum = "8bfa0d2b420fd005aa9b6f99f9584ebd964e6865d7ca787304cc1a3366c39231"
dependencies = [ dependencies = [
"log", "log",
"xml-rs", "xml-rs",
@ -10737,15 +10749,17 @@ dependencies = [
[[package]] [[package]]
name = "yaserde_derive" name = "yaserde_derive"
version = "0.8.0" version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab8bd5c76eebb8380b26833d30abddbdd885b00dd06178412e0d51d5bfc221f" checksum = "1f785831c0e09e0f1a83f917054fd59c088f6561db5b2a42c1c3e1687329325f"
dependencies = [ dependencies = [
"heck 0.4.1", "heck 0.5.0",
"log", "log",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 1.0.109", "serde",
"serde_tokenstream",
"syn 2.0.79",
"xml-rs", "xml-rs",
] ]

View File

@ -71,7 +71,7 @@ const password = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const subscribe = ref(true) const subscribe = ref(true)
async function signInOauth(provider) { async function signInOauth() {
const creds = await login().catch(handleSevereError) const creds = await login().catch(handleSevereError)
if (creds && creds.type === 'two_factor_required') { if (creds && creds.type === 'two_factor_required') {

2
apps/app/.gitignore vendored
View File

@ -2,3 +2,5 @@
# will have compiled files and executables # will have compiled files and executables
/target/ /target/
# Generated by tauri, metadata generated at compile time
/gen/

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
{"ads":{"identifier":"ads","description":"","remote":{"urls":["https://modrinth.com/*","http://localhost:3000/*"]},"local":false,"webviews":["ads-window"],"permissions":["ads:default"]},"core":{"identifier":"core","description":"","local":true,"windows":["main"],"permissions":["core:default","core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","core:window:allow-create","core:window:allow-maximize","core:window:allow-toggle-maximize","core:window:allow-unmaximize","core:window:allow-minimize","core:window:allow-unminimize","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-decorations","core:window:allow-start-dragging","core:webview:allow-set-webview-zoom"]},"plugins":{"identifier":"plugins","description":"","local":true,"windows":["main"],"permissions":["dialog:allow-open","dialog:allow-confirm","shell:allow-open","os:allow-platform","os:allow-version","os:allow-os-type","os:allow-family","os:allow-arch","os:allow-exe-extension","os:allow-locale","os:allow-hostname","deep-link:default","window-state:default","window-state:allow-restore-state","window-state:allow-save-window-state","auth:default","import:default","jre:default","logs:default","metadata:default","mr-auth:default","profile-create:default","pack:default","process:default","profile:default","cache:default","settings:default","tags:default","utils:default","ads:default"]},"updater":{"identifier":"updater","description":"","local":true,"windows":["main"],"permissions":["updater:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, mod_id FROM mods_gallery\n WHERE image_url = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "mod_id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "043f8c91cc63fefaf56b4d59f1b46addf63c85056d9bc47f1f7a4eba86b914cd"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval\n FROM charges\n WHERE id = $1", "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE user_id = $1 ORDER BY due DESC",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -55,8 +55,28 @@
}, },
{ {
"ordinal": 10, "ordinal": 10,
"name": "subscription_interval", "name": "subscription_interval?",
"type_info": "Text" "type_info": "Text"
},
{
"ordinal": 11,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 14,
"name": "net?",
"type_info": "Int8"
} }
], ],
"parameters": { "parameters": {
@ -75,8 +95,12 @@
true, true,
false, false,
true, true,
true true,
false,
true,
true,
false
] ]
}, },
"hash": "86ee460c74f0052a4945ab4df9829b3b077930d8e9e09dca76fde8983413adc6" "hash": "109b6307ff4b06297ddd45ac2385e6a445ee4a4d4f447816dfcd059892c8b68b"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT id, image_url, raw_image_url FROM mods_gallery\n WHERE image_url = $1\n ", "query": "\n SELECT id, image_url, raw_image_url, mod_id FROM mods_gallery\n WHERE image_url = $1\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -17,6 +17,11 @@
"ordinal": 2, "ordinal": 2,
"name": "raw_image_url", "name": "raw_image_url",
"type_info": "Text" "type_info": "Text"
},
{
"ordinal": 3,
"name": "mod_id",
"type_info": "Int8"
} }
], ],
"parameters": { "parameters": {
@ -25,10 +30,11 @@
] ]
}, },
"nullable": [ "nullable": [
false,
false, false,
false, false,
false false
] ]
}, },
"hash": "87a0f2f991d749876d90b8e4ab73727f638a019b64e6cb1d891b333c2f09099c" "hash": "1cedeb3367e780314d99b4c069c5a98383277e0db6240394c9a36bbf5fd5d597"
} }

View File

@ -1,82 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval\n FROM charges\n WHERE (status = 'open' AND due < $1) OR (status = 'failed' AND last_attempt < $1 - INTERVAL '2 days')",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "price_id",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "amount",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "currency_code",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "due",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "last_attempt",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "charge_type",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "subscription_id",
"type_info": "Int8"
},
{
"ordinal": 10,
"name": "subscription_interval",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Timestamptz"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
true
]
},
"hash": "285cdd452fff85480dde02119d224a6e422e4041deb6f640ab5159d55ba2789c"
}

View File

@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n us.id, us.user_id, us.price_id, us.interval, us.created, us.status, us.metadata\n FROM users_subscriptions us\n \n INNER JOIN products_prices pp ON us.price_id = pp.id\n INNER JOIN products p ON p.metadata @> '{\"type\": \"pyro\"}'\n WHERE $1::text IS NULL OR us.status = $1::text\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "price_id",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "interval",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "created",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "metadata",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
true
]
},
"hash": "3c128a131baef8c8b18dd85a02aeca9658b604b3f3eab53fbac5fa0d95560a1c"
}

View File

@ -1,24 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Int8",
"Int8",
"Text",
"Text",
"Varchar",
"Timestamptz",
"Timestamptz",
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "56d7b62fc05c77f228e46dbfe4eaca81b445a7f5a44e52a0526a1b57bd7a8c9d"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval\n FROM charges\n WHERE user_id = $1 ORDER BY due DESC", "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE parent_charge_id = $1",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -55,8 +55,28 @@
}, },
{ {
"ordinal": 10, "ordinal": 10,
"name": "subscription_interval", "name": "subscription_interval?",
"type_info": "Text" "type_info": "Text"
},
{
"ordinal": 11,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 14,
"name": "net?",
"type_info": "Int8"
} }
], ],
"parameters": { "parameters": {
@ -75,8 +95,12 @@
true, true,
false, false,
true, true,
true true,
false,
true,
true,
false
] ]
}, },
"hash": "457493bd11254ba1c9f81c47f15e8154053ae4e90e319d34a940fb73e33a69d4" "hash": "6bcbb0c584804c492ccee49ba0449a8a1cd88fa5d85d4cd6533f65d4c8021634"
} }

View File

@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id FROM mods_gallery\n WHERE image_url = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "7c61fee015231f0a97c25d24f2c6be24821e39e330ab82344ad3b985d0d2aaea"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')", "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE id = $1",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -55,8 +55,28 @@
}, },
{ {
"ordinal": 10, "ordinal": 10,
"name": "subscription_interval", "name": "subscription_interval?",
"type_info": "Text" "type_info": "Text"
},
{
"ordinal": 11,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 14,
"name": "net?",
"type_info": "Int8"
} }
], ],
"parameters": { "parameters": {
@ -75,8 +95,12 @@
true, true,
false, false,
true, true,
true true,
false,
true,
true,
false
] ]
}, },
"hash": "e68e27fcb3e85233be06e7435aaeb6b27d8dbe2ddaf211ba37a026eab3bb6926" "hash": "7fb6f7e0b2b993d4b89146fdd916dbd7efe31a42127f15c9617caa00d60b7bcc"
} }

View File

@ -0,0 +1,106 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'open' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "price_id",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "amount",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "currency_code",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "due",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "last_attempt",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "charge_type",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "subscription_id",
"type_info": "Int8"
},
{
"ordinal": 10,
"name": "subscription_interval?",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 14,
"name": "net?",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
true,
false,
true,
true,
false
]
},
"hash": "8f51f552d1c63fa818cbdba581691e97f693a187ed05224a44adeee68c6809d1"
}

View File

@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval,\n payment_platform = EXCLUDED.payment_platform,\n payment_platform_id = EXCLUDED.payment_platform_id,\n parent_charge_id = EXCLUDED.parent_charge_id,\n net = EXCLUDED.net\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Int8",
"Int8",
"Text",
"Text",
"Varchar",
"Timestamptz",
"Timestamptz",
"Int8",
"Text",
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "933606e1ee3cd9a33e57eaf507ee8b7f966e8d3de5aaafadfe7ae30c12c925d2"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval\n FROM charges\n WHERE (status = 'cancelled' AND due < $1) OR (status = 'failed' AND last_attempt < $1 - INTERVAL '2 days')", "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -55,13 +55,33 @@
}, },
{ {
"ordinal": 10, "ordinal": 10,
"name": "subscription_interval", "name": "subscription_interval?",
"type_info": "Text" "type_info": "Text"
},
{
"ordinal": 11,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 14,
"name": "net?",
"type_info": "Int8"
} }
], ],
"parameters": { "parameters": {
"Left": [ "Left": [
"Timestamptz" "Int8"
] ]
}, },
"nullable": [ "nullable": [
@ -75,8 +95,12 @@
true, true,
false, false,
true, true,
true true,
false,
true,
true,
false
] ]
}, },
"hash": "a87c913916adf9177f8f38369975d5fc644d989293ccb42c1e06ec54dc2571f8" "hash": "99cca53fd3f35325e2da3b671532bf98b8c7ad8e7cb9158e4eb9c5bac66d20b2"
} }

View File

@ -0,0 +1,106 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "price_id",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "amount",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "currency_code",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "due",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "last_attempt",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "charge_type",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "subscription_id",
"type_info": "Int8"
},
{
"ordinal": 10,
"name": "subscription_interval?",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "payment_platform",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "payment_platform_id?",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "parent_charge_id?",
"type_info": "Int8"
},
{
"ordinal": 14,
"name": "net?",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
true,
false,
true,
true,
false
]
},
"hash": "bfcbcadda1e323d56b6a21fc060c56bff2f38a54cf65dd1cc21f209240c7091b"
}

View File

@ -40,8 +40,8 @@ serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_with = "3.0.0" serde_with = "3.0.0"
chrono = { version = "0.4.26", features = ["serde"] } chrono = { version = "0.4.26", features = ["serde"] }
yaserde = "0.8.0" yaserde = "0.12.0"
yaserde_derive = "0.8.0" yaserde_derive = "0.12.0"
xml-rs = "0.8.15" xml-rs = "0.8.15"
rand = "0.8.5" rand = "0.8.5"
@ -120,7 +120,7 @@ rust_iso3166 = "0.1.11"
jemallocator = { version = "0.5.4", optional = true } jemallocator = { version = "0.5.4", optional = true }
async-stripe = { version = "0.37.3", features = ["runtime-tokio-hyper-rustls"] } async-stripe = { version = "0.39.1", features = ["runtime-tokio-hyper-rustls"] }
rusty-money = "0.4.1" rusty-money = "0.4.1"
json-patch = "*" json-patch = "*"

View File

@ -0,0 +1,5 @@
ALTER TABLE charges
ADD COLUMN payment_platform TEXT NOT NULL DEFAULT 'stripe',
ADD COLUMN payment_platform_id TEXT NULL,
ADD COLUMN parent_charge_id BIGINT REFERENCES charges(id) NULL,
ADD COLUMN net BIGINT NULL;

View File

@ -8,7 +8,7 @@ pub struct Success<'a> {
pub name: &'a str, pub name: &'a str,
} }
impl<'a> Success<'a> { impl Success<'_> {
pub fn render(self) -> HttpResponse { pub fn render(self) -> HttpResponse {
let html = include_str!("success.html"); let html = include_str!("success.html");

View File

@ -1,7 +1,9 @@
use crate::database::models::{ use crate::database::models::{
ChargeId, DatabaseError, ProductPriceId, UserId, UserSubscriptionId, ChargeId, DatabaseError, ProductPriceId, UserId, UserSubscriptionId,
}; };
use crate::models::billing::{ChargeStatus, ChargeType, PriceDuration}; use crate::models::billing::{
ChargeStatus, ChargeType, PaymentPlatform, PriceDuration,
};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use std::convert::{TryFrom, TryInto}; use std::convert::{TryFrom, TryInto};
@ -18,6 +20,14 @@ pub struct ChargeItem {
pub type_: ChargeType, pub type_: ChargeType,
pub subscription_id: Option<UserSubscriptionId>, pub subscription_id: Option<UserSubscriptionId>,
pub subscription_interval: Option<PriceDuration>, pub subscription_interval: Option<PriceDuration>,
pub payment_platform: PaymentPlatform,
pub payment_platform_id: Option<String>,
pub parent_charge_id: Option<ChargeId>,
// Net is always in USD
pub net: Option<i64>,
} }
struct ChargeResult { struct ChargeResult {
@ -32,6 +42,10 @@ struct ChargeResult {
charge_type: String, charge_type: String,
subscription_id: Option<i64>, subscription_id: Option<i64>,
subscription_interval: Option<String>, subscription_interval: Option<String>,
payment_platform: String,
payment_platform_id: Option<String>,
parent_charge_id: Option<i64>,
net: Option<i64>,
} }
impl TryFrom<ChargeResult> for ChargeItem { impl TryFrom<ChargeResult> for ChargeItem {
@ -52,6 +66,10 @@ impl TryFrom<ChargeResult> for ChargeItem {
subscription_interval: r subscription_interval: r
.subscription_interval .subscription_interval
.map(|x| PriceDuration::from_string(&x)), .map(|x| PriceDuration::from_string(&x)),
payment_platform: PaymentPlatform::from_string(&r.payment_platform),
payment_platform_id: r.payment_platform_id,
parent_charge_id: r.parent_charge_id.map(ChargeId),
net: r.net,
}) })
} }
} }
@ -61,7 +79,15 @@ macro_rules! select_charges_with_predicate {
sqlx::query_as!( sqlx::query_as!(
ChargeResult, ChargeResult,
r#" r#"
SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval SELECT
id, user_id, price_id, amount, currency_code, status, due, last_attempt,
charge_type, subscription_id,
-- Workaround for https://github.com/launchbadge/sqlx/issues/3336
subscription_interval AS "subscription_interval?",
payment_platform,
payment_platform_id AS "payment_platform_id?",
parent_charge_id AS "parent_charge_id?",
net AS "net?"
FROM charges FROM charges
"# "#
+ $predicate, + $predicate,
@ -77,15 +103,19 @@ impl ChargeItem {
) -> Result<ChargeId, DatabaseError> { ) -> Result<ChargeId, DatabaseError> {
sqlx::query!( sqlx::query!(
r#" r#"
INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval) INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (id) ON CONFLICT (id)
DO UPDATE DO UPDATE
SET status = EXCLUDED.status, SET status = EXCLUDED.status,
last_attempt = EXCLUDED.last_attempt, last_attempt = EXCLUDED.last_attempt,
due = EXCLUDED.due, due = EXCLUDED.due,
subscription_id = EXCLUDED.subscription_id, subscription_id = EXCLUDED.subscription_id,
subscription_interval = EXCLUDED.subscription_interval subscription_interval = EXCLUDED.subscription_interval,
payment_platform = EXCLUDED.payment_platform,
payment_platform_id = EXCLUDED.payment_platform_id,
parent_charge_id = EXCLUDED.parent_charge_id,
net = EXCLUDED.net
"#, "#,
self.id.0, self.id.0,
self.user_id.0, self.user_id.0,
@ -98,6 +128,10 @@ impl ChargeItem {
self.last_attempt, self.last_attempt,
self.subscription_id.map(|x| x.0), self.subscription_id.map(|x| x.0),
self.subscription_interval.map(|x| x.as_str()), self.subscription_interval.map(|x| x.as_str()),
self.payment_platform.as_str(),
self.payment_platform_id.as_deref(),
self.parent_charge_id.map(|x| x.0),
self.net,
) )
.execute(&mut **transaction) .execute(&mut **transaction)
.await?; .await?;
@ -135,6 +169,24 @@ impl ChargeItem {
.collect::<Result<Vec<_>, serde_json::Error>>()?) .collect::<Result<Vec<_>, serde_json::Error>>()?)
} }
pub async fn get_children(
charge_id: ChargeId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<ChargeItem>, DatabaseError> {
let charge_id = charge_id.0;
let res = select_charges_with_predicate!(
"WHERE parent_charge_id = $1",
charge_id
)
.fetch_all(exec)
.await?;
Ok(res
.into_iter()
.map(|r| r.try_into())
.collect::<Result<Vec<_>, serde_json::Error>>()?)
}
pub async fn get_open_subscription( pub async fn get_open_subscription(
user_subscription_id: UserSubscriptionId, user_subscription_id: UserSubscriptionId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
@ -153,9 +205,18 @@ impl ChargeItem {
pub async fn get_chargeable( pub async fn get_chargeable(
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<ChargeItem>, DatabaseError> { ) -> Result<Vec<ChargeItem>, DatabaseError> {
let now = Utc::now(); let charge_type = ChargeType::Subscription.as_str();
let res = select_charges_with_predicate!(
let res = select_charges_with_predicate!("WHERE (status = 'open' AND due < $1) OR (status = 'failed' AND last_attempt < $1 - INTERVAL '2 days')", now) r#"
WHERE
charge_type = $1 AND
(
(status = 'open' AND due < NOW()) OR
(status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')
)
"#,
charge_type
)
.fetch_all(exec) .fetch_all(exec)
.await?; .await?;
@ -168,10 +229,18 @@ impl ChargeItem {
pub async fn get_unprovision( pub async fn get_unprovision(
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<ChargeItem>, DatabaseError> { ) -> Result<Vec<ChargeItem>, DatabaseError> {
let now = Utc::now(); let charge_type = ChargeType::Subscription.as_str();
let res = select_charges_with_predicate!(
let res = r#"
select_charges_with_predicate!("WHERE (status = 'cancelled' AND due < $1) OR (status = 'failed' AND last_attempt < $1 - INTERVAL '2 days')", now) WHERE
charge_type = $1 AND
(
(status = 'cancelled' AND due < NOW()) OR
(status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')
)
"#,
charge_type
)
.fetch_all(exec) .fetch_all(exec)
.await?; .await?;

View File

@ -104,6 +104,29 @@ impl UserSubscriptionItem {
.collect::<Result<Vec<_>, serde_json::Error>>()?) .collect::<Result<Vec<_>, serde_json::Error>>()?)
} }
pub async fn get_all_servers(
status: Option<SubscriptionStatus>,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<UserSubscriptionItem>, DatabaseError> {
let status = status.map(|x| x.as_str());
let results = select_user_subscriptions_with_predicate!(
r#"
INNER JOIN products_prices pp ON us.price_id = pp.id
INNER JOIN products p ON p.metadata @> '{"type": "pyro"}'
WHERE $1::text IS NULL OR us.status = $1::text
"#,
status
)
.fetch_all(exec)
.await?;
Ok(results
.into_iter()
.map(|r| r.try_into())
.collect::<Result<Vec<_>, serde_json::Error>>()?)
}
pub async fn upsert( pub async fn upsert(
&self, &self,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,

View File

@ -161,7 +161,7 @@ pub struct Charge {
pub id: ChargeId, pub id: ChargeId,
pub user_id: UserId, pub user_id: UserId,
pub price_id: ProductPriceId, pub price_id: ProductPriceId,
pub amount: i64, pub amount: u64,
pub currency_code: String, pub currency_code: String,
pub status: ChargeStatus, pub status: ChargeStatus,
pub due: DateTime<Utc>, pub due: DateTime<Utc>,
@ -170,14 +170,16 @@ pub struct Charge {
pub type_: ChargeType, pub type_: ChargeType,
pub subscription_id: Option<UserSubscriptionId>, pub subscription_id: Option<UserSubscriptionId>,
pub subscription_interval: Option<PriceDuration>, pub subscription_interval: Option<PriceDuration>,
pub platform: PaymentPlatform,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type", rename_all = "kebab-case")] #[serde(tag = "type", rename_all = "kebab-case")]
pub enum ChargeType { pub enum ChargeType {
OneTime, OneTime,
Subscription, Subscription,
Proration, Proration,
Refund,
} }
impl ChargeType { impl ChargeType {
@ -186,6 +188,7 @@ impl ChargeType {
ChargeType::OneTime => "one-time", ChargeType::OneTime => "one-time",
ChargeType::Subscription { .. } => "subscription", ChargeType::Subscription { .. } => "subscription",
ChargeType::Proration { .. } => "proration", ChargeType::Proration { .. } => "proration",
ChargeType::Refund => "refund",
} }
} }
@ -194,12 +197,13 @@ impl ChargeType {
"one-time" => ChargeType::OneTime, "one-time" => ChargeType::OneTime,
"subscription" => ChargeType::Subscription, "subscription" => ChargeType::Subscription,
"proration" => ChargeType::Proration, "proration" => ChargeType::Proration,
"refund" => ChargeType::Refund,
_ => ChargeType::OneTime, _ => ChargeType::OneTime,
} }
} }
} }
#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone, Debug)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum ChargeStatus { pub enum ChargeStatus {
// Open charges are for the next billing interval // Open charges are for the next billing interval
@ -232,3 +236,23 @@ impl ChargeStatus {
} }
} }
} }
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaymentPlatform {
Stripe,
}
impl PaymentPlatform {
pub fn from_string(string: &str) -> PaymentPlatform {
match string {
"stripe" => PaymentPlatform::Stripe,
_ => PaymentPlatform::Stripe,
}
}
pub fn as_str(&self) -> &'static str {
match self {
PaymentPlatform::Stripe => "stripe",
}
}
}

View File

@ -158,7 +158,7 @@ pub mod base62_impl {
{ {
struct Base62Visitor; struct Base62Visitor;
impl<'de> Visitor<'de> for Base62Visitor { impl Visitor<'_> for Base62Visitor {
type Value = Base62Id; type Value = Base62Id;
fn expecting( fn expecting(

View File

@ -69,11 +69,11 @@ pub enum PackFileHash {
impl From<String> for PackFileHash { impl From<String> for PackFileHash {
fn from(s: String) -> Self { fn from(s: String) -> Self {
return match s.as_str() { match s.as_str() {
"sha1" => PackFileHash::Sha1, "sha1" => PackFileHash::Sha1,
"sha512" => PackFileHash::Sha512, "sha512" => PackFileHash::Sha512,
_ => PackFileHash::Unknown(s), _ => PackFileHash::Unknown(s),
}; }
} }
} }

View File

@ -818,11 +818,13 @@ pub async fn process_payout(
// Modrinth's share of ad revenue // Modrinth's share of ad revenue
let modrinth_cut = Decimal::from(1) / Decimal::from(4); let modrinth_cut = Decimal::from(1) / Decimal::from(4);
// Clean.io fee (ad antimalware). Per 1000 impressions. // Clean.io fee (ad antimalware). Per 1000 impressions. 0.008 CPM
let clean_io_fee = Decimal::from(8) / Decimal::from(1000); let clean_io_fee = Decimal::from(8) / Decimal::from(1000);
// Google Ad Manager fee. Per 1000 impressions. 0.015400 CPM
let gam_fee = Decimal::from(154) / Decimal::from(10000);
let net_revenue = aditude_amount let net_revenue = aditude_amount
- (clean_io_fee * Decimal::from(aditude_impressions) - ((clean_io_fee + gam_fee) * Decimal::from(aditude_impressions)
/ Decimal::from(1000)); / Decimal::from(1000));
let payout = net_revenue * (Decimal::from(1) - modrinth_cut); let payout = net_revenue * (Decimal::from(1) - modrinth_cut);

View File

@ -6,9 +6,9 @@ use crate::database::models::{
}; };
use crate::database::redis::RedisPool; use crate::database::redis::RedisPool;
use crate::models::billing::{ use crate::models::billing::{
Charge, ChargeStatus, ChargeType, Price, PriceDuration, Product, Charge, ChargeStatus, ChargeType, PaymentPlatform, Price, PriceDuration,
ProductMetadata, ProductPrice, SubscriptionMetadata, SubscriptionStatus, Product, ProductMetadata, ProductPrice, SubscriptionMetadata,
UserSubscription, SubscriptionStatus, UserSubscription,
}; };
use crate::models::ids::base62_impl::{parse_base62, to_base62}; use crate::models::ids::base62_impl::{parse_base62, to_base62};
use crate::models::pats::Scopes; use crate::models::pats::Scopes;
@ -26,11 +26,11 @@ use sqlx::{PgPool, Postgres, Transaction};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::str::FromStr; use std::str::FromStr;
use stripe::{ use stripe::{
CreateCustomer, CreatePaymentIntent, CreateSetupIntent, CreateCustomer, CreatePaymentIntent, CreateRefund, CreateSetupIntent,
CreateSetupIntentAutomaticPaymentMethods, CreateSetupIntentAutomaticPaymentMethods,
CreateSetupIntentAutomaticPaymentMethodsAllowRedirects, Currency, CreateSetupIntentAutomaticPaymentMethodsAllowRedirects, Currency,
CustomerId, CustomerInvoiceSettings, CustomerPaymentMethodRetrieval, CustomerId, CustomerInvoiceSettings, CustomerPaymentMethodRetrieval,
EventObject, EventType, PaymentIntentOffSession, EventObject, EventType, PaymentIntentId, PaymentIntentOffSession,
PaymentIntentSetupFutureUsage, PaymentMethodId, SetupIntent, PaymentIntentSetupFutureUsage, PaymentMethodId, SetupIntent,
UpdateCustomer, Webhook, UpdateCustomer, Webhook,
}; };
@ -47,8 +47,10 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(edit_payment_method) .service(edit_payment_method)
.service(remove_payment_method) .service(remove_payment_method)
.service(charges) .service(charges)
.service(active_servers)
.service(initiate_payment) .service(initiate_payment)
.service(stripe_webhook), .service(stripe_webhook)
.service(refund_charge),
); );
} }
@ -111,6 +113,169 @@ pub async fn subscriptions(
Ok(HttpResponse::Ok().json(subscriptions)) Ok(HttpResponse::Ok().json(subscriptions))
} }
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ChargeRefundAmount {
Full,
Partial { amount: u64 },
}
#[derive(Deserialize)]
pub struct ChargeRefund {
#[serde(flatten)]
pub amount: ChargeRefundAmount,
pub unprovision: Option<bool>,
}
#[post("charge/{id}/refund")]
pub async fn refund_charge(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
info: web::Path<(crate::models::ids::ChargeId,)>,
body: web::Json<ChargeRefund>,
stripe_client: web::Data<stripe::Client>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
)
.await?
.1;
let (id,) = info.into_inner();
if !user.role.is_admin() {
return Err(ApiError::CustomAuthentication(
"You do not have permission to refund a subscription!".to_string(),
));
}
if let Some(charge) = ChargeItem::get(id.into(), &**pool).await? {
let refunds = ChargeItem::get_children(id.into(), &**pool).await?;
let refunds = -refunds
.into_iter()
.filter_map(|x| match x.status {
ChargeStatus::Open
| ChargeStatus::Processing
| ChargeStatus::Succeeded => Some(x.amount),
ChargeStatus::Failed | ChargeStatus::Cancelled => None,
})
.sum::<i64>();
let refundable = charge.amount - refunds;
let refund_amount = match body.0.amount {
ChargeRefundAmount::Full => refundable,
ChargeRefundAmount::Partial { amount } => amount as i64,
};
if charge.status != ChargeStatus::Succeeded {
return Err(ApiError::InvalidInput(
"This charge cannot be refunded!".to_string(),
));
}
if (refundable - refund_amount) < 0 || refund_amount == 0 {
return Err(ApiError::InvalidInput(
"You cannot refund more than the amount of the charge!"
.to_string(),
));
}
let (id, net) = match charge.payment_platform {
PaymentPlatform::Stripe => {
if let Some(payment_platform_id) = charge
.payment_platform_id
.and_then(|x| stripe::PaymentIntentId::from_str(&x).ok())
{
let mut metadata = HashMap::new();
metadata.insert(
"modrinth_user_id".to_string(),
to_base62(user.id.0),
);
metadata.insert(
"modrinth_charge_id".to_string(),
to_base62(charge.id.0 as u64),
);
let refund = stripe::Refund::create(
&stripe_client,
CreateRefund {
amount: Some(refund_amount),
metadata: Some(metadata),
payment_intent: Some(payment_platform_id),
expand: &["balance_transaction"],
..Default::default()
},
)
.await?;
(
refund.id.to_string(),
refund
.balance_transaction
.and_then(|x| x.into_object())
.map(|x| x.net),
)
} else {
return Err(ApiError::InvalidInput(
"Charge does not have attached payment id!".to_string(),
));
}
}
};
let mut transaction = pool.begin().await?;
let charge_id = generate_charge_id(&mut transaction).await?;
ChargeItem {
id: charge_id,
user_id: charge.user_id,
price_id: charge.price_id,
amount: -refund_amount,
currency_code: charge.currency_code,
status: ChargeStatus::Succeeded,
due: Utc::now(),
last_attempt: None,
type_: ChargeType::Refund,
subscription_id: charge.subscription_id,
subscription_interval: charge.subscription_interval,
payment_platform: charge.payment_platform,
payment_platform_id: Some(id),
parent_charge_id: Some(charge.id),
net,
}
.upsert(&mut transaction)
.await?;
if body.0.unprovision.unwrap_or(false) {
if let Some(subscription_id) = charge.subscription_id {
let open_charge =
ChargeItem::get_open_subscription(subscription_id, &**pool)
.await?;
if let Some(mut open_charge) = open_charge {
open_charge.status = ChargeStatus::Cancelled;
open_charge.due = Utc::now();
open_charge.upsert(&mut transaction).await?;
}
}
}
transaction.commit().await?;
}
Ok(HttpResponse::NoContent().body(""))
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SubscriptionEdit { pub struct SubscriptionEdit {
pub interval: Option<PriceDuration>, pub interval: Option<PriceDuration>,
@ -264,15 +429,22 @@ pub async fn edit_subscription(
) )
})?; })?;
// TODO: Add downgrading plans // Plan downgrade, update future charge
if proration <= 0 { if current_amount > amount {
open_charge.price_id = product_price.id;
open_charge.amount = amount as i64;
None
} else {
// For small transactions (under 30 cents), we make a loss on the proration due to fees
if proration < 30 {
return Err(ApiError::InvalidInput( return Err(ApiError::InvalidInput(
"You may not downgrade plans!".to_string(), "Proration is too small!".to_string(),
)); ));
} }
let charge_id = generate_charge_id(&mut transaction).await?; let charge_id = generate_charge_id(&mut transaction).await?;
let charge = ChargeItem { let mut charge = ChargeItem {
id: charge_id, id: charge_id,
user_id: user.id.into(), user_id: user.id.into(),
price_id: product_price.id, price_id: product_price.id,
@ -284,6 +456,10 @@ pub async fn edit_subscription(
type_: ChargeType::Proration, type_: ChargeType::Proration,
subscription_id: Some(subscription.id), subscription_id: Some(subscription.id),
subscription_interval: Some(duration), subscription_interval: Some(duration),
payment_platform: PaymentPlatform::Stripe,
payment_platform_id: None,
parent_charge_id: None,
net: None,
}; };
let customer_id = get_or_create_customer( let customer_id = get_or_create_customer(
@ -296,20 +472,21 @@ pub async fn edit_subscription(
) )
.await?; .await?;
let currency = let currency = Currency::from_str(
Currency::from_str(&current_price.currency_code.to_lowercase()) &current_price.currency_code.to_lowercase(),
.map_err(|_| {
ApiError::InvalidInput(
"Invalid currency code".to_string(),
) )
.map_err(|_| {
ApiError::InvalidInput("Invalid currency code".to_string())
})?; })?;
let mut intent = let mut intent =
CreatePaymentIntent::new(proration as i64, currency); CreatePaymentIntent::new(proration as i64, currency);
let mut metadata = HashMap::new(); let mut metadata = HashMap::new();
metadata metadata.insert(
.insert("modrinth_user_id".to_string(), to_base62(user.id.0)); "modrinth_user_id".to_string(),
to_base62(user.id.0),
);
intent.customer = Some(customer_id); intent.customer = Some(customer_id);
intent.metadata = Some(metadata); intent.metadata = Some(metadata);
@ -317,9 +494,11 @@ pub async fn edit_subscription(
intent.setup_future_usage = intent.setup_future_usage =
Some(PaymentIntentSetupFutureUsage::OffSession); Some(PaymentIntentSetupFutureUsage::OffSession);
if let Some(payment_method) = &edit_subscription.payment_method { if let Some(payment_method) = &edit_subscription.payment_method
let payment_method_id = {
if let Ok(id) = PaymentMethodId::from_str(payment_method) { let payment_method_id = if let Ok(id) =
PaymentMethodId::from_str(payment_method)
{
id id
} else { } else {
return Err(ApiError::InvalidInput( return Err(ApiError::InvalidInput(
@ -329,13 +508,15 @@ pub async fn edit_subscription(
intent.payment_method = Some(payment_method_id); intent.payment_method = Some(payment_method_id);
} }
let intent =
stripe::PaymentIntent::create(&stripe_client, intent)
.await?;
charge.payment_platform_id = Some(intent.id.to_string());
charge.upsert(&mut transaction).await?; charge.upsert(&mut transaction).await?;
Some(( Some((proration, 0, intent))
proration, }
0,
stripe::PaymentIntent::create(&stripe_client, intent).await?,
))
} else { } else {
None None
}; };
@ -423,7 +604,7 @@ pub async fn charges(
id: x.id.into(), id: x.id.into(),
user_id: x.user_id.into(), user_id: x.user_id.into(),
price_id: x.price_id.into(), price_id: x.price_id.into(),
amount: x.amount, amount: x.amount as u64,
currency_code: x.currency_code, currency_code: x.currency_code,
status: x.status, status: x.status,
due: x.due, due: x.due,
@ -431,6 +612,7 @@ pub async fn charges(
type_: x.type_, type_: x.type_,
subscription_id: x.subscription_id.map(|x| x.into()), subscription_id: x.subscription_id.map(|x| x.into()),
subscription_interval: x.subscription_interval, subscription_interval: x.subscription_interval,
platform: x.payment_platform,
}) })
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
)) ))
@ -685,6 +867,49 @@ pub async fn payment_methods(
} }
} }
#[derive(Deserialize)]
pub struct ActiveServersQuery {
pub subscription_status: Option<SubscriptionStatus>,
}
#[get("active_servers")]
pub async fn active_servers(
req: HttpRequest,
pool: web::Data<PgPool>,
query: web::Query<ActiveServersQuery>,
) -> Result<HttpResponse, ApiError> {
let master_key = dotenvy::var("PYRO_API_KEY")?;
if !req
.head()
.headers()
.get("X-Master-Key")
.map_or(false, |it| it.as_bytes() == master_key.as_bytes())
{
return Err(ApiError::CustomAuthentication(
"Invalid master key".to_string(),
));
}
let servers =
user_subscription_item::UserSubscriptionItem::get_all_servers(
query.subscription_status,
&**pool,
)
.await?;
let server_ids = servers
.into_iter()
.filter_map(|x| {
x.metadata.map(|x| match x {
SubscriptionMetadata::Pyro { id } => id,
})
})
.collect::<Vec<_>>();
Ok(HttpResponse::Ok().json(server_ids))
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum PaymentRequestType { pub enum PaymentRequestType {
@ -1130,6 +1355,7 @@ pub async fn stripe_webhook(
} }
async fn get_payment_intent_metadata( async fn get_payment_intent_metadata(
payment_intent_id: PaymentIntentId,
metadata: HashMap<String, String>, metadata: HashMap<String, String>,
pool: &PgPool, pool: &PgPool,
redis: &RedisPool, redis: &RedisPool,
@ -1203,6 +1429,8 @@ pub async fn stripe_webhook(
charge.status = charge_status; charge.status = charge_status;
charge.last_attempt = Some(Utc::now()); charge.last_attempt = Some(Utc::now());
charge.payment_platform_id =
Some(payment_intent_id.to_string());
charge.upsert(transaction).await?; charge.upsert(transaction).await?;
if let Some(subscription_id) = charge.subscription_id { if let Some(subscription_id) = charge.subscription_id {
@ -1229,6 +1457,11 @@ pub async fn stripe_webhook(
ChargeType::Proration => { ChargeType::Proration => {
subscription.price_id = charge.price_id; subscription.price_id = charge.price_id;
} }
ChargeType::Refund => {
return Err(ApiError::InvalidInput(
"Invalid charge type: Refund".to_string(),
));
}
} }
subscription.upsert(transaction).await?; subscription.upsert(transaction).await?;
@ -1332,6 +1565,12 @@ pub async fn stripe_webhook(
subscription_interval: subscription subscription_interval: subscription
.as_ref() .as_ref()
.map(|x| x.interval), .map(|x| x.interval),
payment_platform: PaymentPlatform::Stripe,
payment_platform_id: Some(
payment_intent_id.to_string(),
),
parent_charge_id: None,
net: None,
}; };
if charge_status != ChargeStatus::Failed { if charge_status != ChargeStatus::Failed {
@ -1364,6 +1603,7 @@ pub async fn stripe_webhook(
let mut transaction = pool.begin().await?; let mut transaction = pool.begin().await?;
let mut metadata = get_payment_intent_metadata( let mut metadata = get_payment_intent_metadata(
payment_intent.id,
payment_intent.metadata, payment_intent.metadata,
&pool, &pool,
&redis, &redis,
@ -1372,6 +1612,27 @@ pub async fn stripe_webhook(
) )
.await?; .await?;
if let Some(latest_charge) = payment_intent.latest_charge {
let charge = stripe::Charge::retrieve(
&stripe_client,
&latest_charge.id(),
&["balance_transaction"],
)
.await?;
if let Some(balance_transaction) = charge
.balance_transaction
.and_then(|x| x.into_object())
{
metadata.charge_item.net =
Some(balance_transaction.net);
metadata
.charge_item
.upsert(&mut transaction)
.await?;
}
}
// Provision subscription // Provision subscription
match metadata.product_item.metadata { match metadata.product_item.metadata {
ProductMetadata::Midas => { ProductMetadata::Midas => {
@ -1415,7 +1676,20 @@ pub async fn stripe_webhook(
.await? .await?
.error_for_status()?; .error_for_status()?;
// TODO: Send plan upgrade request for proration client.post(format!(
"https://archon.pyro.host/modrinth/v0/servers/{}/reallocate",
id
))
.header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?)
.json(&serde_json::json!({
"memory_mb": ram,
"cpu": cpu,
"swap_mb": swap,
"storage_mb": storage,
}))
.send()
.await?
.error_for_status()?;
} else { } else {
let (server_name, source) = if let Some( let (server_name, source) = if let Some(
PaymentRequestMetadata::Pyro { PaymentRequestMetadata::Pyro {
@ -1471,6 +1745,10 @@ pub async fn stripe_webhook(
"storage_mb": storage, "storage_mb": storage,
}, },
"source": source, "source": source,
"payment_interval": metadata.charge_item.subscription_interval.map(|x| match x {
PriceDuration::Monthly => 1,
PriceDuration::Yearly => 3,
})
})) }))
.send() .send()
.await? .await?
@ -1546,6 +1824,10 @@ pub async fn stripe_webhook(
subscription_interval: Some( subscription_interval: Some(
subscription.interval, subscription.interval,
), ),
payment_platform: PaymentPlatform::Stripe,
payment_platform_id: None,
parent_charge_id: None,
net: None,
} }
.upsert(&mut transaction) .upsert(&mut transaction)
.await?; .await?;
@ -1569,6 +1851,7 @@ pub async fn stripe_webhook(
{ {
let mut transaction = pool.begin().await?; let mut transaction = pool.begin().await?;
get_payment_intent_metadata( get_payment_intent_metadata(
payment_intent.id,
payment_intent.metadata, payment_intent.metadata,
&pool, &pool,
&redis, &redis,
@ -1586,6 +1869,7 @@ pub async fn stripe_webhook(
let mut transaction = pool.begin().await?; let mut transaction = pool.begin().await?;
let metadata = get_payment_intent_metadata( let metadata = get_payment_intent_metadata(
payment_intent.id,
payment_intent.metadata, payment_intent.metadata,
&pool, &pool,
&redis, &redis,
@ -1596,7 +1880,7 @@ pub async fn stripe_webhook(
if let Some(email) = metadata.user_item.email { if let Some(email) = metadata.user_item.email {
let money = rusty_money::Money::from_minor( let money = rusty_money::Money::from_minor(
metadata.charge_item.amount, metadata.charge_item.amount as i64,
rusty_money::iso::find( rusty_money::iso::find(
&metadata.charge_item.currency_code, &metadata.charge_item.currency_code,
) )

View File

@ -150,7 +150,12 @@ pub async fn ws_init(
{ {
let (status, _) = pair.value_mut(); let (status, _) = pair.value_mut();
if status.profile_name.as_ref().map(|x| x.len() > 64).unwrap_or(false) { if status
.profile_name
.as_ref()
.map(|x| x.len() > 64)
.unwrap_or(false)
{
continue; continue;
} }

View File

@ -21,10 +21,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(version_file); cfg.service(version_file);
} }
// TODO: These were modified in v3 and should be tested
#[derive(Default, Debug, Clone, YaSerialize)] #[derive(Default, Debug, Clone, YaSerialize)]
#[yaserde(root = "metadata", rename = "metadata")] #[yaserde(rename = "metadata")]
pub struct Metadata { pub struct Metadata {
#[yaserde(rename = "groupId")] #[yaserde(rename = "groupId")]
group_id: String, group_id: String,
@ -51,11 +49,11 @@ pub struct Versions {
} }
#[derive(Default, Debug, Clone, YaSerialize)] #[derive(Default, Debug, Clone, YaSerialize)]
#[yaserde(rename = "project", namespace = "http://maven.apache.org/POM/4.0.0")] #[yaserde(rename = "project", namespaces = { "" = "http://maven.apache.org/POM/4.0.0" })]
pub struct MavenPom { pub struct MavenPom {
#[yaserde(rename = "xsi:schemaLocation", attribute)] #[yaserde(rename = "xsi:schemaLocation", attribute = true)]
schema_location: String, schema_location: String,
#[yaserde(rename = "xmlns:xsi", attribute)] #[yaserde(rename = "xmlns:xsi", attribute = true)]
xsi: String, xsi: String,
#[yaserde(rename = "modelVersion")] #[yaserde(rename = "modelVersion")]
model_version: String, model_version: String,

View File

@ -861,7 +861,6 @@ pub struct GalleryEditQuery {
pub async fn edit_gallery_item( pub async fn edit_gallery_item(
req: HttpRequest, req: HttpRequest,
web::Query(item): web::Query<GalleryEditQuery>, web::Query(item): web::Query<GalleryEditQuery>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
@ -876,7 +875,6 @@ pub async fn edit_gallery_item(
description: item.description, description: item.description,
ordering: item.ordering, ordering: item.ordering,
}), }),
info,
pool, pool,
redis, redis,
session_queue, session_queue,
@ -894,7 +892,6 @@ pub struct GalleryDeleteQuery {
pub async fn delete_gallery_item( pub async fn delete_gallery_item(
req: HttpRequest, req: HttpRequest,
web::Query(item): web::Query<GalleryDeleteQuery>, web::Query(item): web::Query<GalleryDeleteQuery>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>, file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
@ -904,7 +901,6 @@ pub async fn delete_gallery_item(
v3::projects::delete_gallery_item( v3::projects::delete_gallery_item(
req, req,
web::Query(v3::projects::GalleryDeleteQuery { url: item.url }), web::Query(v3::projects::GalleryDeleteQuery { url: item.url }),
info,
pool, pool,
redis, redis,
file_host, file_host,

View File

@ -1788,7 +1788,6 @@ pub struct GalleryEditQuery {
pub async fn edit_gallery_item( pub async fn edit_gallery_item(
req: HttpRequest, req: HttpRequest,
web::Query(item): web::Query<GalleryEditQuery>, web::Query(item): web::Query<GalleryEditQuery>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
@ -1802,13 +1801,32 @@ pub async fn edit_gallery_item(
) )
.await? .await?
.1; .1;
let string = info.into_inner().0;
item.validate().map_err(|err| { item.validate().map_err(|err| {
ApiError::Validation(validation_errors_to_string(err, None)) ApiError::Validation(validation_errors_to_string(err, None))
})?; })?;
let project_item = db_models::Project::get(&string, &**pool, &redis) let result = sqlx::query!(
"
SELECT id, mod_id FROM mods_gallery
WHERE image_url = $1
",
item.url
)
.fetch_optional(&**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"Gallery item at URL {} is not part of the project's gallery.",
item.url
))
})?;
let project_item = db_models::Project::get_id(
database::models::ProjectId(result.mod_id),
&**pool,
&redis,
)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
ApiError::InvalidInput( ApiError::InvalidInput(
@ -1845,24 +1863,6 @@ pub async fn edit_gallery_item(
)); ));
} }
} }
let mut transaction = pool.begin().await?;
let id = sqlx::query!(
"
SELECT id FROM mods_gallery
WHERE image_url = $1
",
item.url
)
.fetch_optional(&mut *transaction)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"Gallery item at URL {} is not part of the project's gallery.",
item.url
))
})?
.id;
let mut transaction = pool.begin().await?; let mut transaction = pool.begin().await?;
@ -1887,7 +1887,7 @@ pub async fn edit_gallery_item(
SET featured = $2 SET featured = $2
WHERE id = $1 WHERE id = $1
", ",
id, result.id,
featured featured
) )
.execute(&mut *transaction) .execute(&mut *transaction)
@ -1900,7 +1900,7 @@ pub async fn edit_gallery_item(
SET name = $2 SET name = $2
WHERE id = $1 WHERE id = $1
", ",
id, result.id,
name name
) )
.execute(&mut *transaction) .execute(&mut *transaction)
@ -1913,7 +1913,7 @@ pub async fn edit_gallery_item(
SET description = $2 SET description = $2
WHERE id = $1 WHERE id = $1
", ",
id, result.id,
description description
) )
.execute(&mut *transaction) .execute(&mut *transaction)
@ -1926,7 +1926,7 @@ pub async fn edit_gallery_item(
SET ordering = $2 SET ordering = $2
WHERE id = $1 WHERE id = $1
", ",
id, result.id,
ordering ordering
) )
.execute(&mut *transaction) .execute(&mut *transaction)
@ -1954,7 +1954,6 @@ pub struct GalleryDeleteQuery {
pub async fn delete_gallery_item( pub async fn delete_gallery_item(
req: HttpRequest, req: HttpRequest,
web::Query(item): web::Query<GalleryDeleteQuery>, web::Query(item): web::Query<GalleryDeleteQuery>,
info: web::Path<(String,)>,
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
file_host: web::Data<Arc<dyn FileHost + Send + Sync>>, file_host: web::Data<Arc<dyn FileHost + Send + Sync>>,
@ -1969,9 +1968,28 @@ pub async fn delete_gallery_item(
) )
.await? .await?
.1; .1;
let string = info.into_inner().0;
let project_item = db_models::Project::get(&string, &**pool, &redis) let item = sqlx::query!(
"
SELECT id, image_url, raw_image_url, mod_id FROM mods_gallery
WHERE image_url = $1
",
item.url
)
.fetch_optional(&**pool)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"Gallery item at URL {} is not part of the project's gallery.",
item.url
))
})?;
let project_item = db_models::Project::get_id(
database::models::ProjectId(item.mod_id),
&**pool,
&redis,
)
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
ApiError::InvalidInput( ApiError::InvalidInput(
@ -2009,23 +2027,6 @@ pub async fn delete_gallery_item(
)); ));
} }
} }
let mut transaction = pool.begin().await?;
let item = sqlx::query!(
"
SELECT id, image_url, raw_image_url FROM mods_gallery
WHERE image_url = $1
",
item.url
)
.fetch_optional(&mut *transaction)
.await?
.ok_or_else(|| {
ApiError::InvalidInput(format!(
"Gallery item at URL {} is not part of the project's gallery.",
item.url
))
})?;
delete_old_images( delete_old_images(
Some(item.image_url), Some(item.image_url),

View File

@ -158,8 +158,8 @@ impl TemporaryDatabase {
.fetch_optional(&pool) .fetch_optional(&pool)
.await .await
.unwrap(); .unwrap();
let needs_update = !dummy_data_update let needs_update = dummy_data_update
.is_some_and(|d| d == DUMMY_DATA_UPDATE); .is_none_or(|d| d != DUMMY_DATA_UPDATE);
if needs_update { if needs_update {
println!("Dummy data updated, so template DB tables will be dropped and re-created"); println!("Dummy data updated, so template DB tables will be dropped and re-created");
// Drop all tables in the database so they can be re-created and later filled with updated dummy data // Drop all tables in the database so they can be re-created and later filled with updated dummy data

View File

@ -44,11 +44,11 @@ pub enum PackFileHash {
impl From<String> for PackFileHash { impl From<String> for PackFileHash {
fn from(s: String) -> Self { fn from(s: String) -> Self {
return match s.as_str() { match s.as_str() {
"sha1" => PackFileHash::Sha1, "sha1" => PackFileHash::Sha1,
"sha512" => PackFileHash::Sha512, "sha512" => PackFileHash::Sha512,
_ => PackFileHash::Unknown(s), _ => PackFileHash::Unknown(s),
}; }
} }
} }
@ -324,7 +324,6 @@ pub async fn generate_pack_from_file(
/// Sets generated profile attributes to the pack ones (using profile::edit) /// Sets generated profile attributes to the pack ones (using profile::edit)
/// This includes the pack name, icon, game version, loader version, and loader /// This includes the pack name, icon, game version, loader version, and loader
pub async fn set_profile_information( pub async fn set_profile_information(
profile_path: String, profile_path: String,
description: &CreatePackDescription, description: &CreatePackDescription,

View File

@ -25,7 +25,6 @@ use super::install_from::{
/// Wrapper around install_pack_files that generates a pack creation description, and /// Wrapper around install_pack_files that generates a pack creation description, and
/// attempts to install the pack files. If it fails, it will remove the profile (fail safely) /// attempts to install the pack files. If it fails, it will remove the profile (fail safely)
/// Install a modpack from a mrpack file (a modrinth .zip format) /// Install a modpack from a mrpack file (a modrinth .zip format)
pub async fn install_zipped_mrpack( pub async fn install_zipped_mrpack(
location: CreatePackLocation, location: CreatePackLocation,
profile_path: String, profile_path: String,
@ -68,7 +67,6 @@ pub async fn install_zipped_mrpack(
/// Install all pack files from a description /// Install all pack files from a description
/// Does not remove the profile if it fails /// Does not remove the profile if it fails
pub async fn install_zipped_mrpack_files( pub async fn install_zipped_mrpack_files(
create_pack: CreatePack, create_pack: CreatePack,
ignore_lock: bool, ignore_lock: bool,

View File

@ -44,7 +44,6 @@ const CLI_PROGRESS_BAR_TOTAL: u64 = 1000;
/// total is the total amount of work to be done- all emissions will be considered a fraction of this value (should be 1 or 100 for simplicity) /// total is the total amount of work to be done- all emissions will be considered a fraction of this value (should be 1 or 100 for simplicity)
/// title is the title of the loading bar /// title is the title of the loading bar
/// The app will wait for this loading bar to finish before exiting, as it is considered safe. /// The app will wait for this loading bar to finish before exiting, as it is considered safe.
pub async fn init_loading( pub async fn init_loading(
bar_type: LoadingBarType, bar_type: LoadingBarType,
total: f64, total: f64,
@ -56,7 +55,6 @@ pub async fn init_loading(
/// An unsafe loading bar can be created without adding it to the SafeProcesses list, /// An unsafe loading bar can be created without adding it to the SafeProcesses list,
/// meaning that the app won't ask to wait for it to finish before exiting. /// meaning that the app won't ask to wait for it to finish before exiting.
pub async fn init_loading_unsafe( pub async fn init_loading_unsafe(
bar_type: LoadingBarType, bar_type: LoadingBarType,
total: f64, total: f64,

View File

@ -38,5 +38,6 @@
"fix": { "fix": {
"cache": false "cache": false
} }
} },
"ui": "tui"
} }