This commit is contained in:
ntr 2019-06-28 16:08:55 +10:00
parent e6486dc361
commit 4921b0760c
10 changed files with 342 additions and 69 deletions

View File

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

View File

@ -32,15 +32,20 @@ function BitsBtn(args) {
clientReferenceId: account.id
});
}
const subscription = account.subscribed
? <h3 class="account-header">Subscribed</h3>
: <button
onClick={subscribeClick}
class="stripe-btn"
role="link">
Subscribe
</button>;
return (
<div>
<div id="error-message"></div>
<button
onClick={subscribeClick}
class="stripe-btn"
role="link">
Subscribe
</button>
{subscription}
<button
onClick={bitsClick}
class="stripe-btn"
@ -84,12 +89,13 @@ function AccountStatus(args) {
if (!account) return null;
return (
<div>
<div class="header-status">
<h2 class="header-username">{account.name}</h2>
<div class="account">
<div class="account-status">
<h2 class="account-header">{account.name}</h2>
{saw(pingColour(ping))}
<div class="ping-text">{ping}ms</div>
</div>
<h3 class="account-header">{`¤${account.credits}`}</h3>
<Elements>
<StripeBitsBtn account={account} />
</Elements>

View File

@ -78,6 +78,7 @@ class Login extends Component {
class="login-input"
type="email"
placeholder="username"
tabIndex={1}
value={this.state.name}
onInput={this.nameInput}
/>
@ -85,6 +86,7 @@ class Login extends Component {
class="login-input"
type="password"
placeholder="password"
tabIndex={2}
value={this.state.password}
onInput={this.passwordInput}
/>
@ -92,16 +94,19 @@ class Login extends Component {
class="login-input"
type="text"
placeholder="code"
tabIndex={3}
value={this.state.code}
onInput={this.codeInput}
/>
<button
class="login-btn"
tabIndex={4}
onClick={this.loginSubmit}>
Login
</button>
<button
class="login-btn"
tabIndex={5}
onClick={this.registerSubmit}>
Register
</button>

View File

@ -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 () => {};

View File

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

View File

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

View File

@ -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<Account, Error> {
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<Account, Error> {
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<Account, Error> {
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<String, Error> {
pub fn new_token(tx: &mut Transaction, id: Uuid) -> Result<String, Error> {
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<String, Error> {
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<String, Error> {
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<String, Error> {
if password.len() < PASSWORD_MIN_LEN {
@ -251,4 +296,4 @@ pub fn account_instances(tx: &mut Transaction, account: &Account) -> Result<Vec<
list.sort_unstable_by_key(|c| c.name.clone());
return Ok(list);
}
}

137
server/src/mtx.rs Normal file
View File

@ -0,0 +1,137 @@
use uuid::Uuid;
// use rand::prelude::*;
use serde_cbor::{from_slice};
use postgres::transaction::Transaction;
use failure::Error;
use failure::err_msg;
#[derive(Debug,Copy,Clone,Serialize,Deserialize)]
pub enum MtxVariant {
ArchitectureMolecular,
ArchitectureInvader,
}
impl MtxVariant {
fn new(self, account: Uuid) -> 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<Vec<Mtx>, 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<u8> = row.get(0);
// let id: Uuid = row.get(1);
match from_slice::<Mtx>(&bytes) {
Ok(i) => Some(i),
Err(e) => {
warn!("{:?}", e);
None
}
}
}).collect::<Vec<Mtx>>();
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<Option<Mtx>, 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<u8> = row.get(0);
Ok(Some(from_slice::<Mtx>(&bytes)?))
} else {
Err(format_err!("mtx not found {:?}", id))
}
}
// actual impl
}

View File

@ -84,7 +84,7 @@ fn login(state: web::Data<State>, params: web::Json::<AccountLoginParams>) -> Re
match Account::login(&mut tx, &params.name, &params.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<State>) -> Result<HttpResponse, MnmlH
let mut tx = db.transaction().or(Err(MnmlHttpError::ServerError))?;
match Account::from_token(&mut tx, t.value().to_string()) {
Ok(a) => {
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());
},

View File

@ -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<Uuid, Error> {
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<Vec<StripeData>, Err
return Ok(items);
}
fn process_stripe(event: Event, pool: &PgPool) -> Result<Vec<StripeData>, Error> {
fn process_stripe_event(event: Event, pool: &PgPool) -> Result<String, Error> {
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<Vec<StripeData>, 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<State>, body: web::Json::<Event>) -> Result<HttpResponse, MnmlHttpError> {
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)]