diff --git a/.env b/.env index d24a0d20..07dc02ee 100644 --- a/.env +++ b/.env @@ -103,4 +103,6 @@ FLAME_ANVIL_URL=none STRIPE_API_KEY=none STRIPE_WEBHOOK_SECRET=none -ADITUDE_API_KEY=none \ No newline at end of file +ADITUDE_API_KEY=none + +PYRO_API_KEY=none \ No newline at end of file diff --git a/.sqlx/query-375262713edeccbdf9770a180bf4feaad606fb3aa80a749a36b323d77b01931a.json b/.sqlx/query-375262713edeccbdf9770a180bf4feaad606fb3aa80a749a36b323d77b01931a.json new file mode 100644 index 00000000..3f7a136b --- /dev/null +++ b/.sqlx/query-375262713edeccbdf9770a180bf4feaad606fb3aa80a749a36b323d77b01931a.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Int8", + "Text", + "Int8", + "Text", + "Varchar", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "375262713edeccbdf9770a180bf4feaad606fb3aa80a749a36b323d77b01931a" +} diff --git a/.sqlx/query-3c2665e46fe6a66cba9382426bed07db828f2a8b81cb6d9e640b94d8d12eb28f.json b/.sqlx/query-3c2665e46fe6a66cba9382426bed07db828f2a8b81cb6d9e640b94d8d12eb28f.json new file mode 100644 index 00000000..889b0639 --- /dev/null +++ b/.sqlx/query-3c2665e46fe6a66cba9382426bed07db828f2a8b81cb6d9e640b94d8d12eb28f.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt\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": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "interval", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "due", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "last_attempt", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + false, + false, + false + ] + }, + "hash": "3c2665e46fe6a66cba9382426bed07db828f2a8b81cb6d9e640b94d8d12eb28f" +} diff --git a/.sqlx/query-75d10a180e66d5741318da652b7192c73c1d4e8bdb43b0755607f1182d24c46d.json b/.sqlx/query-75d10a180e66d5741318da652b7192c73c1d4e8bdb43b0755607f1182d24c46d.json new file mode 100644 index 00000000..1cd31c4e --- /dev/null +++ b/.sqlx/query-75d10a180e66d5741318da652b7192c73c1d4e8bdb43b0755607f1182d24c46d.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users_subscriptions (\n id, user_id, price_id, interval, created, expires, status, metadata\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8\n )\n ON CONFLICT (id)\n DO UPDATE\n SET interval = EXCLUDED.interval,\n expires = EXCLUDED.expires,\n status = EXCLUDED.status,\n price_id = EXCLUDED.price_id,\n metadata = EXCLUDED.metadata\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Text", + "Timestamptz", + "Timestamptz", + "Varchar", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "75d10a180e66d5741318da652b7192c73c1d4e8bdb43b0755607f1182d24c46d" +} diff --git a/.sqlx/query-7aae2ea58ac43d7597a8075692359012beddbf0de01a78e63101ef265f096a03.json b/.sqlx/query-7aae2ea58ac43d7597a8075692359012beddbf0de01a78e63101ef265f096a03.json new file mode 100644 index 00000000..7b6d4f03 --- /dev/null +++ b/.sqlx/query-7aae2ea58ac43d7597a8075692359012beddbf0de01a78e63101ef265f096a03.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET badges = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "7aae2ea58ac43d7597a8075692359012beddbf0de01a78e63101ef265f096a03" +} diff --git a/.sqlx/query-07afad3b85ed64acbe9584570fdec92f923abf17439f0011e2b46797cec0ad97.json b/.sqlx/query-80673778f6fea2dc776e722376d6b6aab6d500d836b01094076cb751c3c349e3.json similarity index 76% rename from .sqlx/query-07afad3b85ed64acbe9584570fdec92f923abf17439f0011e2b46797cec0ad97.json rename to .sqlx/query-80673778f6fea2dc776e722376d6b6aab6d500d836b01094076cb751c3c349e3.json index bc77da8c..b87784f2 100644 --- a/.sqlx/query-07afad3b85ed64acbe9584570fdec92f923abf17439f0011e2b46797cec0ad97.json +++ b/.sqlx/query-80673778f6fea2dc776e722376d6b6aab6d500d836b01094076cb751c3c349e3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, interval, created, expires, last_charge, status\n FROM users_subscriptions\n WHERE id = ANY($1::bigint[])", + "query": "\n SELECT\n id, user_id, price_id, interval, created, expires, status, metadata\n FROM users_subscriptions\n WHERE id = ANY($1::bigint[])", "describe": { "columns": [ { @@ -35,13 +35,13 @@ }, { "ordinal": 6, - "name": "last_charge", - "type_info": "Timestamptz" + "name": "status", + "type_info": "Varchar" }, { "ordinal": 7, - "name": "status", - "type_info": "Varchar" + "name": "metadata", + "type_info": "Jsonb" } ], "parameters": { @@ -56,9 +56,9 @@ false, false, false, - true, - false + false, + true ] }, - "hash": "07afad3b85ed64acbe9584570fdec92f923abf17439f0011e2b46797cec0ad97" + "hash": "80673778f6fea2dc776e722376d6b6aab6d500d836b01094076cb751c3c349e3" } diff --git a/.sqlx/query-95b52480a3fc7257a95e1cbc0e05f13c7934e3019675c04d9d3f240eb590bdc4.json b/.sqlx/query-95b52480a3fc7257a95e1cbc0e05f13c7934e3019675c04d9d3f240eb590bdc4.json new file mode 100644 index 00000000..baa4b0c8 --- /dev/null +++ b/.sqlx/query-95b52480a3fc7257a95e1cbc0e05f13c7934e3019675c04d9d3f240eb590bdc4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM charges WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "95b52480a3fc7257a95e1cbc0e05f13c7934e3019675c04d9d3f240eb590bdc4" +} diff --git a/.sqlx/query-e82ba8bafb4e45b8a8a100c639a9174f196a50cd74c9243ddd57d6f4f3d0b062.json b/.sqlx/query-e82ba8bafb4e45b8a8a100c639a9174f196a50cd74c9243ddd57d6f4f3d0b062.json deleted file mode 100644 index 6546eb3d..00000000 --- a/.sqlx/query-e82ba8bafb4e45b8a8a100c639a9174f196a50cd74c9243ddd57d6f4f3d0b062.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO users_subscriptions (\n id, user_id, price_id, interval, created, expires, last_charge, status\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8\n )\n ON CONFLICT (id)\n DO UPDATE\n SET interval = EXCLUDED.interval,\n expires = EXCLUDED.expires,\n last_charge = EXCLUDED.last_charge,\n status = EXCLUDED.status,\n price_id = EXCLUDED.price_id\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Int8", - "Text", - "Timestamptz", - "Timestamptz", - "Timestamptz", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "e82ba8bafb4e45b8a8a100c639a9174f196a50cd74c9243ddd57d6f4f3d0b062" -} diff --git a/.sqlx/query-f1c1442c72e7b9761e1786e8ac8596db21e1daaa74f53381b47847fc9229a993.json b/.sqlx/query-f1c1442c72e7b9761e1786e8ac8596db21e1daaa74f53381b47847fc9229a993.json new file mode 100644 index 00000000..dda71d1c --- /dev/null +++ b/.sqlx/query-f1c1442c72e7b9761e1786e8ac8596db21e1daaa74f53381b47847fc9229a993.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt\n FROM charges\n WHERE user_id = $1", + "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": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "interval", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "due", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "last_attempt", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + false, + false, + false + ] + }, + "hash": "f1c1442c72e7b9761e1786e8ac8596db21e1daaa74f53381b47847fc9229a993" +} diff --git a/.sqlx/query-d6d3c29ff2aa3b311a19225cefcd5b8844fbe5bedf44ffe24f31b12e5bc5f868.json b/.sqlx/query-f3a021c0eeb5c02592d7dddc6c5f70122465388c2a9b1837aa572d69baa38fe1.json similarity index 77% rename from .sqlx/query-d6d3c29ff2aa3b311a19225cefcd5b8844fbe5bedf44ffe24f31b12e5bc5f868.json rename to .sqlx/query-f3a021c0eeb5c02592d7dddc6c5f70122465388c2a9b1837aa572d69baa38fe1.json index df277ea6..49f25866 100644 --- a/.sqlx/query-d6d3c29ff2aa3b311a19225cefcd5b8844fbe5bedf44ffe24f31b12e5bc5f868.json +++ b/.sqlx/query-f3a021c0eeb5c02592d7dddc6c5f70122465388c2a9b1837aa572d69baa38fe1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, interval, created, expires, last_charge, status\n FROM users_subscriptions\n WHERE user_id = $1", + "query": "\n SELECT\n id, user_id, price_id, interval, created, expires, status, metadata\n FROM users_subscriptions\n WHERE user_id = $1", "describe": { "columns": [ { @@ -35,13 +35,13 @@ }, { "ordinal": 6, - "name": "last_charge", - "type_info": "Timestamptz" + "name": "status", + "type_info": "Varchar" }, { "ordinal": 7, - "name": "status", - "type_info": "Varchar" + "name": "metadata", + "type_info": "Jsonb" } ], "parameters": { @@ -56,9 +56,9 @@ false, false, false, - true, - false + false, + true ] }, - "hash": "d6d3c29ff2aa3b311a19225cefcd5b8844fbe5bedf44ffe24f31b12e5bc5f868" + "hash": "f3a021c0eeb5c02592d7dddc6c5f70122465388c2a9b1837aa572d69baa38fe1" } diff --git a/.sqlx/query-f643ba5f92e5f76cc2f9d2016f52dc03483c1e578dd5ea39119fcf5ad58d8250.json b/.sqlx/query-f643ba5f92e5f76cc2f9d2016f52dc03483c1e578dd5ea39119fcf5ad58d8250.json deleted file mode 100644 index 75db1e6c..00000000 --- a/.sqlx/query-f643ba5f92e5f76cc2f9d2016f52dc03483c1e578dd5ea39119fcf5ad58d8250.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE users\n SET badges = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "f643ba5f92e5f76cc2f9d2016f52dc03483c1e578dd5ea39119fcf5ad58d8250" -} diff --git a/.sqlx/query-61a87b00baaba022ab32eedf177d5b9dc6d5b7568cf1df15fac6c9e85acfa448.json b/.sqlx/query-f6cb97b547c80e1f47467f33bbc641e3504bab316de4e30ea263ceb3ffcbc210.json similarity index 59% rename from .sqlx/query-61a87b00baaba022ab32eedf177d5b9dc6d5b7568cf1df15fac6c9e85acfa448.json rename to .sqlx/query-f6cb97b547c80e1f47467f33bbc641e3504bab316de4e30ea263ceb3ffcbc210.json index a91ed2eb..2dda14a0 100644 --- a/.sqlx/query-61a87b00baaba022ab32eedf177d5b9dc6d5b7568cf1df15fac6c9e85acfa448.json +++ b/.sqlx/query-f6cb97b547c80e1f47467f33bbc641e3504bab316de4e30ea263ceb3ffcbc210.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, interval, created, expires, last_charge, status\n FROM users_subscriptions\n WHERE expires < $1", + "query": "\n SELECT id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt\n FROM charges\n WHERE id = $1", "describe": { "columns": [ { @@ -20,33 +20,43 @@ }, { "ordinal": 3, - "name": "interval", - "type_info": "Text" + "name": "amount", + "type_info": "Int8" }, { "ordinal": 4, - "name": "created", - "type_info": "Timestamptz" + "name": "currency_code", + "type_info": "Text" }, { "ordinal": 5, - "name": "expires", - "type_info": "Timestamptz" + "name": "subscription_id", + "type_info": "Int8" }, { "ordinal": 6, - "name": "last_charge", - "type_info": "Timestamptz" + "name": "interval", + "type_info": "Text" }, { "ordinal": 7, "name": "status", "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "due", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "last_attempt", + "type_info": "Timestamptz" } ], "parameters": { "Left": [ - "Timestamptz" + "Int8" ] }, "nullable": [ @@ -55,10 +65,12 @@ false, false, false, - false, true, + true, + false, + false, false ] }, - "hash": "61a87b00baaba022ab32eedf177d5b9dc6d5b7568cf1df15fac6c9e85acfa448" + "hash": "f6cb97b547c80e1f47467f33bbc641e3504bab316de4e30ea263ceb3ffcbc210" } diff --git a/migrations/20240923163452_charges-fix.sql b/migrations/20240923163452_charges-fix.sql index 378bd488..2df73e6a 100644 --- a/migrations/20240923163452_charges-fix.sql +++ b/migrations/20240923163452_charges-fix.sql @@ -4,9 +4,12 @@ CREATE TABLE charges ( price_id bigint REFERENCES products_prices NOT NULL, amount bigint NOT NULL, currency_code text NOT NULL, - subscription_id bigint REFERENCES users_subscriptions NULL, + subscription_id bigint NULL, interval text NULL, status varchar(255) NOT NULL, due timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, last_attempt timestamptz NOT NULL -); \ No newline at end of file +); + +ALTER TABLE users_subscriptions DROP COLUMN last_charge; +ALTER TABLE users_subscriptions ADD COLUMN metadata jsonb NULL; \ No newline at end of file diff --git a/src/database/models/charge_item.rs b/src/database/models/charge_item.rs index af535a7a..1320c605 100644 --- a/src/database/models/charge_item.rs +++ b/src/database/models/charge_item.rs @@ -3,7 +3,7 @@ use crate::database::models::{ }; use crate::models::billing::{ChargeStatus, PriceDuration}; use chrono::{DateTime, Utc}; -use std::convert::TryFrom; +use std::convert::{TryFrom, TryInto}; pub struct ChargeItem { pub id: ChargeId, @@ -65,14 +65,19 @@ macro_rules! select_charges_with_predicate { } impl ChargeItem { - pub async fn insert( + pub async fn upsert( &self, - exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result { sqlx::query!( r#" INSERT INTO charges (id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) + DO UPDATE + SET status = EXCLUDED.status, + last_attempt = EXCLUDED.last_attempt, + due = EXCLUDED.due "#, self.id.0, self.user_id.0, @@ -85,7 +90,7 @@ impl ChargeItem { self.due, self.last_attempt, ) - .execute(exec) + .execute(&mut **transaction) .await?; Ok(self.id) @@ -95,17 +100,19 @@ impl ChargeItem { id: ChargeId, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result, DatabaseError> { + let id = id.0; let res = select_charges_with_predicate!("WHERE id = $1", id) .fetch_optional(exec) .await?; - Ok(res.map(|r| r.try_into())) + Ok(res.and_then(|r| r.try_into().ok())) } pub async fn get_from_user( user_id: UserId, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result, DatabaseError> { + let user_id = user_id.0; let res = select_charges_with_predicate!("WHERE user_id = $1", user_id) .fetch_all(exec) .await?; @@ -119,7 +126,9 @@ impl ChargeItem { pub async fn get_chargeable( exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result, DatabaseError> { - let res = select_charges_with_predicate!("WHERE (status = 'open' AND due < NOW()) OR (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')") + let now = Utc::now(); + + let res = select_charges_with_predicate!("WHERE (status = 'open' AND due < $1) OR (status = 'failed' AND last_attempt < $1 - INTERVAL '2 days')", now) .fetch_all(exec) .await?; diff --git a/src/database/models/user_subscription_item.rs b/src/database/models/user_subscription_item.rs index fb5af314..d851893a 100644 --- a/src/database/models/user_subscription_item.rs +++ b/src/database/models/user_subscription_item.rs @@ -1,7 +1,8 @@ use crate::database::models::{DatabaseError, ProductPriceId, UserId, UserSubscriptionId}; -use crate::models::billing::{PriceDuration, SubscriptionStatus}; +use crate::models::billing::{PriceDuration, SubscriptionMetadata, SubscriptionStatus}; use chrono::{DateTime, Utc}; use itertools::Itertools; +use std::convert::{TryFrom, TryInto}; pub struct UserSubscriptionItem { pub id: UserSubscriptionId, @@ -10,8 +11,8 @@ pub struct UserSubscriptionItem { pub interval: PriceDuration, pub created: DateTime, pub expires: DateTime, - pub last_charge: Option>, pub status: SubscriptionStatus, + pub metadata: Option, } struct UserSubscriptionResult { @@ -21,8 +22,8 @@ struct UserSubscriptionResult { interval: String, pub created: DateTime, pub expires: DateTime, - pub last_charge: Option>, pub status: String, + pub metadata: serde_json::Value, } macro_rules! select_user_subscriptions_with_predicate { @@ -31,7 +32,7 @@ macro_rules! select_user_subscriptions_with_predicate { UserSubscriptionResult, r#" SELECT - id, user_id, price_id, interval, created, expires, last_charge, status + id, user_id, price_id, interval, created, expires, status, metadata FROM users_subscriptions "# + $predicate, @@ -40,18 +41,20 @@ macro_rules! select_user_subscriptions_with_predicate { }; } -impl From for UserSubscriptionItem { - fn from(r: UserSubscriptionResult) -> Self { - UserSubscriptionItem { +impl TryFrom for UserSubscriptionItem { + type Error = serde_json::Error; + + fn try_from(r: UserSubscriptionResult) -> Result { + Ok(UserSubscriptionItem { id: UserSubscriptionId(r.id), user_id: UserId(r.user_id), price_id: ProductPriceId(r.price_id), interval: PriceDuration::from_string(&r.interval), created: r.created, expires: r.expires, - last_charge: r.last_charge, status: SubscriptionStatus::from_string(&r.status), - } + metadata: serde_json::from_value(r.metadata)?, + }) } } @@ -74,7 +77,10 @@ impl UserSubscriptionItem { .fetch_all(exec) .await?; - Ok(results.into_iter().map(|r| r.into()).collect()) + Ok(results + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) } pub async fn get_all_user( @@ -86,7 +92,10 @@ impl UserSubscriptionItem { .fetch_all(exec) .await?; - Ok(results.into_iter().map(|r| r.into()).collect()) + Ok(results + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) } pub async fn upsert( @@ -96,7 +105,7 @@ impl UserSubscriptionItem { sqlx::query!( " INSERT INTO users_subscriptions ( - id, user_id, price_id, interval, created, expires, last_charge, status + id, user_id, price_id, interval, created, expires, status, metadata ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8 @@ -105,9 +114,9 @@ impl UserSubscriptionItem { DO UPDATE SET interval = EXCLUDED.interval, expires = EXCLUDED.expires, - last_charge = EXCLUDED.last_charge, status = EXCLUDED.status, - price_id = EXCLUDED.price_id + price_id = EXCLUDED.price_id, + metadata = EXCLUDED.metadata ", self.id.0, self.user_id.0, @@ -115,8 +124,8 @@ impl UserSubscriptionItem { self.interval.as_str(), self.created, self.expires, - self.last_charge, self.status.as_str(), + serde_json::to_value(&self.metadata)?, ) .execute(&mut **transaction) .await?; diff --git a/src/lib.rs b/src/lib.rs index 29b5af1c..bde9760b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -456,5 +456,7 @@ pub fn check_env_vars() -> bool { failed |= check_var::("ADITUDE_API_KEY"); + failed |= check_var::("PYRO_API_KEY"); + failed } diff --git a/src/models/v3/billing.rs b/src/models/v3/billing.rs index c1e3d703..30d2917f 100644 --- a/src/models/v3/billing.rs +++ b/src/models/v3/billing.rs @@ -21,6 +21,7 @@ pub struct Product { #[serde(tag = "type", rename_all = "kebab-case")] pub enum ProductMetadata { Midas, + Pyro { ram: u32 }, } #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] @@ -55,6 +56,13 @@ pub enum PriceDuration { } impl PriceDuration { + pub fn duration(&self) -> chrono::Duration { + match self { + PriceDuration::Monthly => chrono::Duration::days(30), + PriceDuration::Yearly => chrono::Duration::days(365), + } + } + pub fn from_string(string: &str) -> PriceDuration { match string { "monthly" => PriceDuration::Monthly, @@ -85,7 +93,7 @@ pub struct UserSubscription { pub status: SubscriptionStatus, pub created: DateTime, pub expires: DateTime, - pub last_charge: Option>, + pub metadata: Option, } impl From @@ -100,7 +108,7 @@ impl From status: x.status, created: x.created, expires: x.expires, - last_charge: x.last_charge, + metadata: x.metadata, } } } @@ -135,6 +143,12 @@ impl SubscriptionStatus { } } +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum SubscriptionMetadata { + Pyro { id: String }, +} + #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] #[serde(from = "Base62Id")] #[serde(into = "Base62Id")] @@ -154,7 +168,7 @@ pub struct Charge { pub last_attempt: Option>, } -#[derive(Serialize, Deserialize, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "kebab-case")] pub enum ChargeStatus { // Open charges are for the next billing interval diff --git a/src/routes/internal/billing.rs b/src/routes/internal/billing.rs index 424d0a1f..6b7265e4 100644 --- a/src/routes/internal/billing.rs +++ b/src/routes/internal/billing.rs @@ -5,7 +5,7 @@ use crate::database::models::{ use crate::database::redis::RedisPool; use crate::models::billing::{ Charge, ChargeStatus, Price, PriceDuration, Product, ProductMetadata, ProductPrice, - SubscriptionStatus, UserSubscription, + SubscriptionMetadata, SubscriptionStatus, UserSubscription, }; use crate::models::ids::base62_impl::{parse_base62, to_base62}; use crate::models::pats::Scopes; @@ -15,15 +15,16 @@ use crate::routes::ApiError; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::{Duration, Utc}; use log::{info, warn}; +use serde::Serialize; use serde_with::serde_derive::Deserialize; -use sqlx::PgPool; +use sqlx::{PgPool, Postgres, Transaction}; use std::collections::{HashMap, HashSet}; use std::str::FromStr; use stripe::{ CreateCustomer, CreatePaymentIntent, CreatePaymentIntentAutomaticPaymentMethods, CreateSetupIntent, CreateSetupIntentAutomaticPaymentMethods, CreateSetupIntentAutomaticPaymentMethodsAllowRedirects, Currency, CustomerId, - CustomerInvoiceSettings, CustomerPaymentMethodRetrieval, EventObject, EventType, ListCharges, + CustomerInvoiceSettings, CustomerPaymentMethodRetrieval, EventObject, EventType, PaymentIntentOffSession, PaymentIntentSetupFutureUsage, PaymentMethodId, SetupIntent, UpdateCustomer, Webhook, }; @@ -34,7 +35,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(products) .service(subscriptions) .service(user_customer) - .service(cancel_subscription) + .service(edit_subscription) .service(payment_methods) .service(add_payment_method_flow) .service(edit_payment_method) @@ -101,13 +102,21 @@ pub async fn subscriptions( Ok(HttpResponse::Ok().json(subscriptions)) } -#[delete("subscription/{id}")] -pub async fn cancel_subscription( +#[derive(Deserialize)] +pub struct SubscriptionEdit { + pub interval: Option, + pub status: Option, + pub product: Option, +} + +#[patch("subscription/{id}")] +pub async fn edit_subscription( req: HttpRequest, info: web::Path<(crate::models::ids::UserSubscriptionId,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, + edit_subscription: web::Json, ) -> Result { let user = get_user_from_headers( &req, @@ -128,6 +137,11 @@ pub async fn cancel_subscription( return Err(ApiError::NotFound); } + // cancel: set status to cancelled + delete all open charges + // uncancel: set status to active + create new open charge + // change interval: set interval + update existing open charge + //.change plan: update existing open charge + let mut transaction = pool.begin().await?; if subscription.expires < Utc::now() { @@ -480,12 +494,22 @@ pub enum ChargeRequestType { }, } +#[derive(Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PaymentRequestMetadata { + Pyro { + server_name: Option, + source: serde_json::Value, + }, +} + #[derive(Deserialize)] pub struct PaymentRequest { #[serde(flatten)] pub type_: PaymentRequestType, pub charge: ChargeRequestType, pub existing_payment_intent: Option, + pub metadata: Option, } fn infer_currency_code(country: &str) -> String { @@ -697,22 +721,14 @@ pub async fn initiate_payment( ) .await?; - if let Some(product) = user_products + if user_products .into_iter() .find(|x| x.product_id == product.id) + .is_some() { - if let Some(subscription) = user_subscriptions - .into_iter() - .find(|x| x.price_id == product.id) - { - return Err(ApiError::InvalidInput(if !(subscription.status == SubscriptionStatus::Cancelled - || subscription.status == SubscriptionStatus::PaymentFailed) - { - "You are already subscribed to this product!" - } else { - "You are already subscribed to this product, but the payment failed!" - }.to_string())); - } + return Err(ApiError::InvalidInput( + "You are already subscribed to this product!".to_string(), + )); } } } @@ -755,7 +771,7 @@ pub async fn initiate_payment( .await?; Ok(HttpResponse::Ok().json(serde_json::json!({ - "price_id": to_base62(price_item.id.0 as u64), + "price_id": to_base62(price_id.0 as u64), "tax": 0, "total": price, "payment_method": payment_method, @@ -766,9 +782,29 @@ pub async fn initiate_payment( let mut metadata = HashMap::new(); metadata.insert("modrinth_user_id".to_string(), to_base62(user.id.0)); + if let Some(payment_metadata) = &payment_request.metadata { + metadata.insert( + "modrinth_payment_metadata".to_string(), + serde_json::to_string(&payment_metadata)?, + ); + } + if let Some(charge_id) = charge_id { metadata.insert("modrinth_charge_id".to_string(), to_base62(charge_id.0)); } else { + let mut transaction = pool.begin().await?; + let charge_id = generate_charge_id(&mut transaction).await?; + let subscription_id = generate_user_subscription_id(&mut transaction).await?; + + metadata.insert( + "modrinth_charge_id".to_string(), + to_base62(charge_id.0 as u64), + ); + metadata.insert( + "modrinth_subscription_id".to_string(), + to_base62(subscription_id.0 as u64), + ); + metadata.insert( "modrinth_price_id".to_string(), to_base62(price_id.0 as u64), @@ -800,7 +836,7 @@ pub async fn initiate_payment( Ok(HttpResponse::Ok().json(serde_json::json!({ "payment_intent_id": payment_intent.id, "client_secret": payment_intent.client_secret, - "price_id": to_base62(price_item.id.0 as u64), + "price_id": to_base62(price_id.0 as u64), "tax": 0, "total": price, "payment_method": payment_method, @@ -828,79 +864,199 @@ pub async fn stripe_webhook( &dotenvy::var("STRIPE_WEBHOOK_SECRET")?, ) { struct PaymentIntentMetadata { - user: crate::database::models::User, - user_subscription_data: Option<( - crate::database::models::ids::UserSubscriptionId, - PriceDuration, - )>, - user_subscription: Option, - product: product_item::ProductItem, - product_price: product_item::ProductPriceItem, - charge_item: Option, + pub user_item: crate::database::models::user_item::User, + pub product_price_item: product_item::ProductPriceItem, + pub product_item: product_item::ProductItem, + pub charge_item: crate::database::models::charge_item::ChargeItem, + pub user_subscription_item: Option, + pub payment_metadata: Option, } async fn get_payment_intent_metadata( metadata: HashMap, pool: &PgPool, redis: &RedisPool, + charge_status: ChargeStatus, + subscription_status: SubscriptionStatus, + transaction: &mut Transaction<'_, Postgres>, ) -> Result { - if let Some(user_id) = metadata - .get("modrinth_user_id") - .and_then(|x| parse_base62(x).ok()) - .map(|x| crate::database::models::ids::UserId(x as i64)) - { - let user = - crate::database::models::user_item::User::get_id(user_id, pool, redis).await?; + 'metadata: { + let user_id = if let Some(user_id) = metadata + .get("modrinth_user_id") + .and_then(|x| parse_base62(x).ok()) + .map(|x| crate::database::models::ids::UserId(x as i64)) + { + user_id + } else { + break 'metadata; + }; + + let user = if let Some(user) = + crate::database::models::user_item::User::get_id(user_id, pool, redis).await? + { + user + } else { + break 'metadata; + }; + + let payment_metadata = metadata + .get("modrinth_payment_metadata") + .and_then(|x| serde_json::from_str(x).ok()); + + let charge_id = if let Some(charge_id) = metadata + .get("modrinth_charge_id") + .and_then(|x| parse_base62(x).ok()) + .map(|x| crate::database::models::ids::ChargeId(x as i64)) + { + charge_id + } else { + break 'metadata; + }; + + let (charge, price, product, subscription) = if let Some(mut charge) = + crate::database::models::charge_item::ChargeItem::get(charge_id, pool).await? + { + let price = if let Some(price) = + product_item::ProductPriceItem::get(charge.price_id, pool).await? + { + price + } else { + break 'metadata; + }; - if let Some(user) = user { - let (user_subscription_data, user_subscription) = if let Some(subscription_id) = - metadata - .get("modrinth_subscription_id") - .and_then(|x| parse_base62(x).ok()) - .map(|x| crate::database::models::ids::UserSubscriptionId(x as i64)) + let product = if let Some(product) = + product_item::ProductItem::get(price.product_id, pool).await? { - if let Some(interval) = metadata - .get("modrinth_subscription_interval") - .map(|x| PriceDuration::from_string(x)) - { - let subscription = user_subscription_item::UserSubscriptionItem::get( - subscription_id, - pool, - ) - .await?; + product + } else { + break 'metadata; + }; - (Some((subscription_id, interval)), subscription) + charge.status = charge_status; + charge.last_attempt = Some(Utc::now()); + charge.upsert(transaction).await?; + + if let Some(subscription_id) = charge.subscription_id { + let mut subscription = if let Some(subscription) = + user_subscription_item::UserSubscriptionItem::get(subscription_id, pool) + .await? + { + subscription } else { - (None, None) + break 'metadata; + }; + + if charge_status == ChargeStatus::Succeeded { + subscription.expires = Utc::now() + subscription.interval.duration(); } - } else { - (None, None) - }; + subscription.status = subscription_status; + subscription.upsert(transaction).await?; - if let Some(price_id) = metadata + (charge, price, product, Some(subscription)) + } else { + (charge, price, product, None) + } + } else { + let price_id = if let Some(price_id) = metadata .get("modrinth_price_id") .and_then(|x| parse_base62(x).ok()) .map(|x| crate::database::models::ids::ProductPriceId(x as i64)) { - let price = product_item::ProductPriceItem::get(price_id, pool).await?; + price_id + } else { + break 'metadata; + }; - if let Some(product_price) = price { - let product = - product_item::ProductItem::get(product_price.product_id, pool) - .await?; + let price = if let Some(price) = + product_item::ProductPriceItem::get(price_id, pool).await? + { + price + } else { + break 'metadata; + }; - if let Some(product) = product { - return Ok(PaymentIntentMetadata { - user, - user_subscription_data, - user_subscription, - product, - product_price, - }); + let product = if let Some(product) = + product_item::ProductItem::get(price.product_id, pool).await? + { + product + } else { + break 'metadata; + }; + + let (amount, subscription) = match &price.prices { + Price::OneTime { price } => (*price, None), + Price::Recurring { intervals } => { + let interval = if let Some(interval) = metadata + .get("modrinth_subscription_interval") + .map(|x| PriceDuration::from_string(x)) + { + interval + } else { + break 'metadata; + }; + + if let Some(price) = intervals.get(&interval) { + let subscription_id = if let Some(subscription_id) = metadata + .get("modrinth_subscription_id") + .and_then(|x| parse_base62(x).ok()) + .map(|x| { + crate::database::models::ids::UserSubscriptionId(x as i64) + }) { + subscription_id + } else { + break 'metadata; + }; + + let subscription = user_subscription_item::UserSubscriptionItem { + id: subscription_id, + user_id, + price_id, + interval, + created: Utc::now(), + expires: Utc::now() + interval.duration(), + status: subscription_status, + metadata: None, + }; + + if charge_status != ChargeStatus::Failed { + subscription.upsert(transaction).await?; + } + + (*price, Some(subscription)) + } else { + break 'metadata; } } + }; + + let charge = crate::database::models::charge_item::ChargeItem { + id: charge_id, + user_id, + price_id, + amount: amount as i64, + currency_code: price.currency_code.clone(), + subscription_id: subscription.as_ref().map(|x| x.id), + interval: subscription.as_ref().map(|x| x.interval), + status: charge_status, + due: Utc::now(), + last_attempt: Some(Utc::now()), + }; + + if charge_status != ChargeStatus::Failed { + charge.upsert(transaction).await?; } - } + + (charge, price, product, subscription) + }; + + return Ok(PaymentIntentMetadata { + user_item: user, + product_price_item: price, + product_item: product, + charge_item: charge, + user_subscription_item: subscription, + payment_metadata, + }); } Err(ApiError::InvalidInput( @@ -911,43 +1067,22 @@ pub async fn stripe_webhook( match event.type_ { EventType::PaymentIntentSucceeded => { if let EventObject::PaymentIntent(payment_intent) = event.data.object { - let metadata = - get_payment_intent_metadata(payment_intent.metadata, &pool, &redis).await?; - let mut transaction = pool.begin().await?; - if let Some((subscription_id, interval)) = metadata.user_subscription_data { - let duration = match interval { - PriceDuration::Monthly => Duration::days(30), - PriceDuration::Yearly => Duration::days(365), - }; - - if let Some(mut user_subscription) = metadata.user_subscription { - user_subscription.expires += duration; - user_subscription.status = SubscriptionStatus::Active; - user_subscription.interval = interval; - user_subscription.price_id = metadata.product_price.id; - user_subscription.upsert(&mut transaction).await?; - } else { - user_subscription_item::UserSubscriptionItem { - id: subscription_id, - user_id: metadata.user.id, - price_id: metadata.product_price.id, - interval, - created: Utc::now(), - expires: Utc::now() + duration, - last_charge: None, - status: SubscriptionStatus::Active, - } - .upsert(&mut transaction) - .await?; - } - } + let metadata = get_payment_intent_metadata( + payment_intent.metadata, + &pool, + &redis, + ChargeStatus::Succeeded, + SubscriptionStatus::Active, + &mut transaction, + ) + .await?; // Provision subscription - match metadata.product.metadata { + match metadata.product_item.metadata { ProductMetadata::Midas => { - let badges = metadata.user.badges | Badges::MIDAS; + let badges = metadata.user_item.badges | Badges::MIDAS; sqlx::query!( " @@ -956,16 +1091,86 @@ pub async fn stripe_webhook( WHERE (id = $2) ", badges.bits() as i64, - metadata.user.id as crate::database::models::ids::UserId, + metadata.user_item.id as crate::database::models::ids::UserId, ) .execute(&mut *transaction) .await?; } + ProductMetadata::Pyro { ram } => { + if let Some(ref subscription) = metadata.user_subscription_item { + let client = reqwest::Client::new(); + + if let Some(SubscriptionMetadata::Pyro { id }) = + &subscription.metadata + { + let res = client + .post(format!( + "https://archon.pyro.host/v0/servers/{}/unsuspend", + id + )) + .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) + .send() + .await; + + if let Err(e) = res { + warn!("Error unsuspending pyro server: {:?}", e); + } + } else { + if let Some(PaymentRequestMetadata::Pyro { + server_name, + source, + }) = &metadata.payment_metadata + { + let server_name = + server_name.clone().unwrap_or_else(|| { + format!("{}'s server", metadata.user_item.username) + }); + + let res = client + .post("https://archon.pyro.host/v0/servers/create") + .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) + .json(&serde_json::json!({ + "user_id": to_base62(metadata.user_item.id.0 as u64), + "name": server_name, + "specs": { + "ram": ram, + "cpu": std::cmp::max(2, (ram / 1024) / 2), + "swap": ram / 4, + }, + "source": source, + })) + .send() + .await; + + if let Err(e) = res { + warn!("Error creating pyro server: {:?}", e); + } + } + } + } + } + } + + if let Some(subscription) = metadata.user_subscription_item { + let charge_id = generate_charge_id(&mut transaction).await?; + let charge = crate::database::models::charge_item::ChargeItem { + id: charge_id, + user_id: metadata.user_item.id, + price_id: metadata.product_price_item.id, + amount: metadata.charge_item.amount, + currency_code: metadata.product_price_item.currency_code, + subscription_id: Some(subscription.id), + interval: Some(subscription.interval), + status: ChargeStatus::Open, + due: subscription.expires, + last_attempt: None, + }; + charge.upsert(&mut transaction).await?; } transaction.commit().await?; crate::database::models::user_item::User::clear_caches( - &[(metadata.user.id, None)], + &[(metadata.user_item.id, None)], &redis, ) .await?; @@ -973,83 +1178,47 @@ pub async fn stripe_webhook( } EventType::PaymentIntentProcessing => { if let EventObject::PaymentIntent(payment_intent) = event.data.object { - let metadata = - get_payment_intent_metadata(payment_intent.metadata, &pool, &redis).await?; - let mut transaction = pool.begin().await?; - - if let Some((subscription_id, interval)) = metadata.user_subscription_data { - if let Some(mut user_subscription) = metadata.user_subscription { - user_subscription.status = SubscriptionStatus::PaymentProcessing; - user_subscription.interval = interval; - user_subscription.price_id = metadata.product_price.id; - user_subscription.upsert(&mut transaction).await?; - } else { - user_subscription_item::UserSubscriptionItem { - id: subscription_id, - user_id: metadata.user.id, - price_id: metadata.product_price.id, - interval, - created: Utc::now(), - expires: Utc::now(), - last_charge: None, - status: SubscriptionStatus::PaymentProcessing, - } - .upsert(&mut transaction) - .await?; - } - } - + get_payment_intent_metadata( + payment_intent.metadata, + &pool, + &redis, + ChargeStatus::Processing, + SubscriptionStatus::PaymentProcessing, + &mut transaction, + ) + .await?; transaction.commit().await?; } } EventType::PaymentIntentPaymentFailed => { if let EventObject::PaymentIntent(payment_intent) = event.data.object { - let metadata = - get_payment_intent_metadata(payment_intent.metadata, &pool, &redis).await?; - let mut transaction = pool.begin().await?; - let price = match metadata.product_price.prices { - Price::OneTime { price } => Some(price), - Price::Recurring { intervals } => { - if let Some((_subscription_id, interval)) = - metadata.user_subscription_data - { - if let Some(mut user_subscription) = metadata.user_subscription { - user_subscription.last_charge = Some(Utc::now()); - user_subscription.status = SubscriptionStatus::PaymentFailed; - user_subscription.price_id = metadata.product_price.id; - user_subscription.interval = interval; - user_subscription.upsert(&mut transaction).await?; - - intervals.get(&interval).copied() - } else { - // We don't create a new subscription for a failed payment, so we return None here so no email is sent - None - } - } else { - None - } - } - }; + let metadata = get_payment_intent_metadata( + payment_intent.metadata, + &pool, + &redis, + ChargeStatus::Failed, + SubscriptionStatus::PaymentFailed, + &mut transaction, + ) + .await?; - if let Some(price) = price { - if let Some(email) = metadata.user.email { - let money = rusty_money::Money::from_minor( - price as i64, - rusty_money::iso::find(&metadata.product_price.currency_code) - .unwrap_or(rusty_money::iso::USD), - ); - - let _ = send_email( - email, - "Payment Failed for Modrinth", - &format!("Our attempt to collect payment for {money} from the payment card on file was unsuccessful."), - "Please visit the following link below to update your payment method or contact your card provider. If the button does not work, you can copy the link and paste it into your browser.", - Some(("Update billing settings", &format!("{}/{}", dotenvy::var("SITE_URL")?, dotenvy::var("SITE_BILLING_PATH")?))), - ); - } + if let Some(email) = metadata.user_item.email { + let money = rusty_money::Money::from_minor( + metadata.charge_item.amount, + rusty_money::iso::find(&metadata.charge_item.currency_code) + .unwrap_or(rusty_money::iso::USD), + ); + + let _ = send_email( + email, + "Payment Failed for Modrinth", + &format!("Our attempt to collect payment for {money} from the payment card on file was unsuccessful."), + "Please visit the following link below to update your payment method or contact your card provider. If the button does not work, you can copy the link and paste it into your browser.", + Some(("Update billing settings", &format!("{}/{}", dotenvy::var("SITE_URL")?, dotenvy::var("SITE_BILLING_PATH")?))), + ); } transaction.commit().await?; @@ -1140,16 +1309,15 @@ async fn get_or_create_customer( pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) { // Check for open charges which are open AND last charge hasn't already been attempted // CHeck for open charges which are failed ANd last attempt > 2 days ago (and unprovision) + // If charge's subscription is cancelled and expired, unprovision and remove - // if subscription is cancelled and expired, unprovision and remove - // if subscription is payment failed and last attempt is > 2 days ago, try again to charge and unprovision - // if subscription is active and expired, attempt to charge and set as processing loop { info!("Indexing billing queue"); let res = async { - let charges_to_do = crate::database::models::charge_item::ChargeItem::get_chargeable(&pool).await?; + let charges_to_do = + crate::database::models::charge_item::ChargeItem::get_chargeable(&pool).await?; - let subscription_items = user_subscription_item::UserSubscriptionItem::get_many( + let mut subscription_items = user_subscription_item::UserSubscriptionItem::get_many( &charges_to_do .iter() .flat_map(|x| x.subscription_id) @@ -1194,13 +1362,12 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) let mut transaction = pool.begin().await?; let mut clear_cache_users = Vec::new(); - for mut charge in charges_to_do { + for charge in charges_to_do { let user = users.iter().find(|x| x.id == charge.user_id); if let Some(user) = user { - let product_price = subscription_prices - .iter() - .find(|x| x.id == charge.price_id); + let product_price = + subscription_prices.iter().find(|x| x.id == charge.price_id); if let Some(product_price) = product_price { let product = subscription_products @@ -1208,55 +1375,92 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) .find(|x| x.id == product_price.product_id); if let Some(product) = product { + let mut subscription = charge + .subscription_id + .and_then(|x| subscription_items.iter_mut().find(|y| y.id == x)); + let price = match &product_price.prices { Price::OneTime { price } => Some(price), Price::Recurring { intervals } => { - intervals.get(&subscription.interval) + if let Some(ref subscription) = subscription { + intervals.get(&subscription.interval) + } else { + warn!( + "Could not find subscription for charge {:?}", + charge.id + ); + continue; + } } }; if let Some(price) = price { - let cancelled = - subscription.status == SubscriptionStatus::Cancelled; - let payment_failed = subscription - .last_charge - .map(|y| { - subscription.status == SubscriptionStatus::PaymentFailed - && Utc::now() - y > Duration::days(2) - }) - .unwrap_or(false); - let active = subscription.status == SubscriptionStatus::Active; - - // Unprovision subscription - if cancelled || payment_failed { - match product.metadata { - ProductMetadata::Midas => { - let badges = user.badges - Badges::MIDAS; - - sqlx::query!( - " - UPDATE users - SET badges = $1 - WHERE (id = $2) - ", - badges.bits() as i64, - user.id as crate::database::models::ids::UserId, - ) - .execute(&mut *transaction) - .await?; + let should_charge = if let Some(ref subscription) = subscription { + let cancelled = + subscription.status == SubscriptionStatus::Cancelled; + let payment_failed = charge + .last_attempt + .map(|y| { + subscription.status == SubscriptionStatus::PaymentFailed + && Utc::now() - y > Duration::days(2) + }) + .unwrap_or(false); + let active = subscription.status == SubscriptionStatus::Active; + + // Unprovision subscription + if cancelled || payment_failed { + match product.metadata { + ProductMetadata::Midas => { + let badges = user.badges - Badges::MIDAS; + + sqlx::query!( + " + UPDATE users + SET badges = $1 + WHERE (id = $2) + ", + badges.bits() as i64, + user.id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + ProductMetadata::Pyro { .. } => { + if let Some(SubscriptionMetadata::Pyro { id }) = &subscription.metadata { + let res = reqwest::Client::new() + .post(format!("https://archon.pyro.host/v0/servers/{}/suspend", id)) + .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) + .json(&serde_json::json!({ + "reason": if cancelled { "cancelled" } else { "paymentfailed" } + })) + .send() + .await; + + if let Err(e) = res { + warn!("Error suspending pyro server: {:?}", e); + } + } + } } + + clear_cache_users.push(user.id); } - clear_cache_users.push(user.id); - } + if cancelled { + user_subscription_item::UserSubscriptionItem::remove( + subscription.id, + &mut transaction, + ) + .await?; + false + } else { + payment_failed || active + } + } else { + true + }; - if cancelled { - user_subscription_item::UserSubscriptionItem::remove( - subscription.id, - &mut transaction, - ) - .await?; - } else if payment_failed || active { + if should_charge { let customer_id = get_or_create_customer( user.id.into(), user.stripe_customer_id.as_deref(), @@ -1296,16 +1500,8 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) to_base62(user.id.0 as u64), ); metadata.insert( - "modrinth_price_id".to_string(), - to_base62(product_price.id.0 as u64), - ); - metadata.insert( - "modrinth_subscription_id".to_string(), - to_base62(subscription.id.0 as u64), - ); - metadata.insert( - "modrinth_subscription_interval".to_string(), - subscription.interval.as_str().to_string(), + "modrinth_charge_id".to_string(), + to_base62(charge.id.0 as u64), ); intent.metadata = Some(metadata); @@ -1320,14 +1516,22 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) intent.off_session = Some(PaymentIntentOffSession::Exists(true)); - subscription.status = SubscriptionStatus::PaymentProcessing; + if let Some(ref mut subscription) = subscription { + subscription.status = + SubscriptionStatus::PaymentProcessing; + } + stripe::PaymentIntent::create(&stripe_client, intent) .await?; } else { - subscription.status = SubscriptionStatus::PaymentFailed; + if let Some(ref mut subscription) = subscription { + subscription.status = SubscriptionStatus::PaymentFailed; + } } - subscription.upsert(&mut transaction).await?; + if let Some(ref subscription) = subscription { + subscription.upsert(&mut transaction).await?; + } } } }