1
0

Move charges to DB + fix subscription recurring payments (#971)

* Move charges to DB + fix subscription recurring payments

* Finish most + pyro integration

* Finish billing

* Run prepare

* Fix intervals

* Fix clippy

* Remove unused test
This commit is contained in:
Geometrically
2024-10-09 21:11:30 -07:00
committed by GitHub
parent 28b6bf8603
commit c88bfbb5f0
33 changed files with 1692 additions and 816 deletions

4
.env
View File

@@ -103,4 +103,6 @@ FLAME_ANVIL_URL=none
STRIPE_API_KEY=none
STRIPE_WEBHOOK_SECRET=none
ADITUDE_API_KEY=none
ADITUDE_API_KEY=none
PYRO_API_KEY=none

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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;

View File

@@ -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<Utc>,
pub last_attempt: Option<DateTime<Utc>>,
pub type_: ChargeType,
pub subscription_id: Option<UserSubscriptionId>,
pub subscription_interval: Option<PriceDuration>,
}
struct ChargeResult {
id: i64,
user_id: i64,
price_id: i64,
amount: i64,
currency_code: String,
status: String,
due: DateTime<Utc>,
last_attempt: Option<DateTime<Utc>>,
charge_type: String,
subscription_id: Option<i64>,
subscription_interval: Option<String>,
}
impl TryFrom<ChargeResult> for ChargeItem {
type Error = serde_json::Error;
fn try_from(r: ChargeResult) -> Result<Self, Self::Error> {
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<ChargeId, DatabaseError> {
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<Option<ChargeItem>, 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<Vec<ChargeItem>, 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::<Result<Vec<_>, serde_json::Error>>()?)
}
pub async fn get_open_subscription(
user_subscription_id: UserSubscriptionId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Option<ChargeItem>, 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<Vec<ChargeItem>, 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::<Result<Vec<_>, 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(())
}
}

View File

@@ -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<ids::ProjectId> for ProjectId {
@@ -571,3 +583,14 @@ impl From<UserSubscriptionId> for ids::UserSubscriptionId {
ids::UserSubscriptionId(id.0 as u64)
}
}
impl From<ids::ChargeId> for ChargeId {
fn from(id: ids::ChargeId) -> Self {
ChargeId(id.0 as i64)
}
}
impl From<ChargeId> for ids::ChargeId {
fn from(id: ChargeId) -> Self {
ids::ChargeId(id.0 as u64)
}
}

View File

@@ -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;

View File

@@ -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<Utc>,
pub expires: DateTime<Utc>,
pub last_charge: Option<DateTime<Utc>>,
pub status: SubscriptionStatus,
pub metadata: Option<SubscriptionMetadata>,
}
struct UserSubscriptionResult {
@@ -20,9 +20,8 @@ struct UserSubscriptionResult {
price_id: i64,
interval: String,
pub created: DateTime<Utc>,
pub expires: DateTime<Utc>,
pub last_charge: Option<DateTime<Utc>>,
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<UserSubscriptionResult> for UserSubscriptionItem {
fn from(r: UserSubscriptionResult) -> Self {
UserSubscriptionItem {
impl TryFrom<UserSubscriptionResult> for UserSubscriptionItem {
type Error = serde_json::Error;
fn try_from(r: UserSubscriptionResult) -> Result<Self, Self::Error> {
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::<Result<Vec<_>, serde_json::Error>>()?)
}
pub async fn get_all_user(
@@ -82,22 +85,38 @@ impl UserSubscriptionItem {
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<UserSubscriptionItem>, 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::<Result<Vec<_>, serde_json::Error>>()?)
}
pub async fn get_all_expired(
pub async fn get_all_unprovision(
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<UserSubscriptionItem>, 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::<Result<Vec<_>, 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?;

View File

@@ -15,7 +15,7 @@ pub async fn delete_file_version(
file_name: &str,
) -> Result<DeleteFileData, FileHostingError> {
let response = reqwest::Client::new()
.post(&format!(
.post(format!(
"{}/b2api/v2/b2_delete_file_version",
authorization_data.api_url
))

View File

@@ -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::<u64>("ADITUDE_API_KEY");
failed |= check_var::<String>("PYRO_API_KEY");
failed
}

View File

@@ -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<Utc>,
pub expires: DateTime<Utc>,
pub last_charge: Option<DateTime<Utc>>,
pub metadata: Option<SubscriptionMetadata>,
}
impl From<crate::database::models::user_subscription_item::UserSubscriptionItem>
@@ -99,38 +106,119 @@ impl From<crate::database::models::user_subscription_item::UserSubscriptionItem>
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::Active => "active",
SubscriptionStatus::PaymentProcessing => "payment-processing",
SubscriptionStatus::PaymentFailed => "payment-failed",
SubscriptionStatus::Cancelled => "cancelled",
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<Utc>,
pub last_attempt: Option<DateTime<Utc>>,
#[serde(flatten)]
pub type_: ChargeType,
pub subscription_id: Option<UserSubscriptionId>,
pub subscription_interval: Option<PriceDuration>,
}
#[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 {
ChargeStatus::Processing => "processing",
ChargeStatus::Succeeded => "succeeded",
ChargeStatus::Failed => "failed",
ChargeStatus::Open => "open",
ChargeStatus::Cancelled => "cancelled",
}
}
}

View File

@@ -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};

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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))

View File

@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
Ok(HttpResponse::Ok().json(serde_json::json!({

View File

@@ -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<v3::tags::LoaderData>,
>(loader_response)
.await
{
Ok(loaders) => loaders,
Err(_) => vec![],
},
Ok(loader_response) => {
(v2_reroute::extract_ok_json::<Vec<v3::tags::LoaderData>>(loader_response)
.await)
.unwrap_or_default()
}
Err(_) => vec![],
};

View File

@@ -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))
}

View File

@@ -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<ApiV3>| 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::<serde_json::Value, _>(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::<serde_json::Value, _>(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::<i64>().ok()),
None
);
assert_eq!(
redis_pool
.get(PROJECTS_SLUGS_NAMESPACE, alpha_project_id)
.await
.unwrap()
.and_then(|x| x.parse::<i64>().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<ApiV3>| async move {