diff --git a/client/assets/styles/styles.css b/client/assets/styles/styles.css index eb18d3ef..35f0b2ea 100644 --- a/client/assets/styles/styles.css +++ b/client/assets/styles/styles.css @@ -293,7 +293,7 @@ button[disabled] { } /* - HEADER + account */ header { @@ -303,23 +303,26 @@ header { margin-bottom: 1.5em; } -.header-title { +.account { + margin: 1em 0; +} + +.account-title { flex: 1; letter-spacing: 0.05em; } -.header-status { - margin: 1em 0; +.account-status { display: flex; } -.header-username { +.account-header { letter-spacing: 0.05em; flex: 1; display: inline; } -.header-status svg { +.account-status svg { margin: 0.5em 0 0 1em; height: 1em; background-color: black; diff --git a/client/src/components/account.status.jsx b/client/src/components/account.status.jsx index 2a7d6edf..4ad082bb 100644 --- a/client/src/components/account.status.jsx +++ b/client/src/components/account.status.jsx @@ -32,15 +32,20 @@ function BitsBtn(args) { clientReferenceId: account.id }); } + + const subscription = account.subscribed + ?

Subscribed

+ : ; + return (
- + {subscription} diff --git a/ops/migrations/20180913000513_create_accounts.js b/ops/migrations/20180913000513_create_accounts.js index 78fb2381..f693e90c 100755 --- a/ops/migrations/20180913000513_create_accounts.js +++ b/ops/migrations/20180913000513_create_accounts.js @@ -2,14 +2,29 @@ exports.up = async knex => { return knex.schema.createTable('accounts', table => { table.uuid('id').primary(); table.timestamps(true, true); + table.string('name', 42).notNullable().unique(); table.string('password').notNullable(); + table.string('token', 64).notNullable(); table.timestamp('token_expiry').notNullable(); + table.bigInteger('credits') + .defaultTo(0) + .notNullable(); + + table.bool('subscribed') + .defaultTo(false) + .notNullable(); + table.index('name'); table.index('id'); }); + + await knex.schema.raw(` + ALTER TABLE accounts + ADD CHECK (credits > 0); + `); }; exports.down = async () => {}; \ No newline at end of file diff --git a/ops/migrations/20190624170147_stripe.js b/ops/migrations/20190624170147_stripe.js index fc53b0a3..d9482e84 100644 --- a/ops/migrations/20190624170147_stripe.js +++ b/ops/migrations/20190624170147_stripe.js @@ -4,7 +4,7 @@ exports.up = async knex => { await knex.schema.createTable('stripe_customers', table => { - table.string('customer', 64) + table.string('customer', 128) .primary(); table.uuid('account') @@ -16,7 +16,7 @@ exports.up = async knex => { .inTable('accounts') .onDelete('RESTRICT'); - table.string('checkout', 64) + table.string('checkout', 128) .notNullable() .unique(); @@ -24,7 +24,7 @@ exports.up = async knex => { }); await knex.schema.createTable('stripe_subscriptions', table => { - table.string('subscription', 64) + table.string('subscription', 128) .primary(); table.uuid('account') @@ -36,14 +36,17 @@ exports.up = async knex => { .inTable('accounts') .onDelete('RESTRICT'); - table.string('customer', 64) + table.string('customer', 128) + .notNullable(); + + table.string('checkout', 128) .notNullable(); table.timestamps(true, true); }); await knex.schema.createTable('stripe_purchases', table => { - table.string('checkout', 64) + table.string('checkout', 128) .primary(); table.uuid('account') @@ -55,7 +58,7 @@ exports.up = async knex => { .inTable('accounts') .onDelete('RESTRICT'); - table.string('customer', 64) + table.string('customer', 128) .notNullable(); table.bigInteger('amount') diff --git a/server/Cargo.toml b/server/Cargo.toml index 6c9626eb..96b04263 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -31,4 +31,9 @@ actix-web = "1.0.0" actix-web-actors = "1.0.0" actix-cors = "0.1.0" -stripe-rust = { version = "0.10", features = ["webhooks"] } +stripe-rust = { version = "0.10.4", features = ["webhooks"] } + +[patch.crates-io] +# stripe-rust = { git = "https://github.com/margh/stripe-rs.git" } + +stripe-rust = { path = "/home/ntr/code/stripe-rs" } diff --git a/server/src/account.rs b/server/src/account.rs index 7d3f0e27..9705fc4e 100644 --- a/server/src/account.rs +++ b/server/src/account.rs @@ -21,15 +21,15 @@ pub struct Account { pub id: Uuid, pub name: String, pub credits: u32, + pub subscribed: bool, } impl Account { pub fn select(tx: &mut Transaction, id: Uuid) -> Result { let query = " - SELECT id, name, credits + SELECT id, name, credits, subscribed FROM accounts - WHERE id = $1 - FOR UPDATE; + WHERE id = $1; "; let result = tx @@ -38,17 +38,18 @@ impl Account { let row = result.iter().next() .ok_or(format_err!("account not found {:?}", id))?; - let db_credits: i32 = row.get(2); - + let db_credits: i64 = row.get(2); let credits = u32::try_from(db_credits) .or(Err(format_err!("user {:?} has unparsable balance {:?}", id, db_credits)))?; - Ok(Account { id, name: row.get(1), credits }) + let subscribed: bool = row.get(3); + + Ok(Account { id, name: row.get(1), credits, subscribed }) } pub fn from_token(tx: &mut Transaction, token: String) -> Result { let query = " - SELECT id, name, credits + SELECT id, name, subscribed, credits FROM accounts WHERE token = $1 AND token_expiry > now(); @@ -61,17 +62,19 @@ impl Account { .ok_or(err_msg("invalid token"))?; let id: Uuid = row.get(0); - let db_credits: i32 = row.get(2); + let name: String = row.get(1); + let subscribed: bool = row.get(2); + let db_credits: i64 = row.get(3); let credits = u32::try_from(db_credits) .or(Err(format_err!("user {:?} has unparsable balance {:?}", id, db_credits)))?; - Ok(Account { id, name: row.get(1), credits }) + Ok(Account { id, name, credits, subscribed }) } pub fn login(tx: &mut Transaction, name: &String, password: &String) -> Result { let query = " - SELECT id, password, name, credits + SELECT id, password, name, credits, subscribed FROM accounts WHERE name = $1 "; @@ -97,7 +100,8 @@ impl Account { let id: Uuid = row.get(0); let hash: String = row.get(1); let name: String = row.get(2); - let db_credits: i32 = row.get(3); + let db_credits: i64 = row.get(3); + let subscribed: bool = row.get(4); if !verify(password, &hash)? { return Err(err_msg("password does not match")); @@ -106,10 +110,10 @@ impl Account { let credits = u32::try_from(db_credits) .or(Err(format_err!("user {:?} has unparsable balance {:?}", id, db_credits)))?; - Ok(Account { id, name, credits }) + Ok(Account { id, name, credits, subscribed }) } - pub fn new_token(&self, tx: &mut Transaction) -> Result { + pub fn new_token(tx: &mut Transaction, id: Uuid) -> Result { let mut rng = thread_rng(); let token: String = iter::repeat(()) .map(|()| rng.sample(Alphanumeric)) @@ -125,26 +129,67 @@ impl Account { "; let result = tx - .query(query, &[&token, &self.id])?; + .query(query, &[&token, &id])?; - let _row = result.iter().next() - .ok_or(format_err!("account not updated {:?}", self.id))?; + let row = result.iter().next() + .ok_or(format_err!("account not updated {:?}", id))?; - info!("login account={:?}", self.name); + let name: String = row.get(1); + + info!("login account={:?}", name); Ok(token) } -} + pub fn add_credits(tx: &mut Transaction, id: Uuid, credits: i64) -> Result { + let query = " + UPDATE accounts + SET credits = credits + $1 + WHERE id = $2 + RETURNING credits, name; + "; -#[derive(Debug,Clone,Serialize,Deserialize)] -struct AccountEntry { - id: Uuid, - name: String, - password: String, - token: String, -} + let result = tx + .query(query, &[&credits, &id])?; + let row = result.iter().next() + .ok_or(format_err!("account not updated {:?}", id))?; + + println!("{:?}", row); + + let db_credits: i64 = row.get(0); + let total = u32::try_from(db_credits) + .or(Err(format_err!("user {:?} has unparsable balance {:?}", id, db_credits)))?; + + let name: String = row.get(1); + + info!("account credited name={:?} credited={:?} total={:?}", name, credits, total); + + Ok(name) + } + + pub fn set_subscribed(tx: &mut Transaction, id: Uuid, subscribed: bool) -> Result { + let query = " + UPDATE accounts + SET subscribed = $1 + WHERE id = $2 + RETURNING name; + "; + + let result = tx + .query(query, &[&subscribed, &id])?; + + let row = result.iter().next() + .ok_or(format_err!("account not updated {:?}", id))?; + + let name: String = row.get(0); + + info!("account subscription status updated name={:?} subscribed={:?}", name, subscribed); + + Ok(name) + } + +} pub fn account_create(name: &String, password: &String, code: &String, tx: &mut Transaction) -> Result { if password.len() < PASSWORD_MIN_LEN { @@ -251,4 +296,4 @@ pub fn account_instances(tx: &mut Transaction, account: &Account) -> Result Mtx { + match self { + MtxVariant::ArchitectureInvader => Mtx { id: Uuid::new_v4(), account, variant: self }, + MtxVariant::ArchitectureMolecular => Mtx { id: Uuid::new_v4(), account, variant: self }, + } + } +} + +#[derive(Debug,Copy,Clone,Serialize,Deserialize)] +pub struct Mtx { + id: Uuid, + account: Uuid, + variant: MtxVariant, +} + +impl Mtx { + pub fn account_list(tx: &mut Transaction, account: Uuid) -> Result, Error> { + let query = " + SELECT data, id + FROM mtx + WHERE account = $1 + FOR UPDATE; + "; + + let result = tx + .query(query, &[&account])?; + + let values = result.into_iter().filter_map(|row| { + let bytes: Vec = row.get(0); + // let id: Uuid = row.get(1); + + match from_slice::(&bytes) { + Ok(i) => Some(i), + Err(e) => { + warn!("{:?}", e); + None + } + } + }).collect::>(); + + return Ok(values); + } + + pub fn delete(tx: &mut Transaction, id: Uuid) -> Result<(), Error> { + let query = " + DELETE + FROM mtx + WHERE id = $1; + "; + + let result = tx + .execute(query, &[&id])?; + + if result != 1 { + return Err(format_err!("unable to delete mtx {:?}", id)); + } + + info!("mtx deleted {:?}", id); + + return Ok(()); + } + + pub fn insert(&self, tx: &mut Transaction) -> Result<&Mtx, Error> { + let query = " + INSERT INTO mtx (id, account, variant) + VALUES ($1, $2, $3) + RETURNING id, account; + "; + + let result = tx + .query(query, &[&self.id, &self.account, &format!("{:?}", self.variant)])?; + + result.iter().next().ok_or(err_msg("mtx not written"))?; + + info!("wrote mtx {:?}", self); + + return Ok(self); + } + + // pub fn update(&self, tx: &mut Transaction) -> Result<&Mtx, Error> { + // let query = " + // UPDATE mtx + // SET data = $1, updated_at = now() + // WHERE id = $2 + // RETURNING id, data; + // "; + + // let result = tx + // .query(query, &[&self.id, &to_vec(self)?])?; + + // if let None = result.iter().next() { + // return Err(err_msg("mtx not written")); + // } + + // info!("wrote mtx {:?}", self); + + // return Ok(self); + // } + + pub fn select(tx: &mut Transaction, id: Uuid, account: Uuid) -> Result, Error> { + let query = " + SELECT data, id + FROM mtx + WHERE account = $1 + AND id = $2 + FOR UPDATE; + "; + + let result = tx + .query(query, &[&account, &id])?; + + if let Some(row) = result.iter().next() { + let bytes: Vec = row.get(0); + Ok(Some(from_slice::(&bytes)?)) + } else { + Err(format_err!("mtx not found {:?}", id)) + } + } + + // actual impl +} diff --git a/server/src/net.rs b/server/src/net.rs index 66428570..21d67e70 100644 --- a/server/src/net.rs +++ b/server/src/net.rs @@ -84,7 +84,7 @@ fn login(state: web::Data, params: web::Json::) -> Re match Account::login(&mut tx, ¶ms.name, ¶ms.password) { Ok(a) => { - let token = a.new_token(&mut tx).or(Err(MnmlHttpError::ServerError))?; + let token = Account::new_token(&mut tx, a.id).or(Err(MnmlHttpError::ServerError))?; tx.commit().or(Err(MnmlHttpError::ServerError))?; Ok(login_res(token)) }, @@ -102,7 +102,7 @@ fn logout(r: HttpRequest, state: web::Data) -> Result { - a.new_token(&mut tx).or(Err(MnmlHttpError::Unauthorized))?; + Account::new_token(&mut tx, a.id).or(Err(MnmlHttpError::Unauthorized))?; tx.commit().or(Err(MnmlHttpError::ServerError))?; return Ok(logout_res()); }, diff --git a/server/src/payments.rs b/server/src/payments.rs index a4fede82..8c52b754 100644 --- a/server/src/payments.rs +++ b/server/src/payments.rs @@ -5,14 +5,32 @@ use postgres::transaction::Transaction; use failure::Error; use failure::err_msg; -use stripe::{Event, EventObject, CheckoutSession}; +use stripe::{Event, EventObject, CheckoutSession, SubscriptionStatus}; use net::{State, PgPool, MnmlHttpError}; use account::{Account}; +pub fn subscription_account(tx: &mut Transaction, sub: String) -> Result { + let query = " + SELECT account + FROM stripe_subscriptions + WHERE subscription = $1; + "; + + let result = tx + .query(query, &[&sub])?; + + let row = result.iter().next() + .ok_or(err_msg("user not subscribed"))?; + + Ok(row.get(0)) +} + // we use i64 because it is converted to BIGINT for pg -const CREDITS_COST_CENTS: i64 = 20; -const CREDITS_SUB_BONUS: i64 = 25; +// and we can losslessly pull it into u32 which is big +// enough for the ballers +const CREDITS_COST_CENTS: i64 = 10; +const CREDITS_SUB_BONUS: i64 = 40; // Because the client_reference_id (account.id) is only included // in the stripe CheckoutSession object @@ -60,14 +78,25 @@ impl StripeData { } } - fn add_credits(&self, tx: &mut Transaction) -> Result<&StripeData, Error> { + fn side_effects(&self, tx: &mut Transaction) -> Result<&StripeData, Error> { match self { + // when we get a subscription we just immediately set the user to be subbed + // so we don't have to deal with going to fetch all the details from + // stripe just to double check + // update webhooks will tell us when the subscription changes + // see EventObject::Subscription handler below StripeData::Subscription { subscription: _, account, customer: _, checkout: _ } => { - let account = Account::select(tx, *account)?; + Account::add_credits(tx, *account, CREDITS_SUB_BONUS)?; + Account::set_subscribed(tx, *account, true)?; Ok(self) }, - StripeData::Purchase { account: _, customer: _, amount, checkout: _ } => { - amount.checked_div(CREDITS_COST_CENTS).expect("credits cost 0"); + StripeData::Purchase { account, customer: _, amount, checkout: _ } => { + let credits = amount + .checked_div(CREDITS_COST_CENTS) + .expect("credits cost 0"); + + Account::add_credits(tx, *account, credits)?; + Ok(self) }, _ => Ok(self), @@ -108,8 +137,11 @@ fn stripe_checkout_data(session: CheckoutSession) -> Result, Err return Ok(items); } -fn process_stripe(event: Event, pool: &PgPool) -> Result, Error> { +fn process_stripe_event(event: Event, pool: &PgPool) -> Result { info!("stripe event {:?}", event); + let connection = pool.get()?; + let mut tx = connection.transaction()?; + match event.data.object { EventObject::CheckoutSession(s) => { let data = match stripe_checkout_data(s) { @@ -120,28 +152,50 @@ fn process_stripe(event: Event, pool: &PgPool) -> Result, Error> } }; - let connection = pool.get()?; - let mut tx = connection.transaction()?; - for item in data.iter() { item.insert(&mut tx)?; - item.add_credits(&mut tx)?; + item.side_effects(&mut tx)?; } - - tx.commit()?; - Ok(data) }, + + // we only receive the cancelled and updated events + // because the checkout object is needed to link + // a sub to an account initially and + // stripe doesn't guarantee the order + // so this just checks if the sub is still active + EventObject::Subscription(s) => { + let account = subscription_account(&mut tx, s.id.to_string())?; + + let subbed = match s.status { + SubscriptionStatus::Active => true, + _ => false, + }; + + Account::set_subscribed(&mut tx, account, subbed)?; + } _ => { error!("unhandled stripe event {:?}", event); - Err(err_msg("UnhanldedEvent")) + return Err(err_msg("UnhanldedEvent")); }, - } + }; + + tx.commit()?; + + Ok(event.id.to_string()) } pub fn post_stripe_event(state: web::Data, body: web::Json::) -> Result { let event: Event = body.into_inner(); - process_stripe(event, &state.pool).or(Err(MnmlHttpError::ServerError))?; - Ok(HttpResponse::Accepted().finish()) + match process_stripe_event(event, &state.pool) { + Ok(id)=> { + info!("event processed successfully {:?}", id); + Ok(HttpResponse::Ok().finish()) + } + Err(e) => { + error!("{:?}", e); + Err(MnmlHttpError::ServerError) + } + } } #[cfg(test)]