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

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