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-285cdd452fff85480dde02119d224a6e422e4041deb6f640ab5159d55ba2789c.json b/.sqlx/query-285cdd452fff85480dde02119d224a6e422e4041deb6f640ab5159d55ba2789c.json new file mode 100644 index 00000000..72f8988c --- /dev/null +++ b/.sqlx/query-285cdd452fff85480dde02119d224a6e422e4041deb6f640ab5159d55ba2789c.json @@ -0,0 +1,82 @@ +{ + "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" +} diff --git a/.sqlx/query-61a87b00baaba022ab32eedf177d5b9dc6d5b7568cf1df15fac6c9e85acfa448.json b/.sqlx/query-3cbc34bc326595fc9d070494613fca57628eed279f720565fab55c8d10decd88.json similarity index 57% rename from .sqlx/query-61a87b00baaba022ab32eedf177d5b9dc6d5b7568cf1df15fac6c9e85acfa448.json rename to .sqlx/query-3cbc34bc326595fc9d070494613fca57628eed279f720565fab55c8d10decd88.json index a91ed2eb..14f57491 100644 --- a/.sqlx/query-61a87b00baaba022ab32eedf177d5b9dc6d5b7568cf1df15fac6c9e85acfa448.json +++ b/.sqlx/query-3cbc34bc326595fc9d070494613fca57628eed279f720565fab55c8d10decd88.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\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 charges c\n ON c.subscription_id = us.id\n AND (\n (c.status = 'cancelled' AND c.due < $1) OR\n (c.status = 'failed' AND c.last_attempt < $1 - INTERVAL '2 days')\n )\n ", "describe": { "columns": [ { @@ -30,18 +30,13 @@ }, { "ordinal": 5, - "name": "expires", - "type_info": "Timestamptz" + "name": "status", + "type_info": "Varchar" }, { "ordinal": 6, - "name": "last_charge", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "status", - "type_info": "Varchar" + "name": "metadata", + "type_info": "Jsonb" } ], "parameters": { @@ -56,9 +51,8 @@ false, false, false, - true, - false + true ] }, - "hash": "61a87b00baaba022ab32eedf177d5b9dc6d5b7568cf1df15fac6c9e85acfa448" + "hash": "3cbc34bc326595fc9d070494613fca57628eed279f720565fab55c8d10decd88" } diff --git a/.sqlx/query-457493bd11254ba1c9f81c47f15e8154053ae4e90e319d34a940fb73e33a69d4.json b/.sqlx/query-457493bd11254ba1c9f81c47f15e8154053ae4e90e319d34a940fb73e33a69d4.json new file mode 100644 index 00000000..33d196a9 --- /dev/null +++ b/.sqlx/query-457493bd11254ba1c9f81c47f15e8154053ae4e90e319d34a940fb73e33a69d4.json @@ -0,0 +1,82 @@ +{ + "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", + "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": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "457493bd11254ba1c9f81c47f15e8154053ae4e90e319d34a940fb73e33a69d4" +} diff --git a/.sqlx/query-56d7b62fc05c77f228e46dbfe4eaca81b445a7f5a44e52a0526a1b57bd7a8c9d.json b/.sqlx/query-56d7b62fc05c77f228e46dbfe4eaca81b445a7f5a44e52a0526a1b57bd7a8c9d.json new file mode 100644 index 00000000..53bd4798 --- /dev/null +++ b/.sqlx/query-56d7b62fc05c77f228e46dbfe4eaca81b445a7f5a44e52a0526a1b57bd7a8c9d.json @@ -0,0 +1,24 @@ +{ + "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" +} diff --git a/.sqlx/query-86ee460c74f0052a4945ab4df9829b3b077930d8e9e09dca76fde8983413adc6.json b/.sqlx/query-86ee460c74f0052a4945ab4df9829b3b077930d8e9e09dca76fde8983413adc6.json new file mode 100644 index 00000000..e146de6b --- /dev/null +++ b/.sqlx/query-86ee460c74f0052a4945ab4df9829b3b077930d8e9e09dca76fde8983413adc6.json @@ -0,0 +1,82 @@ +{ + "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", + "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": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "86ee460c74f0052a4945ab4df9829b3b077930d8e9e09dca76fde8983413adc6" +} diff --git a/.sqlx/query-88d135700420321a3896f9262bb663df0ac672d465d78445e48f321fc47e09cb.json b/.sqlx/query-88d135700420321a3896f9262bb663df0ac672d465d78445e48f321fc47e09cb.json deleted file mode 100644 index 7058e994..00000000 --- a/.sqlx/query-88d135700420321a3896f9262bb663df0ac672d465d78445e48f321fc47e09cb.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM users_subscriptions\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "88d135700420321a3896f9262bb663df0ac672d465d78445e48f321fc47e09cb" -} diff --git a/.sqlx/query-8bcf4589c429ab0abf2460f658fd91caafb733a5217b832ab9dcf7fde60d49dd.json b/.sqlx/query-8bcf4589c429ab0abf2460f658fd91caafb733a5217b832ab9dcf7fde60d49dd.json new file mode 100644 index 00000000..36befffd --- /dev/null +++ b/.sqlx/query-8bcf4589c429ab0abf2460f658fd91caafb733a5217b832ab9dcf7fde60d49dd.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users_subscriptions (\n id, user_id, price_id, interval, created, status, metadata\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ON CONFLICT (id)\n DO UPDATE\n SET interval = EXCLUDED.interval,\n status = EXCLUDED.status,\n price_id = EXCLUDED.price_id,\n metadata = EXCLUDED.metadata\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Text", + "Timestamptz", + "Varchar", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "8bcf4589c429ab0abf2460f658fd91caafb733a5217b832ab9dcf7fde60d49dd" +} 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-07afad3b85ed64acbe9584570fdec92f923abf17439f0011e2b46797cec0ad97.json b/.sqlx/query-a25ee30b6968dc98b66b1beac5124f39c64ad8815ff0ec0a98903fee0b4167c7.json similarity index 64% rename from .sqlx/query-07afad3b85ed64acbe9584570fdec92f923abf17439f0011e2b46797cec0ad97.json rename to .sqlx/query-a25ee30b6968dc98b66b1beac5124f39c64ad8815ff0ec0a98903fee0b4167c7.json index bc77da8c..179313d3 100644 --- a/.sqlx/query-07afad3b85ed64acbe9584570fdec92f923abf17439f0011e2b46797cec0ad97.json +++ b/.sqlx/query-a25ee30b6968dc98b66b1beac5124f39c64ad8815ff0ec0a98903fee0b4167c7.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 us.id, us.user_id, us.price_id, us.interval, us.created, us.status, us.metadata\n FROM users_subscriptions us\n WHERE us.id = ANY($1::bigint[])", "describe": { "columns": [ { @@ -30,18 +30,13 @@ }, { "ordinal": 5, - "name": "expires", - "type_info": "Timestamptz" + "name": "status", + "type_info": "Varchar" }, { "ordinal": 6, - "name": "last_charge", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "status", - "type_info": "Varchar" + "name": "metadata", + "type_info": "Jsonb" } ], "parameters": { @@ -56,9 +51,8 @@ false, false, false, - true, - false + true ] }, - "hash": "07afad3b85ed64acbe9584570fdec92f923abf17439f0011e2b46797cec0ad97" + "hash": "a25ee30b6968dc98b66b1beac5124f39c64ad8815ff0ec0a98903fee0b4167c7" } diff --git a/.sqlx/query-d6d3c29ff2aa3b311a19225cefcd5b8844fbe5bedf44ffe24f31b12e5bc5f868.json b/.sqlx/query-af1a10f0fa88c7893cff3a451fd890762fd7068cab7822a5b60545b44e6ba775.json similarity index 64% rename from .sqlx/query-d6d3c29ff2aa3b311a19225cefcd5b8844fbe5bedf44ffe24f31b12e5bc5f868.json rename to .sqlx/query-af1a10f0fa88c7893cff3a451fd890762fd7068cab7822a5b60545b44e6ba775.json index df277ea6..84837d7a 100644 --- a/.sqlx/query-d6d3c29ff2aa3b311a19225cefcd5b8844fbe5bedf44ffe24f31b12e5bc5f868.json +++ b/.sqlx/query-af1a10f0fa88c7893cff3a451fd890762fd7068cab7822a5b60545b44e6ba775.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 us.id, us.user_id, us.price_id, us.interval, us.created, us.status, us.metadata\n FROM users_subscriptions us\n WHERE us.user_id = $1", "describe": { "columns": [ { @@ -30,18 +30,13 @@ }, { "ordinal": 5, - "name": "expires", - "type_info": "Timestamptz" + "name": "status", + "type_info": "Varchar" }, { "ordinal": 6, - "name": "last_charge", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "status", - "type_info": "Varchar" + "name": "metadata", + "type_info": "Jsonb" } ], "parameters": { @@ -56,9 +51,8 @@ false, false, false, - true, - false + true ] }, - "hash": "d6d3c29ff2aa3b311a19225cefcd5b8844fbe5bedf44ffe24f31b12e5bc5f868" + "hash": "af1a10f0fa88c7893cff3a451fd890762fd7068cab7822a5b60545b44e6ba775" } diff --git a/.sqlx/query-b64651865cf9c1fbebed7f188da6566d53049176d72073c22a04b43adea18326.json b/.sqlx/query-b64651865cf9c1fbebed7f188da6566d53049176d72073c22a04b43adea18326.json deleted file mode 100644 index 6b762f83..00000000 --- a/.sqlx/query-b64651865cf9c1fbebed7f188da6566d53049176d72073c22a04b43adea18326.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM users_subscriptions\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "b64651865cf9c1fbebed7f188da6566d53049176d72073c22a04b43adea18326" -} diff --git a/.sqlx/query-bc2a2166718a2d2b23e57cde6b144e88f58fd2e1cc3e6da8d90708cbf242f761.json b/.sqlx/query-bc2a2166718a2d2b23e57cde6b144e88f58fd2e1cc3e6da8d90708cbf242f761.json new file mode 100644 index 00000000..0c464981 --- /dev/null +++ b/.sqlx/query-bc2a2166718a2d2b23e57cde6b144e88f58fd2e1cc3e6da8d90708cbf242f761.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM charges\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "bc2a2166718a2d2b23e57cde6b144e88f58fd2e1cc3e6da8d90708cbf242f761" +} diff --git a/.sqlx/query-bd26a27ce80ca796ae19bc709c92800a0a43dfef4a37a5725403d33ccb20d908.json b/.sqlx/query-bd26a27ce80ca796ae19bc709c92800a0a43dfef4a37a5725403d33ccb20d908.json new file mode 100644 index 00000000..c0c2cbe9 --- /dev/null +++ b/.sqlx/query-bd26a27ce80ca796ae19bc709c92800a0a43dfef4a37a5725403d33ccb20d908.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": "bd26a27ce80ca796ae19bc709c92800a0a43dfef4a37a5725403d33ccb20d908" +} diff --git a/.sqlx/query-e68e27fcb3e85233be06e7435aaeb6b27d8dbe2ddaf211ba37a026eab3bb6926.json b/.sqlx/query-e68e27fcb3e85233be06e7435aaeb6b27d8dbe2ddaf211ba37a026eab3bb6926.json new file mode 100644 index 00000000..5f6fbb75 --- /dev/null +++ b/.sqlx/query-e68e27fcb3e85233be06e7435aaeb6b27d8dbe2ddaf211ba37a026eab3bb6926.json @@ -0,0 +1,82 @@ +{ + "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')", + "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": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "e68e27fcb3e85233be06e7435aaeb6b27d8dbe2ddaf211ba37a026eab3bb6926" +} 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-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/migrations/20240923163452_charges-fix.sql b/migrations/20240923163452_charges-fix.sql new file mode 100644 index 00000000..c494528e --- /dev/null +++ b/migrations/20240923163452_charges-fix.sql @@ -0,0 +1,17 @@ +CREATE TABLE charges ( + id bigint PRIMARY KEY, + user_id bigint REFERENCES users NOT NULL, + price_id bigint REFERENCES products_prices NOT NULL, + amount bigint NOT NULL, + currency_code text NOT NULL, + status varchar(255) NOT NULL, + due timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + last_attempt timestamptz NULL, + charge_type text NOT NULL, + subscription_id bigint NULL, + subscription_interval text NULL +); + +ALTER TABLE users_subscriptions DROP COLUMN last_charge; +ALTER TABLE users_subscriptions ADD COLUMN metadata jsonb NULL; +ALTER TABLE users_subscriptions DROP COLUMN expires; \ No newline at end of file diff --git a/src/database/models/charge_item.rs b/src/database/models/charge_item.rs new file mode 100644 index 00000000..af0ed0bf --- /dev/null +++ b/src/database/models/charge_item.rs @@ -0,0 +1,181 @@ +use crate::database::models::{ + ChargeId, DatabaseError, ProductPriceId, UserId, UserSubscriptionId, +}; +use crate::models::billing::{ChargeStatus, ChargeType, PriceDuration}; +use chrono::{DateTime, Utc}; +use std::convert::{TryFrom, TryInto}; + +pub struct ChargeItem { + pub id: ChargeId, + pub user_id: UserId, + pub price_id: ProductPriceId, + pub amount: i64, + pub currency_code: String, + pub status: ChargeStatus, + pub due: DateTime, + pub last_attempt: Option>, + + pub type_: ChargeType, + pub subscription_id: Option, + pub subscription_interval: Option, +} + +struct ChargeResult { + id: i64, + user_id: i64, + price_id: i64, + amount: i64, + currency_code: String, + status: String, + due: DateTime, + last_attempt: Option>, + charge_type: String, + subscription_id: Option, + subscription_interval: Option, +} + +impl TryFrom for ChargeItem { + type Error = serde_json::Error; + + fn try_from(r: ChargeResult) -> Result { + Ok(ChargeItem { + id: ChargeId(r.id), + user_id: UserId(r.user_id), + price_id: ProductPriceId(r.price_id), + amount: r.amount, + currency_code: r.currency_code, + status: ChargeStatus::from_string(&r.status), + due: r.due, + last_attempt: r.last_attempt, + type_: ChargeType::from_string(&r.charge_type), + subscription_id: r.subscription_id.map(UserSubscriptionId), + subscription_interval: r + .subscription_interval + .map(|x| PriceDuration::from_string(&x)), + }) + } +} + +macro_rules! select_charges_with_predicate { + ($predicate:tt, $param:ident) => { + sqlx::query_as!( + ChargeResult, + r#" + SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval + FROM charges + "# + + $predicate, + $param + ) + }; +} + +impl ChargeItem { + pub async fn upsert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + sqlx::query!( + r#" + INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) + DO UPDATE + SET status = EXCLUDED.status, + last_attempt = EXCLUDED.last_attempt, + due = EXCLUDED.due, + subscription_id = EXCLUDED.subscription_id, + subscription_interval = EXCLUDED.subscription_interval + "#, + self.id.0, + self.user_id.0, + self.price_id.0, + self.amount, + self.currency_code, + self.type_.as_str(), + self.status.as_str(), + self.due, + self.last_attempt, + self.subscription_id.map(|x| x.0), + self.subscription_interval.map(|x| x.as_str()), + ) + .execute(&mut **transaction) + .await?; + + Ok(self.id) + } + + pub async fn get( + 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.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 ORDER BY due DESC", user_id) + .fetch_all(exec) + .await?; + + Ok(res + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } + + pub async fn get_open_subscription( + user_subscription_id: UserSubscriptionId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let user_subscription_id = user_subscription_id.0; + let res = select_charges_with_predicate!( + "WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')", + user_subscription_id + ) + .fetch_optional(exec) + .await?; + + Ok(res.and_then(|r| r.try_into().ok())) + } + + pub async fn get_chargeable( + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + 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?; + + Ok(res + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } + + pub async fn remove( + id: ChargeId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + DELETE FROM charges + WHERE id = $1 + ", + id.0 as i64 + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } +} diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index fd85a64c..be380924 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -256,6 +256,14 @@ generate_ids!( UserSubscriptionId ); +generate_ids!( + pub generate_charge_id, + ChargeId, + 8, + "SELECT EXISTS(SELECT 1 FROM charges WHERE id=$1)", + ChargeId +); + #[derive(Copy, Clone, Debug, PartialEq, Eq, Type, Hash, Serialize, Deserialize)] #[sqlx(transparent)] pub struct UserId(pub i64); @@ -386,6 +394,10 @@ pub struct ProductPriceId(pub i64); #[sqlx(transparent)] pub struct UserSubscriptionId(pub i64); +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[sqlx(transparent)] +pub struct ChargeId(pub i64); + use crate::models::ids; impl From for ProjectId { @@ -571,3 +583,14 @@ impl From for ids::UserSubscriptionId { ids::UserSubscriptionId(id.0 as u64) } } + +impl From for ChargeId { + fn from(id: ids::ChargeId) -> Self { + ChargeId(id.0 as i64) + } +} +impl From for ids::ChargeId { + fn from(id: ChargeId) -> Self { + ids::ChargeId(id.0 as u64) + } +} diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index 51dafed6..dabcfdda 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -1,6 +1,7 @@ use thiserror::Error; pub mod categories; +pub mod charge_item; pub mod collection_item; pub mod flow_item; pub mod ids; diff --git a/src/database/models/user_subscription_item.rs b/src/database/models/user_subscription_item.rs index b13d319f..8de0fd0e 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, @@ -9,9 +10,8 @@ pub struct UserSubscriptionItem { pub price_id: ProductPriceId, pub interval: PriceDuration, pub created: DateTime, - pub expires: DateTime, - pub last_charge: Option>, pub status: SubscriptionStatus, + pub metadata: Option, } struct UserSubscriptionResult { @@ -20,9 +20,8 @@ struct UserSubscriptionResult { price_id: i64, 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,8 +30,8 @@ macro_rules! select_user_subscriptions_with_predicate { UserSubscriptionResult, r#" SELECT - id, user_id, price_id, interval, created, expires, last_charge, status - FROM users_subscriptions + us.id, us.user_id, us.price_id, us.interval, us.created, us.status, us.metadata + FROM users_subscriptions us "# + $predicate, $param @@ -40,18 +39,19 @@ 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)?, + }) } } @@ -70,11 +70,14 @@ impl UserSubscriptionItem { let ids = ids.iter().map(|id| id.0).collect_vec(); let ids_ref: &[i64] = &ids; let results = - select_user_subscriptions_with_predicate!("WHERE id = ANY($1::bigint[])", ids_ref) + select_user_subscriptions_with_predicate!("WHERE us.id = ANY($1::bigint[])", ids_ref) .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( @@ -82,22 +85,38 @@ impl UserSubscriptionItem { exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result, DatabaseError> { let user_id = user_id.0; - let results = select_user_subscriptions_with_predicate!("WHERE user_id = $1", user_id) + let results = select_user_subscriptions_with_predicate!("WHERE us.user_id = $1", user_id) .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_expired( + pub async fn get_all_unprovision( exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result, DatabaseError> { let now = Utc::now(); - let results = select_user_subscriptions_with_predicate!("WHERE expires < $1", now) - .fetch_all(exec) - .await?; + let results = select_user_subscriptions_with_predicate!( + " + INNER JOIN charges c + ON c.subscription_id = us.id + AND ( + (c.status = 'cancelled' AND c.due < $1) OR + (c.status = 'failed' AND c.last_attempt < $1 - INTERVAL '2 days') + ) + ", + now + ) + .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( @@ -107,44 +126,25 @@ 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, status, metadata ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8 + $1, $2, $3, $4, $5, $6, $7 ) ON CONFLICT (id) 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, self.price_id.0, self.interval.as_str(), self.created, - self.expires, - self.last_charge, self.status.as_str(), - ) - .execute(&mut **transaction) - .await?; - - Ok(()) - } - - pub async fn remove( - id: UserSubscriptionId, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - ) -> Result<(), DatabaseError> { - sqlx::query!( - " - DELETE FROM users_subscriptions - WHERE id = $1 - ", - id.0 as i64 + serde_json::to_value(&self.metadata)?, ) .execute(&mut **transaction) .await?; diff --git a/src/file_hosting/backblaze/delete.rs b/src/file_hosting/backblaze/delete.rs index 190288e6..87e24ac3 100644 --- a/src/file_hosting/backblaze/delete.rs +++ b/src/file_hosting/backblaze/delete.rs @@ -15,7 +15,7 @@ pub async fn delete_file_version( file_name: &str, ) -> Result { let response = reqwest::Client::new() - .post(&format!( + .post(format!( "{}/b2api/v2/b2_delete_file_version", authorization_data.api_url )) diff --git a/src/lib.rs b/src/lib.rs index 29b5af1c..8d4bcc06 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -259,15 +259,24 @@ pub fn app_setup( } let stripe_client = stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap()); - // { - // let pool_ref = pool.clone(); - // let redis_ref = redis_pool.clone(); - // let stripe_client_ref = stripe_client.clone(); - // - // actix_rt::spawn(async move { - // routes::internal::billing::task(stripe_client_ref, pool_ref, redis_ref).await; - // }); - // } + { + let pool_ref = pool.clone(); + let redis_ref = redis_pool.clone(); + let stripe_client_ref = stripe_client.clone(); + + actix_rt::spawn(async move { + routes::internal::billing::task(stripe_client_ref, pool_ref, redis_ref).await; + }); + } + + { + let pool_ref = pool.clone(); + let redis_ref = redis_pool.clone(); + + actix_rt::spawn(async move { + routes::internal::billing::subscription_task(pool_ref, redis_ref).await; + }); + } let ip_salt = Pepper { pepper: models::ids::Base62Id(models::ids::random_base62(11)).to_string(), @@ -456,5 +465,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 c78583da..0cc1dadf 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, @@ -84,8 +92,7 @@ pub struct UserSubscription { pub interval: PriceDuration, pub status: SubscriptionStatus, pub created: DateTime, - pub expires: DateTime, - pub last_charge: Option>, + pub metadata: Option, } impl From @@ -99,38 +106,119 @@ impl From interval: x.interval, status: x.status, created: x.created, - expires: x.expires, - last_charge: x.last_charge, + metadata: x.metadata, } } } -#[derive(Serialize, Deserialize, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "kebab-case")] pub enum SubscriptionStatus { - Active, - PaymentProcessing, - PaymentFailed, - Cancelled, + Provisioned, + Unprovisioned, } impl SubscriptionStatus { pub fn from_string(string: &str) -> SubscriptionStatus { match string { - "active" => SubscriptionStatus::Active, - "payment-processing" => SubscriptionStatus::PaymentProcessing, - "payment-failed" => SubscriptionStatus::PaymentFailed, - "cancelled" => SubscriptionStatus::Cancelled, - _ => SubscriptionStatus::Cancelled, + "provisioned" => SubscriptionStatus::Provisioned, + "unprovisioned" => SubscriptionStatus::Unprovisioned, + _ => SubscriptionStatus::Provisioned, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + SubscriptionStatus::Provisioned => "provisioned", + SubscriptionStatus::Unprovisioned => "unprovisioned", + } + } +} + +#[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")] +pub struct ChargeId(pub u64); + +#[derive(Serialize, Deserialize)] +pub struct Charge { + pub id: ChargeId, + pub user_id: UserId, + pub price_id: ProductPriceId, + pub amount: i64, + pub currency_code: String, + pub status: ChargeStatus, + pub due: DateTime, + pub last_attempt: Option>, + #[serde(flatten)] + pub type_: ChargeType, + pub subscription_id: Option, + pub subscription_interval: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum ChargeType { + OneTime, + Subscription, + Proration, +} + +impl ChargeType { + pub fn as_str(&self) -> &'static str { + match self { + ChargeType::OneTime => "one-time", + ChargeType::Subscription { .. } => "subscription", + ChargeType::Proration { .. } => "proration", + } + } + + pub fn from_string(string: &str) -> ChargeType { + match string { + "one-time" => ChargeType::OneTime, + "subscription" => ChargeType::Subscription, + "proration" => ChargeType::Proration, + _ => ChargeType::OneTime, + } + } +} + +#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] +#[serde(rename_all = "kebab-case")] +pub enum ChargeStatus { + // Open charges are for the next billing interval + Open, + Processing, + Succeeded, + Failed, + Cancelled, +} + +impl ChargeStatus { + pub fn from_string(string: &str) -> ChargeStatus { + match string { + "processing" => ChargeStatus::Processing, + "succeeded" => ChargeStatus::Succeeded, + "failed" => ChargeStatus::Failed, + "open" => ChargeStatus::Open, + "cancelled" => ChargeStatus::Cancelled, + _ => ChargeStatus::Failed, } } pub fn as_str(&self) -> &'static str { match self { - SubscriptionStatus::Active => "active", - SubscriptionStatus::PaymentProcessing => "payment-processing", - SubscriptionStatus::PaymentFailed => "payment-failed", - SubscriptionStatus::Cancelled => "cancelled", + ChargeStatus::Processing => "processing", + ChargeStatus::Succeeded => "succeeded", + ChargeStatus::Failed => "failed", + ChargeStatus::Open => "open", + ChargeStatus::Cancelled => "cancelled", } } } diff --git a/src/models/v3/ids.rs b/src/models/v3/ids.rs index 839d6587..3af87437 100644 --- a/src/models/v3/ids.rs +++ b/src/models/v3/ids.rs @@ -13,8 +13,7 @@ pub use super::teams::TeamId; pub use super::threads::ThreadId; pub use super::threads::ThreadMessageId; pub use super::users::UserId; -pub use crate::models::billing::UserSubscriptionId; -pub use crate::models::v3::billing::{ProductId, ProductPriceId}; +pub use crate::models::billing::{ChargeId, ProductId, ProductPriceId, UserSubscriptionId}; use thiserror::Error; /// Generates a random 64 bit integer that is exactly `n` characters @@ -137,6 +136,7 @@ base62_id_impl!(PayoutId, PayoutId); base62_id_impl!(ProductId, ProductId); base62_id_impl!(ProductPriceId, ProductPriceId); base62_id_impl!(UserSubscriptionId, UserSubscriptionId); +base62_id_impl!(ChargeId, ChargeId); pub mod base62_impl { use serde::de::{self, Deserializer, Visitor}; diff --git a/src/queue/payouts.rs b/src/queue/payouts.rs index e7188c7f..6d1d0c1c 100644 --- a/src/queue/payouts.rs +++ b/src/queue/payouts.rs @@ -74,7 +74,7 @@ impl PayoutsQueue { } let credential: PaypalCredential = client - .post(&format!("{}oauth2/token", dotenvy::var("PAYPAL_API_URL")?)) + .post(format!("{}oauth2/token", dotenvy::var("PAYPAL_API_URL")?)) .header("Accept", "application/json") .header("Accept-Language", "en_US") .header("Authorization", formatted_key) diff --git a/src/routes/internal/billing.rs b/src/routes/internal/billing.rs index a63442dc..b017f5bd 100644 --- a/src/routes/internal/billing.rs +++ b/src/routes/internal/billing.rs @@ -1,11 +1,11 @@ use crate::auth::{get_user_from_headers, send_email}; use crate::database::models::{ - generate_user_subscription_id, product_item, user_subscription_item, + generate_charge_id, generate_user_subscription_id, product_item, user_subscription_item, }; use crate::database::redis::RedisPool; use crate::models::billing::{ - Price, PriceDuration, Product, ProductMetadata, ProductPrice, SubscriptionStatus, - UserSubscription, + Charge, ChargeStatus, ChargeType, Price, PriceDuration, Product, ProductMetadata, ProductPrice, + SubscriptionMetadata, SubscriptionStatus, UserSubscription, }; use crate::models::ids::base62_impl::{parse_base62, to_base62}; use crate::models::pats::Scopes; @@ -13,17 +13,18 @@ use crate::models::users::Badges; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; -use chrono::{Duration, Utc}; +use chrono::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, + CreateCustomer, CreatePaymentIntent, 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 cancelled: 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, @@ -121,7 +130,7 @@ pub async fn cancel_subscription( let (id,) = info.into_inner(); - if let Some(mut subscription) = + if let Some(subscription) = user_subscription_item::UserSubscriptionItem::get(id.into(), &**pool).await? { if subscription.user_id != user.id.into() && !user.role.is_admin() { @@ -130,21 +139,56 @@ pub async fn cancel_subscription( let mut transaction = pool.begin().await?; - if subscription.expires < Utc::now() { - sqlx::query!( - " - DELETE FROM users_subscriptions - WHERE id = $1 - ", - subscription.id.0 as i64 + let mut open_charge = + crate::database::models::charge_item::ChargeItem::get_open_subscription( + subscription.id, + &mut *transaction, ) - .execute(&mut *transaction) - .await?; - } else { - subscription.status = SubscriptionStatus::Cancelled; - subscription.upsert(&mut transaction).await?; + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "Could not find open charge for this subscription".to_string(), + ) + })?; + + let current_price = + product_item::ProductPriceItem::get(subscription.price_id, &mut *transaction) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("Could not find current product price".to_string()) + })?; + + if let Some(cancelled) = &edit_subscription.cancelled { + if open_charge.status != ChargeStatus::Open + && open_charge.status != ChargeStatus::Cancelled + { + return Err(ApiError::InvalidInput( + "You may not change the status of this subscription!".to_string(), + )); + } + + if *cancelled { + open_charge.status = ChargeStatus::Cancelled; + } else { + open_charge.status = ChargeStatus::Open; + } } + if let Some(interval) = &edit_subscription.interval { + if let Price::Recurring { intervals } = ¤t_price.prices { + if let Some(price) = intervals.get(interval) { + open_charge.subscription_interval = Some(*interval); + open_charge.amount = *price as i64; + } else { + return Err(ApiError::InvalidInput( + "Interval is not valid for this subscription!".to_string(), + )); + } + } + } + + open_charge.upsert(&mut transaction).await?; + transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) @@ -191,7 +235,6 @@ pub async fn charges( pool: web::Data, redis: web::Data, session_queue: web::Data, - stripe_client: web::Data, ) -> Result { let user = get_user_from_headers( &req, @@ -203,25 +246,28 @@ pub async fn charges( .await? .1; - if let Some(customer_id) = user - .stripe_customer_id - .as_ref() - .and_then(|x| stripe::CustomerId::from_str(x).ok()) - { - let charges = stripe::Charge::list( - &stripe_client, - &ListCharges { - customer: Some(customer_id), - limit: Some(100), - ..Default::default() - }, - ) - .await?; + let charges = + crate::database::models::charge_item::ChargeItem::get_from_user(user.id.into(), &**pool) + .await?; - Ok(HttpResponse::Ok().json(charges.data)) - } else { - Ok(HttpResponse::NoContent().finish()) - } + Ok(HttpResponse::Ok().json( + charges + .into_iter() + .map(|x| Charge { + id: x.id.into(), + user_id: x.user_id.into(), + price_id: x.price_id.into(), + amount: x.amount, + currency_code: x.currency_code, + status: x.status, + due: x.due, + last_attempt: x.last_attempt, + type_: x.type_, + subscription_id: x.subscription_id.map(|x| x.into()), + subscription_interval: x.subscription_interval, + }) + .collect::>(), + )) } #[post("payment_method")] @@ -387,7 +433,7 @@ pub async fn remove_payment_method( if user_subscriptions .iter() - .any(|x| x.status != SubscriptionStatus::Cancelled) + .any(|x| x.status != SubscriptionStatus::Unprovisioned) { let customer = stripe::Customer::retrieve(&stripe_client, &customer, &[]).await?; @@ -466,13 +512,94 @@ pub enum PaymentRequestType { ConfirmationToken { token: String }, } +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ChargeRequestType { + Existing { + id: crate::models::ids::ChargeId, + }, + New { + product_id: crate::models::ids::ProductId, + interval: Option, + }, +} + +#[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 { - pub product_id: crate::models::ids::ProductId, - pub interval: Option, #[serde(flatten)] pub type_: PaymentRequestType, + pub charge: ChargeRequestType, pub existing_payment_intent: Option, + pub metadata: Option, +} + +fn infer_currency_code(country: &str) -> String { + match country { + "US" => "USD", + "GB" => "GBP", + "EU" => "EUR", + "AT" => "EUR", + "BE" => "EUR", + "CY" => "EUR", + "EE" => "EUR", + "FI" => "EUR", + "FR" => "EUR", + "DE" => "EUR", + "GR" => "EUR", + "IE" => "EUR", + "IT" => "EUR", + "LV" => "EUR", + "LT" => "EUR", + "LU" => "EUR", + "MT" => "EUR", + "NL" => "EUR", + "PT" => "EUR", + "SK" => "EUR", + "SI" => "EUR", + "RU" => "RUB", + "BR" => "BRL", + "JP" => "JPY", + "ID" => "IDR", + "MY" => "MYR", + "PH" => "PHP", + "TH" => "THB", + "VN" => "VND", + "KR" => "KRW", + "TR" => "TRY", + "UA" => "UAH", + "MX" => "MXN", + "CA" => "CAD", + "NZ" => "NZD", + "NO" => "NOK", + "PL" => "PLN", + "CH" => "CHF", + "LI" => "CHF", + "IN" => "INR", + "CL" => "CLP", + "PE" => "PEN", + "CO" => "COP", + "ZA" => "ZAR", + "HK" => "HKD", + "AR" => "ARS", + "KZ" => "KZT", + "UY" => "UYU", + "CN" => "CNY", + "AU" => "AUD", + "TW" => "TWD", + "SA" => "SAR", + "QA" => "QAR", + _ => "USD", + } + .to_string() } #[post("payment")] @@ -494,12 +621,6 @@ pub async fn initiate_payment( .await? .1; - let product = product_item::ProductItem::get(payment_request.product_id.into(), &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("Specified product could not be found!".to_string()) - })?; - let (user_country, payment_method) = match &payment_request.type_ { PaymentRequestType::PaymentMethod { id } => { let payment_method_id = stripe::PaymentMethodId::from_str(id) @@ -551,93 +672,104 @@ pub async fn initiate_payment( }; let country = user_country.as_deref().unwrap_or("US"); - let recommended_currency_code = match country { - "US" => "USD", - "GB" => "GBP", - "EU" => "EUR", - "AT" => "EUR", - "BE" => "EUR", - "CY" => "EUR", - "EE" => "EUR", - "FI" => "EUR", - "FR" => "EUR", - "DE" => "EUR", - "GR" => "EUR", - "IE" => "EUR", - "IT" => "EUR", - "LV" => "EUR", - "LT" => "EUR", - "LU" => "EUR", - "MT" => "EUR", - "NL" => "EUR", - "PT" => "EUR", - "SK" => "EUR", - "SI" => "EUR", - "RU" => "RUB", - "BR" => "BRL", - "JP" => "JPY", - "ID" => "IDR", - "MY" => "MYR", - "PH" => "PHP", - "TH" => "THB", - "VN" => "VND", - "KR" => "KRW", - "TR" => "TRY", - "UA" => "UAH", - "MX" => "MXN", - "CA" => "CAD", - "NZ" => "NZD", - "NO" => "NOK", - "PL" => "PLN", - "CH" => "CHF", - "LI" => "CHF", - "IN" => "INR", - "CL" => "CLP", - "PE" => "PEN", - "CO" => "COP", - "ZA" => "ZAR", - "HK" => "HKD", - "AR" => "ARS", - "KZ" => "KZT", - "UY" => "UYU", - "CN" => "CNY", - "AU" => "AUD", - "TW" => "TWD", - "SA" => "SAR", - "QA" => "QAR", - _ => "USD", - }; - - let mut product_prices = - product_item::ProductPriceItem::get_all_product_prices(product.id, &**pool).await?; - - let price_item = if let Some(pos) = product_prices - .iter() - .position(|x| x.currency_code == recommended_currency_code) - { - product_prices.remove(pos) - } else if let Some(pos) = product_prices.iter().position(|x| x.currency_code == "USD") { - product_prices.remove(pos) - } else { - return Err(ApiError::InvalidInput( - "Could not find a valid price for the user's country".to_string(), - )); - }; + let recommended_currency_code = infer_currency_code(country); + + let (price, currency_code, interval, price_id, charge_id) = match payment_request.charge { + ChargeRequestType::Existing { id } => { + let charge = crate::database::models::charge_item::ChargeItem::get(id.into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("Specified charge could not be found!".to_string()) + })?; + + ( + charge.amount, + charge.currency_code, + charge.subscription_interval, + charge.price_id, + Some(id), + ) + } + ChargeRequestType::New { + product_id, + interval, + } => { + let product = product_item::ProductItem::get(product_id.into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("Specified product could not be found!".to_string()) + })?; + + let mut product_prices = + product_item::ProductPriceItem::get_all_product_prices(product.id, &**pool).await?; + + let price_item = if let Some(pos) = product_prices + .iter() + .position(|x| x.currency_code == recommended_currency_code) + { + product_prices.remove(pos) + } else if let Some(pos) = product_prices.iter().position(|x| x.currency_code == "USD") { + product_prices.remove(pos) + } else { + return Err(ApiError::InvalidInput( + "Could not find a valid price for the user's country".to_string(), + )); + }; + + let price = match price_item.prices { + Price::OneTime { price } => price, + Price::Recurring { ref intervals } => { + let interval = interval.ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid interval for the user's country".to_string(), + ) + })?; + + *intervals.get(&interval).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's country".to_string(), + ) + })? + } + }; + + if let Price::Recurring { .. } = price_item.prices { + if product.unitary { + let user_subscriptions = + user_subscription_item::UserSubscriptionItem::get_all_user( + user.id.into(), + &**pool, + ) + .await?; + + let user_products = product_item::ProductPriceItem::get_many( + &user_subscriptions + .iter() + .filter(|x| x.status == SubscriptionStatus::Provisioned) + .map(|x| x.price_id) + .collect::>(), + &**pool, + ) + .await?; - let price = match price_item.prices { - Price::OneTime { price } => price, - Price::Recurring { ref intervals } => { - let interval = payment_request.interval.ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid interval for the user's country".to_string(), - ) - })?; + if user_products + .into_iter() + .any(|x| x.product_id == product.id) + { + return Err(ApiError::InvalidInput( + "You are already subscribed to this product!".to_string(), + )); + } + } + } - *intervals.get(&interval).ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid price for the user's country".to_string(), - ) - })? + ( + price as i64, + price_item.currency_code, + interval, + price_item.id, + None, + ) } }; @@ -650,31 +782,17 @@ pub async fn initiate_payment( &redis, ) .await?; - let stripe_currency = Currency::from_str(&price_item.currency_code.to_lowercase()) + let stripe_currency = Currency::from_str(¤cy_code.to_lowercase()) .map_err(|_| ApiError::InvalidInput("Invalid currency code".to_string()))?; if let Some(payment_intent_id) = &payment_request.existing_payment_intent { let mut update_payment_intent = stripe::UpdatePaymentIntent { - amount: Some(price as i64), + amount: Some(price), currency: Some(stripe_currency), customer: Some(customer), ..Default::default() }; - let mut metadata = HashMap::new(); - metadata.insert("modrinth_user_id".to_string(), to_base62(user.id.0)); - metadata.insert( - "modrinth_price_id".to_string(), - to_base62(price_item.id.0 as u64), - ); - if let Some(interval) = payment_request.interval { - metadata.insert( - "modrinth_subscription_interval".to_string(), - interval.as_str().to_string(), - ); - } - update_payment_intent.metadata = Some(metadata); - if let PaymentRequestType::PaymentMethod { .. } = payment_request.type_ { update_payment_intent.payment_method = Some(payment_method.id.clone()); } @@ -683,74 +801,46 @@ 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, }))) } else { - let mut intent = CreatePaymentIntent::new(price as i64, stripe_currency); + let mut intent = CreatePaymentIntent::new(price, stripe_currency); - let mut transaction = pool.begin().await?; let mut metadata = HashMap::new(); metadata.insert("modrinth_user_id".to_string(), to_base62(user.id.0)); - metadata.insert( - "modrinth_price_id".to_string(), - to_base62(price_item.id.0 as u64), - ); - - if let Price::Recurring { .. } = price_item.prices { - if product.unitary { - let user_subscriptions = - user_subscription_item::UserSubscriptionItem::get_all_user( - user.id.into(), - &**pool, - ) - .await?; - let user_products = product_item::ProductPriceItem::get_many( - &user_subscriptions - .iter() - .map(|x| x.price_id) - .collect::>(), - &**pool, - ) - .await?; + if let Some(payment_metadata) = &payment_request.metadata { + metadata.insert( + "modrinth_payment_metadata".to_string(), + serde_json::to_string(&payment_metadata)?, + ); + } - if let Some(product) = user_products - .into_iter() - .find(|x| x.product_id == product.id) - { - if let Some(subscription) = user_subscriptions - .into_iter() - .find(|x| x.price_id == product.id) - { - if subscription.status == SubscriptionStatus::Cancelled - || subscription.status == SubscriptionStatus::PaymentFailed - { - metadata.insert( - "modrinth_subscription_id".to_string(), - to_base62(subscription.id.0 as u64), - ); - } else { - return Err(ApiError::InvalidInput( - "You are already subscribed to this product!".to_string(), - )); - } - } - } - } + if let Some(charge_id) = charge_id { + metadata.insert("modrinth_charge_id".to_string(), to_base62(charge_id.0)); + } else { + let mut transaction = pool.begin().await?; + let charge_id = generate_charge_id(&mut transaction).await?; + let subscription_id = generate_user_subscription_id(&mut transaction).await?; - if !metadata.contains_key("modrinth_subscription_id") { - let user_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_subscription_id".to_string(), - to_base62(user_subscription_id.0 as u64), - ); - } + metadata.insert( + "modrinth_price_id".to_string(), + to_base62(price_id.0 as u64), + ); - if let Some(interval) = payment_request.interval { + if let Some(interval) = interval { metadata.insert( "modrinth_subscription_interval".to_string(), interval.as_str().to_string(), @@ -760,25 +850,19 @@ pub async fn initiate_payment( intent.customer = Some(customer); intent.metadata = Some(metadata); - intent.automatic_payment_methods = Some(CreatePaymentIntentAutomaticPaymentMethods { - allow_redirects: None, - enabled: false, - }); intent.receipt_email = user.email.as_deref(); intent.setup_future_usage = Some(PaymentIntentSetupFutureUsage::OffSession); - intent.payment_method_types = Some(vec!["card".to_string(), "cashapp".to_string()]); if let PaymentRequestType::PaymentMethod { .. } = payment_request.type_ { intent.payment_method = Some(payment_method.id.clone()); } let payment_intent = stripe::PaymentIntent::create(&stripe_client, intent).await?; - transaction.commit().await?; Ok(HttpResponse::Ok().json(serde_json::json!({ "payment_intent_id": payment_intent.id, "client_secret": payment_intent.client_secret, - "price_id": to_base62(price_item.id.0 as u64), + "price_id": to_base62(price_id.0 as u64), "tax": 0, "total": price, "payment_method": payment_method, @@ -806,78 +890,205 @@ 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, + 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, + 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?; - - 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)) + '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? { - 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?; + price + } else { + break 'metadata; + }; - (Some((subscription_id, interval)), subscription) - } else { - (None, None) - } + let product = if let Some(product) = + product_item::ProductItem::get(price.product_id, pool).await? + { + product } else { - (None, None) + break 'metadata; }; - if let Some(price_id) = metadata + 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 { + break 'metadata; + }; + + if let Some(interval) = charge.subscription_interval { + subscription.interval = interval; + } + subscription.upsert(transaction).await?; + + (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?; - - if let Some(product_price) = price { - let product = - product_item::ProductItem::get(product_price.product_id, pool) - .await?; - - if let Some(product) = product { - return Ok(PaymentIntentMetadata { - user, - user_subscription_data, - user_subscription, - product, - product_price, - }); + price_id + } else { + break 'metadata; + }; + + let price = if let Some(price) = + product_item::ProductPriceItem::get(price_id, pool).await? + { + price + } else { + break 'metadata; + }; + + 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(), + status: if charge_status == ChargeStatus::Succeeded { + SubscriptionStatus::Provisioned + } else { + SubscriptionStatus::Unprovisioned + }, + 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(), + status: charge_status, + due: Utc::now(), + last_attempt: Some(Utc::now()), + type_: if subscription.is_some() { + ChargeType::Subscription + } else { + ChargeType::OneTime + }, + subscription_id: subscription.as_ref().map(|x| x.id), + subscription_interval: subscription.as_ref().map(|x| x.interval), + }; + + 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( @@ -888,43 +1099,21 @@ 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, + &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!( " @@ -933,16 +1122,88 @@ 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 { + if metadata.charge_item.status != ChargeStatus::Cancelled { + 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, + status: ChargeStatus::Open, + due: Utc::now() + subscription.interval.duration(), + last_attempt: None, + type_: ChargeType::Subscription, + subscription_id: Some(subscription.id), + subscription_interval: Some(subscription.interval), + }; + let err = charge.upsert(&mut transaction).await; + + err?; + } } transaction.commit().await?; crate::database::models::user_item::User::clear_caches( - &[(metadata.user.id, None)], + &[(metadata.user_item.id, None)], &redis, ) .await?; @@ -950,83 +1211,45 @@ 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, + &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, + &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?; @@ -1114,17 +1337,19 @@ async fn get_or_create_customer( } } -pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) { - // 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 +pub async fn subscription_task(pool: PgPool, redis: RedisPool) { loop { - info!("Indexing billing queue"); + info!("Indexing subscriptions"); + let res = async { - let expired = - user_subscription_item::UserSubscriptionItem::get_all_expired(&pool).await?; + let mut transaction = pool.begin().await?; + let mut clear_cache_users = Vec::new(); + + // If an active subscription has a canceled charge OR a failed charge more than two days ago, it should be cancelled + let all_subscriptions = + user_subscription_item::UserSubscriptionItem::get_all_unprovision(&pool).await?; let subscription_prices = product_item::ProductPriceItem::get_many( - &expired + &all_subscriptions .iter() .map(|x| x.price_id) .collect::>() @@ -1144,7 +1369,7 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) ) .await?; let users = crate::database::models::User::get_many_ids( - &expired + &all_subscriptions .iter() .map(|x| x.user_id) .collect::>() @@ -1155,138 +1380,81 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) ) .await?; - let mut transaction = pool.begin().await?; - let mut clear_cache_users = Vec::new(); - - for mut subscription in expired { - let user = users.iter().find(|x| x.id == subscription.user_id); - - if let Some(user) = user { - let product_price = subscription_prices - .iter() - .find(|x| x.id == subscription.price_id); - - if let Some(product_price) = product_price { - let product = subscription_products - .iter() - .find(|x| x.id == product_price.product_id); - - if let Some(product) = product { - let price = match &product_price.prices { - Price::OneTime { price } => Some(price), - Price::Recurring { intervals } => { - intervals.get(&subscription.interval) - } - }; - - 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?; - } - } - - clear_cache_users.push(user.id); - } - - if cancelled { - user_subscription_item::UserSubscriptionItem::remove( - subscription.id, - &mut transaction, - ) - .await?; - } else if payment_failed || active { - let customer_id = get_or_create_customer( - user.id.into(), - user.stripe_customer_id.as_deref(), - user.email.as_deref(), - &stripe_client, - &pool, - &redis, - ) - .await?; - - let customer = stripe::Customer::retrieve( - &stripe_client, - &customer_id, - &[], - ) - .await?; - - let mut intent = CreatePaymentIntent::new( - *price as i64, - Currency::from_str(&product_price.currency_code) - .unwrap_or(Currency::USD), - ); - - let mut metadata = HashMap::new(); - metadata.insert( - "modrinth_user_id".to_string(), - 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(), - ); - - intent.metadata = Some(metadata); - intent.customer = Some(customer_id); - - if let Some(payment_method) = customer - .invoice_settings - .and_then(|x| x.default_payment_method.map(|x| x.id())) - { - intent.payment_method = Some(payment_method); - intent.confirm = Some(true); - intent.off_session = - Some(PaymentIntentOffSession::Exists(true)); - - subscription.status = SubscriptionStatus::PaymentProcessing; - stripe::PaymentIntent::create(&stripe_client, intent) - .await?; - } else { - subscription.status = SubscriptionStatus::PaymentFailed; - } + for mut subscription in all_subscriptions { + let product_price = if let Some(product_price) = subscription_prices + .iter() + .find(|x| x.id == subscription.price_id) + { + product_price + } else { + continue; + }; - subscription.upsert(&mut transaction).await?; - } + let product = if let Some(product) = subscription_products + .iter() + .find(|x| x.id == product_price.product_id) + { + product + } else { + continue; + }; + + let user = if let Some(user) = users.iter().find(|x| x.id == subscription.user_id) { + user + } else { + continue; + }; + + let unprovisioned = match product.metadata { + ProductMetadata::Midas => { + let badges = user.badges - Badges::MIDAS; + + sqlx::query!( + " + UPDATE users + SET badges = $1 + WHERE (id = $2) + ", + badges.bits() as i64, + user.id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + true + } + 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": "cancelled" + })) + .send() + .await; + + if let Err(e) = res { + warn!("Error suspending pyro server: {:?}", e); + false + } else { + true } + } else { + true } } + }; + + if unprovisioned { + subscription.status = SubscriptionStatus::Unprovisioned; + subscription.upsert(&mut transaction).await?; } + + clear_cache_users.push(user.id); } crate::database::models::User::clear_caches( @@ -1299,6 +1467,141 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) .await?; transaction.commit().await?; + Ok::<(), ApiError>(()) + }; + + if let Err(e) = res.await { + warn!("Error indexing billing queue: {:?}", e); + } + + info!("Done indexing billing queue"); + + tokio::time::sleep(std::time::Duration::from_secs(60 * 5)).await; + } +} + +pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) { + loop { + info!("Indexing billing queue"); + let res = async { + // If a charge is open and due or has been attempted more than two days ago, it should be processed + let charges_to_do = + crate::database::models::charge_item::ChargeItem::get_chargeable(&pool).await?; + + let prices = product_item::ProductPriceItem::get_many( + &charges_to_do + .iter() + .map(|x| x.price_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + ) + .await?; + + let users = crate::database::models::User::get_many_ids( + &charges_to_do + .iter() + .map(|x| x.user_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + &redis, + ) + .await?; + + let mut transaction = pool.begin().await?; + + for mut charge in charges_to_do { + let product_price = + if let Some(price) = prices.iter().find(|x| x.id == charge.price_id) { + price + } else { + continue; + }; + + let user = if let Some(user) = users.iter().find(|x| x.id == charge.user_id) { + user + } else { + continue; + }; + + let price = match &product_price.prices { + Price::OneTime { price } => Some(price), + Price::Recurring { intervals } => { + if let Some(ref interval) = charge.subscription_interval { + intervals.get(interval) + } else { + warn!("Could not find subscription for charge {:?}", charge.id); + continue; + } + } + }; + + if let Some(price) = price { + let customer_id = get_or_create_customer( + user.id.into(), + user.stripe_customer_id.as_deref(), + user.email.as_deref(), + &stripe_client, + &pool, + &redis, + ) + .await?; + + let customer = + stripe::Customer::retrieve(&stripe_client, &customer_id, &[]).await?; + + let currency = + match Currency::from_str(&product_price.currency_code.to_lowercase()) { + Ok(x) => x, + Err(_) => { + warn!( + "Could not find currency for {}", + product_price.currency_code + ); + continue; + } + }; + + let mut intent = CreatePaymentIntent::new(*price as i64, currency); + + let mut metadata = HashMap::new(); + metadata.insert( + "modrinth_user_id".to_string(), + to_base62(charge.user_id.0 as u64), + ); + metadata.insert( + "modrinth_charge_id".to_string(), + to_base62(charge.id.0 as u64), + ); + + intent.metadata = Some(metadata); + intent.customer = Some(customer.id); + + if let Some(payment_method) = customer + .invoice_settings + .and_then(|x| x.default_payment_method.map(|x| x.id())) + { + intent.payment_method = Some(payment_method); + intent.confirm = Some(true); + intent.off_session = Some(PaymentIntentOffSession::Exists(true)); + + charge.status = ChargeStatus::Processing; + + stripe::PaymentIntent::create(&stripe_client, intent).await?; + } else { + charge.status = ChargeStatus::Failed; + charge.last_attempt = Some(Utc::now()); + } + + charge.upsert(&mut transaction).await?; + } + } + + transaction.commit().await?; + Ok::<(), ApiError>(()) } .await; diff --git a/src/routes/internal/flows.rs b/src/routes/internal/flows.rs index 094e40ff..ef7d395e 100644 --- a/src/routes/internal/flows.rs +++ b/src/routes/internal/flows.rs @@ -510,7 +510,7 @@ impl AuthProvider { map.insert("grant_type", "authorization_code"); let token: AccessToken = reqwest::Client::new() - .post(&format!("{api_url}oauth2/token")) + .post(format!("{api_url}oauth2/token")) .header(reqwest::header::ACCEPT, "application/json") .header( AUTHORIZATION, @@ -766,7 +766,7 @@ impl AuthProvider { let api_url = dotenvy::var("PAYPAL_API_URL")?; let paypal_user: PayPalUser = reqwest::Client::new() - .get(&format!( + .get(format!( "{api_url}identity/openidconnect/userinfo?schema=openid" )) .header(reqwest::header::USER_AGENT, "Modrinth") @@ -1393,7 +1393,7 @@ pub async fn sign_up_beehiiv(email: &str) -> Result<(), AuthenticationError> { let client = reqwest::Client::new(); client - .post(&format!( + .post(format!( "https://api.beehiiv.com/v2/publications/{id}/subscriptions" )) .header(AUTHORIZATION, format!("Bearer {}", api_key)) diff --git a/src/routes/internal/gdpr.rs b/src/routes/internal/gdpr.rs index 8d7f51da..e07855e5 100644 --- a/src/routes/internal/gdpr.rs +++ b/src/routes/internal/gdpr.rs @@ -21,7 +21,7 @@ pub async fn export( &req, &**pool, &redis, - &*session_queue, + &session_queue, Some(&[Scopes::SESSION_ACCESS]), ) .await? @@ -34,19 +34,19 @@ pub async fn export( crate::database::models::Collection::get_many(&collection_ids, &**pool, &redis) .await? .into_iter() - .map(|x| crate::models::collections::Collection::from(x)) + .map(crate::models::collections::Collection::from) .collect::>(); let follows = crate::database::models::User::get_follows(user_id, &**pool) .await? .into_iter() - .map(|x| crate::models::ids::ProjectId::from(x)) + .map(crate::models::ids::ProjectId::from) .collect::>(); let projects = crate::database::models::User::get_projects(user_id, &**pool, &redis) .await? .into_iter() - .map(|x| crate::models::ids::ProjectId::from(x)) + .map(crate::models::ids::ProjectId::from) .collect::>(); let org_ids = crate::database::models::User::get_organizations(user_id, &**pool).await?; @@ -64,7 +64,7 @@ pub async fn export( ) .await? .into_iter() - .map(|x| crate::models::notifications::Notification::from(x)) + .map(crate::models::notifications::Notification::from) .collect::>(); let oauth_clients = @@ -73,7 +73,7 @@ pub async fn export( ) .await? .into_iter() - .map(|x| crate::models::oauth_clients::OAuthClient::from(x)) + .map(crate::models::oauth_clients::OAuthClient::from) .collect::>(); let oauth_authorizations = crate::database::models::oauth_client_authorization_item::OAuthClientAuthorization::get_all_for_user( @@ -81,7 +81,7 @@ pub async fn export( ) .await? .into_iter() - .map(|x| crate::models::oauth_clients::OAuthClientAuthorization::from(x)) + .map(crate::models::oauth_clients::OAuthClientAuthorization::from) .collect::>(); let pat_ids = crate::database::models::pat_item::PersonalAccessToken::get_user_pats( @@ -102,7 +102,7 @@ pub async fn export( let payouts = crate::database::models::payout_item::Payout::get_many(&payout_ids, &**pool) .await? .into_iter() - .map(|x| crate::models::payouts::Payout::from(x)) + .map(crate::models::payouts::Payout::from) .collect::>(); let report_ids = @@ -110,7 +110,7 @@ pub async fn export( let reports = crate::database::models::report_item::Report::get_many(&report_ids, &**pool) .await? .into_iter() - .map(|x| crate::models::reports::Report::from(x)) + .map(crate::models::reports::Report::from) .collect::>(); let message_ids = sqlx::query!( @@ -146,7 +146,7 @@ pub async fn export( crate::database::models::image_item::Image::get_many(&uploaded_images_ids, &**pool, &redis) .await? .into_iter() - .map(|x| crate::models::images::Image::from(x)) + .map(crate::models::images::Image::from) .collect::>(); let subscriptions = @@ -155,7 +155,7 @@ pub async fn export( ) .await? .into_iter() - .map(|x| crate::models::billing::UserSubscription::from(x)) + .map(crate::models::billing::UserSubscription::from) .collect::>(); Ok(HttpResponse::Ok().json(serde_json::json!({ diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index f9422de2..cd4335a1 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -106,14 +106,11 @@ pub async fn version_create( // Get all possible side-types for loaders given- we will use these to check if we need to convert/apply singleplayer, etc. let loaders = match v3::tags::loader_list(client.clone(), redis.clone()).await { - Ok(loader_response) => match v2_reroute::extract_ok_json::< - Vec, - >(loader_response) - .await - { - Ok(loaders) => loaders, - Err(_) => vec![], - }, + Ok(loader_response) => { + (v2_reroute::extract_ok_json::>(loader_response) + .await) + .unwrap_or_default() + } Err(_) => vec![], }; diff --git a/src/routes/v3/payouts.rs b/src/routes/v3/payouts.rs index f2d1d6fb..f97844d2 100644 --- a/src/routes/v3/payouts.rs +++ b/src/routes/v3/payouts.rs @@ -352,7 +352,7 @@ pub async fn create_payout( .fetch_optional(&mut *transaction) .await?; - let balance = get_user_balance(user.id.into(), &**pool).await?; + let balance = get_user_balance(user.id, &pool).await?; if balance.available < body.amount || body.amount < Decimal::ZERO { return Err(ApiError::InvalidInput( "You do not have enough funds to make this payout!".to_string(), @@ -734,7 +734,7 @@ pub async fn get_balance( .await? .1; - let balance = get_user_balance(user.id.into(), &**pool).await?; + let balance = get_user_balance(user.id.into(), &pool).await?; Ok(HttpResponse::Ok().json(balance)) } diff --git a/tests/project.rs b/tests/project.rs index 99c68c3b..ed096507 100644 --- a/tests/project.rs +++ b/tests/project.rs @@ -579,94 +579,6 @@ pub async fn test_bulk_edit_links() { .await; } -#[actix_rt::test] -async fn delete_project_with_report() { - with_test_environment(None, |test_env: TestEnvironment| async move { - let api = &test_env.api; - let alpha_project_id: &str = &test_env.dummy.project_alpha.project_id; - let beta_project_id: &str = &test_env.dummy.project_beta.project_id; - - // Create a report for the project - let resp = api - .create_report( - "copyright", - alpha_project_id, - CommonItemType::Project, - "Hey! This is my project, copied without permission!", - ENEMY_USER_PAT, // Enemy makes a report - ) - .await; - assert_status!(&resp, StatusCode::OK); - let value = test::read_body_json::(resp).await; - let alpha_report_id = value["id"].as_str().unwrap(); - - // Confirm existence - let resp = api - .get_report( - alpha_report_id, - ENEMY_USER_PAT, // Enemy makes a report - ) - .await; - assert_status!(&resp, StatusCode::OK); - - // Do the same for beta - let resp = api - .create_report( - "copyright", - beta_project_id, - CommonItemType::Project, - "Hey! This is my project, copied without permission!", - ENEMY_USER_PAT, // Enemy makes a report - ) - .await; - assert_status!(&resp, StatusCode::OK); - let value = test::read_body_json::(resp).await; - let beta_report_id = value["id"].as_str().unwrap(); - - // Delete the project - let resp = api.remove_project(alpha_project_id, USER_USER_PAT).await; - assert_status!(&resp, StatusCode::NO_CONTENT); - - // Confirm that the project is gone from the cache - let mut redis_pool = test_env.db.redis_pool.connect().await.unwrap(); - assert_eq!( - redis_pool - .get(PROJECTS_SLUGS_NAMESPACE, "demo") - .await - .unwrap() - .and_then(|x| x.parse::().ok()), - None - ); - assert_eq!( - redis_pool - .get(PROJECTS_SLUGS_NAMESPACE, alpha_project_id) - .await - .unwrap() - .and_then(|x| x.parse::().ok()), - None - ); - - // Report for alpha no longer exists - let resp = api - .get_report( - alpha_report_id, - ENEMY_USER_PAT, // Enemy makes a report - ) - .await; - assert_status!(&resp, StatusCode::NOT_FOUND); - - // Confirm that report for beta still exists - let resp = api - .get_report( - beta_report_id, - ENEMY_USER_PAT, // Enemy makes a report - ) - .await; - assert_status!(&resp, StatusCode::OK); - }) - .await; -} - #[actix_rt::test] async fn permissions_patch_project_v3() { with_test_environment(Some(8), |test_env: TestEnvironment| async move {