stripe sub cancellations

This commit is contained in:
ntr 2019-08-28 18:57:23 +10:00
parent 54a65fc160
commit d726aa8d33
13 changed files with 173 additions and 39 deletions

View File

@ -3,8 +3,7 @@
*PRODUCTION* *PRODUCTION*
* ACP * ACP
* essential * essential
* error log * stripe verify
* account lookup w/ pw reset
* treats * treats
* constructs jiggle when clicked * constructs jiggle when clicked
@ -13,12 +12,9 @@
* bot game grind * bot game grind
* serde serialize privatise * serde serialize privatise
* stripe prod
* mobile styles * mobile styles
* change score to enum * change score to enum
* pct based translates for combat animation * pct based translates for combat animation
* account page
* graphs n shit
* acp init * acp init
* DO postgres * DO postgres
* make our own toasts / msg pane * make our own toasts / msg pane
@ -28,6 +24,8 @@
* clear skill (if currently targetted) * clear skill (if currently targetted)
* increase power to speed up early rounds * increase power to speed up early rounds
* only login / logout / register http
## SOON ## SOON
*SERVER* *SERVER*
* modules * modules
@ -38,12 +36,15 @@
* empower on ko * empower on ko
* rework vecs into sets * rework vecs into sets
* remove names so games/instances are copy
*$$$* *$$$*
* chatwheel * chatwheel
* eth adapter * eth adapter
* illusions * illusions
* vaporwave * vaporwave
* crop circles
* insects
* sacred geometry * sacred geometry
* skulls / day of the dead * skulls / day of the dead
* Aztec * Aztec

View File

@ -20,6 +20,18 @@
display: block; display: block;
} }
.unsub {
&:hover {
color: @red;
border-color: @red;
};
&:active, &.confirming {
background: @red;
color: black;
border: 1px solid black;
}
}
.list { .list {
letter-spacing: 0.25em; letter-spacing: 0.25em;

View File

@ -78,6 +78,10 @@ p {
margin-bottom: 1em; margin-bottom: 1em;
} }
dl {
margin: 1em 0;
}
#mnml { #mnml {
display: grid; display: grid;
grid-template-columns: minmax(min-content, 1fr) 8fr 1fr; grid-template-columns: minmax(min-content, 1fr) 8fr 1fr;

View File

@ -30,7 +30,7 @@ document.fonts.load('16pt "Jura"').then(() => {
const App = () => ( const App = () => (
<Provider store={store}> <Provider store={store}>
<StripeProvider apiKey="pk_test_PiLzjIQE7zUy3Xpott7tdQbl00uLiCesTa"> <StripeProvider apiKey="pk_test_Cb49tTqTXpzk7nEmlGzRrNJg00AU0aNZDj">
<Mnml /> <Mnml />
</StripeProvider> </StripeProvider>
</Provider> </Provider>

View File

@ -18,7 +18,7 @@ const addState = connect(
} = state; } = state;
function setPassword(current, password) { function sendSetPassword(current, password) {
postData('/account/password', { current, password }) postData('/account/password', { current, password })
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
@ -29,7 +29,7 @@ const addState = connect(
.catch(error => errorToast(error)); .catch(error => errorToast(error));
} }
function setEmail(email) { function sendSetEmail(email) {
postData('/account/email', { email }) postData('/account/email', { email })
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
@ -49,14 +49,19 @@ const addState = connect(
return ws.sendMtxConstructSpawn(name); return ws.sendMtxConstructSpawn(name);
} }
function sendSubscriptionCancel() {
return ws.sendSubscriptionCancel();
}
return { return {
account, account,
ping, ping,
email, email,
logout, logout,
setPassword, sendSetPassword,
setEmail, sendSetEmail,
sendConstructSpawn, sendConstructSpawn,
sendSubscriptionCancel,
}; };
}, },
); );
@ -67,42 +72,73 @@ class AccountStatus extends Component {
super(props); super(props);
this.state = { this.state = {
setPassword: { current: '', password: '', confirm: ''}, passwordState: { current: '', password: '', confirm: ''},
email: null, emailState: null,
unsubState: false,
}; };
} }
render(args) { render(args, state) {
const { const {
account, account,
ping, ping,
email, email,
logout, logout,
setPassword, sendSetEmail,
setEmail, sendSetPassword,
sendConstructSpawn, sendConstructSpawn,
sendSubscriptionCancel,
} = args; } = args;
const {
passwordState,
emailState,
unsubState,
} = state;
if (!account) return null; if (!account) return null;
const passwordsEqual = () => const passwordsEqual = () =>
this.state.setPassword.password === this.state.setPassword.confirm; passwordState.password === passwordState.confirm;
const setPasswordDisabled = () => { const setPasswordDisabled = () => {
const { current, password, confirm } = this.state.setPassword; const { current, password, confirm } = passwordState;
return !(passwordsEqual() && password && current && confirm); return !(passwordsEqual() && password && current && confirm);
} }
const unsub = e => {
if (unsubState) {
return sendSubscriptionCancel();
}
e.stopPropagation();
return this.setState({ unsubState: true });
}
const unsubBtn = () => {
if (!account.subscribed) return false;
const classes = `unsub ${unsubState ? 'confirming' : ''}`;
const text = unsubState ? 'Confirm' : 'Unsubscribe'
return <button class={classes} onClick={unsub}>{text}</button>
}
const tlClick = e => {
e.stopPropagation();
return this.setState({ unsubState: false });
}
return ( return (
<section class='account'> <section class='account' onClick={tlClick}>
<div> <div>
<h1>{account.name}</h1> <h1>{account.name}</h1>
<dl> <dl>
<dt>Subscription</dt> <dt>Subscription</dt>
<dd>{account.subscribed ? 'some date' : 'unsubscribed'}</dd> <dd>{account.subscribed ? 'Active' : 'Unsubscribed'}</dd>
</dl> </dl>
<button><a href={`mailto:humans@mnml.gg?subject=Account%20Support:%20${account.name}`}> support</a></button> {unsubBtn()}
<button onClick={() => logout()}>Logout</button> <button onClick={() => logout()}>Logout</button>
<button><a href={`mailto:humans@mnml.gg?subject=Account%20Support:%20${account.name}`}> support</a></button>
</div> </div>
<div> <div>
<label for="email">Email Settings:</label> <label for="email">Email Settings:</label>
@ -116,11 +152,11 @@ class AccountStatus extends Component {
class="login-input" class="login-input"
type="email" type="email"
name="email" name="email"
value={this.state.email} value={emailState}
onInput={linkState(this, 'email')} onInput={linkState(this, 'emailState')}
placeholder="recovery@email.tld" placeholder="recovery@email.tld"
/> />
<button onClick={() => setEmail(this.state.email)}>Update</button> <button onClick={() => sendSetEmail(emailState)}>Update</button>
</div> </div>
<div> <div>
<label for="current">Password:</label> <label for="current">Password:</label>
@ -128,29 +164,29 @@ class AccountStatus extends Component {
class="login-input" class="login-input"
type="password" type="password"
name="current" name="current"
value={this.state.setPassword.current} value={passwordState.current}
onInput={linkState(this, 'setPassword.current')} onInput={linkState(this, 'passwordState.current')}
placeholder="current" placeholder="current"
/> />
<input <input
class="login-input" class="login-input"
type="password" type="password"
name="new" name="new"
value={this.state.setPassword.password} value={passwordState.password}
onInput={linkState(this, 'setPassword.password')} onInput={linkState(this, 'passwordState.password')}
placeholder="new password" placeholder="new password"
/> />
<input <input
class="login-input" class="login-input"
type="password" type="password"
name="confirm" name="confirm"
value={this.state.setPassword.confirm} value={passwordState.confirm}
onInput={linkState(this, 'setPassword.confirm')} onInput={linkState(this, 'passwordState.confirm')}
placeholder="confirm" placeholder="confirm"
/> />
<button <button
disabled={setPasswordDisabled()} disabled={setPasswordDisabled()}
onClick={() => setPassword(this.state.setPassword.current, this.state.setPassword.password)}> onClick={() => sendSetPassword(passwordState.current, passwordState.password)}>
Set Password Set Password
</button> </button>
</div> </div>

View File

@ -8,7 +8,7 @@ function BitsBtn(args) {
} = args; } = args;
function subscribeClick() { function subscribeClick() {
stripe.redirectToCheckout({ stripe.redirectToCheckout({
items: [{ plan: 'plan_FGmRwawcOJJ7Nv', quantity: 1 }], items: [{ plan: 'plan_Fhl9r7UdMadjGi', quantity: 1 }],
successUrl: 'http://localhost', successUrl: 'http://localhost',
cancelUrl: 'http://localhost', cancelUrl: 'http://localhost',
clientReferenceId: account.id, clientReferenceId: account.id,

View File

@ -3,6 +3,7 @@ const eachSeries = require('async/eachSeries');
const actions = require('./actions'); const actions = require('./actions');
const { TIMES } = require('./constants'); const { TIMES } = require('./constants');
const animations = require('./animations.utils'); const animations = require('./animations.utils');
const { infoToast, errorToast } = require('./utils');
function registerEvents(store) { function registerEvents(store) {
function setPing(ping) { function setPing(ping) {
@ -170,6 +171,10 @@ function registerEvents(store) {
return store.dispatch(actions.setItemInfo(v)); return store.dispatch(actions.setItemInfo(v));
} }
function notify(msg) {
return infoToast(msg);
}
// events.on('SET_PLAYER', setInstance); // events.on('SET_PLAYER', setInstance);
// events.on('SEND_SKILL', function skillActive(gameId, constructId, targetConstructId, skill) { // events.on('SEND_SKILL', function skillActive(gameId, constructId, targetConstructId, skill) {
@ -223,6 +228,8 @@ function registerEvents(store) {
setShop, setShop,
setTeam, setTeam,
setWs, setWs,
notify,
}; };
} }

View File

@ -54,6 +54,10 @@ function createSocket(events) {
send(['AccountSetTeam', { ids }]); send(['AccountSetTeam', { ids }]);
} }
function sendSubscriptionCancel() {
send(['SubscriptionCancel', { }]);
}
function sendGameState(id) { function sendGameState(id) {
send(['GameState', { id }]); send(['GameState', { id }]);
} }
@ -166,6 +170,10 @@ function createSocket(events) {
events.setTeam(constructs); events.setTeam(constructs);
} }
function onAccountSubscription() {
events.notify('Your account has been set to cancelled. You will no longer be billed. Thanks for playing.');
}
function onConstructSpawn(construct) { function onConstructSpawn(construct) {
events.setNewConstruct(construct); events.setNewConstruct(construct);
} }
@ -200,6 +208,7 @@ function createSocket(events) {
AccountTeam: onAccountTeam, AccountTeam: onAccountTeam,
AccountInstances: onAccountInstances, AccountInstances: onAccountInstances,
AccountShop: onAccountShop, AccountShop: onAccountShop,
AccountSubscription: onAccountSubscription,
ConstructSpawn: onConstructSpawn, ConstructSpawn: onConstructSpawn,
GameState: onGameState, GameState: onGameState,
EmailState: onEmailState, EmailState: onEmailState,
@ -296,6 +305,8 @@ function createSocket(events) {
sendAccountInstances, sendAccountInstances,
sendAccountSetTeam, sendAccountSetTeam,
sendSubscriptionCancel,
sendGameState, sendGameState,
sendGameReady, sendGameReady,
sendGameSkill, sendGameSkill,

View File

@ -3,3 +3,5 @@ DATABASE_URL=postgres://mnml:password@somewhere/mnml
MAIL_ADDRESS=machines@mnml.gg MAIL_ADDRESS=machines@mnml.gg
MAIL_DOMAIN=vinyl.mnml.gg MAIL_DOMAIN=vinyl.mnml.gg
MAIL_PASSWORD=mmmmmmmmmmmmmmmm MAIL_PASSWORD=mmmmmmmmmmmmmmmm
STRIPE_SECRET=shhhhhhhhhh

View File

@ -38,4 +38,5 @@ ws = "0.8"
lettre = "0.9" lettre = "0.9"
lettre_email = "0.9" lettre_email = "0.9"
stripe-rust = { version = "0.10.4", features = ["webhooks"] } stripe-rust = "0.10"
# stripe-rust = { path = "/home/ntr/code/stripe-rs" }

View File

@ -146,6 +146,8 @@ fn main() {
let pg_pool = pool.clone(); let pg_pool = pool.clone();
let mailer = mail::listen(mail_rx); let mailer = mail::listen(mail_rx);
let stripe = payments::stripe_client();
spawn(move || http::start(http_pool, mailer)); spawn(move || http::start(http_pool, mailer));
spawn(move || warden.listen()); spawn(move || warden.listen());
spawn(move || warden::upkeep_tick(warden_tick_tx)); spawn(move || warden::upkeep_tick(warden_tick_tx));
@ -154,5 +156,5 @@ fn main() {
// the main thread becomes this ws listener // the main thread becomes this ws listener
let rpc_pool = pool.clone(); let rpc_pool = pool.clone();
rpc::start(rpc_pool, rpc_events_tx); rpc::start(rpc_pool, rpc_events_tx, stripe);
} }

View File

@ -1,3 +1,4 @@
use std::env;
use std::io::Read; use std::io::Read;
use http::State; use http::State;
@ -12,15 +13,16 @@ use postgres::transaction::Transaction;
use failure::Error; use failure::Error;
use failure::err_msg; use failure::err_msg;
use stripe::{Event, EventObject, CheckoutSession, SubscriptionStatus}; use stripe::{Client, Event, EventObject, CheckoutSession, SubscriptionStatus};
use http::{MnmlHttpError}; use http::{MnmlHttpError};
use pg::{PgPool}; use pg::{PgPool};
use account; use account;
use account::Account;
pub fn subscription_account(tx: &mut Transaction, sub: String) -> Result<Uuid, Error> { pub fn subscription_account(tx: &mut Transaction, sub: String) -> Result<Uuid, Error> {
let query = " let query = "
SELECT account SELECT account, customer, checkout, subscription
FROM stripe_subscriptions FROM stripe_subscriptions
WHERE subscription = $1; WHERE subscription = $1;
"; ";
@ -34,6 +36,41 @@ pub fn subscription_account(tx: &mut Transaction, sub: String) -> Result<Uuid, E
Ok(row.get(0)) Ok(row.get(0))
} }
pub fn subscription_cancel(tx: &mut Transaction, client: &Client, account: &Account) -> Result<Option<String>, Error> {
let query = "
SELECT account, customer, checkout, subscription
FROM stripe_subscriptions
WHERE account = $1;
";
let result = tx
.query(query, &[&account.id])?;
let row = result.iter().next()
.ok_or(err_msg("user not subscribed"))?;
let _customer: String = row.get(1);
let _checkout: String = row.get(2);
let subscription: String = row.get(3);
let id = subscription.parse()?;
let mut params = stripe::UpdateSubscription::new();
params.cancel_at_period_end = Some(true);
let updated = match stripe::Subscription::update(client, &id, params) {
Ok(s) => s,
Err(e) => {
warn!("{:?}", e);
return Err(err_msg("unable to cancel subscription"));
}
};
info!("subscription cancelled account={:?} subscription={:?}", account, updated);
Ok(Some(updated.status.to_string()))
}
// we use i64 because it is converted to BIGINT for pg // we use i64 because it is converted to BIGINT for pg
// and we can losslessly pull it into u32 which is big // and we can losslessly pull it into u32 which is big
// enough for the ballers // enough for the ballers
@ -45,7 +82,7 @@ const CREDITS_SUB_BONUS: i64 = 40;
// we ensure that we store each object in pg with a link to the object // we ensure that we store each object in pg with a link to the object
// and to the account id in case of refunds // and to the account id in case of refunds
#[derive(Debug,Clone,Serialize,Deserialize)] #[derive(Debug,Clone,Serialize,Deserialize)]
enum StripeData { pub enum StripeData {
Customer { account: Uuid, customer: String, checkout: String }, Customer { account: Uuid, customer: String, checkout: String },
Subscription { account: Uuid, customer: String, checkout: String, subscription: String, }, Subscription { account: Uuid, customer: String, checkout: String, subscription: String, },
@ -211,6 +248,13 @@ pub fn stripe(req: &mut Request) -> IronResult<Response> {
} }
} }
pub fn stripe_client() -> Client {
let secret = env::var("STRIPE_SECRET")
.expect("STRIPE_SECRET must be set");
stripe::Client::new(secret)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -11,6 +11,8 @@ use failure::err_msg;
use serde_cbor::{from_slice, to_vec}; use serde_cbor::{from_slice, to_vec};
use cookie::Cookie; use cookie::Cookie;
use stripe::Client as StripeClient;
use crossbeam_channel::{unbounded, Sender as CbSender}; use crossbeam_channel::{unbounded, Sender as CbSender};
use ws::{listen, CloseCode, Message, Handler, Request, Response}; use ws::{listen, CloseCode, Message, Handler, Request, Response};
@ -23,6 +25,7 @@ use instance::{Instance, instance_state, instance_practice, instance_ready};
use item::{Item, ItemInfoCtr, item_info}; use item::{Item, ItemInfoCtr, item_info};
use mtx; use mtx;
use mail; use mail;
use payments;
use mail::Email; use mail::Email;
use pg::{Db}; use pg::{Db};
use pg::{PgPool}; use pg::{PgPool};
@ -37,6 +40,7 @@ pub enum RpcMessage {
AccountTeam(Vec<Construct>), AccountTeam(Vec<Construct>),
AccountInstances(Vec<Instance>), AccountInstances(Vec<Instance>),
AccountShop(mtx::Shop), AccountShop(mtx::Shop),
AccountSubscription(Option<String>),
ConstructSpawn(Construct), ConstructSpawn(Construct),
EmailState(Email), EmailState(Email),
GameState(Game), GameState(Game),
@ -44,6 +48,7 @@ pub enum RpcMessage {
InstanceState(Instance), InstanceState(Instance),
Pong(()), Pong(()),
DevResolutions(Resolutions), DevResolutions(Resolutions),
@ -75,6 +80,8 @@ enum RpcRequest {
AccountConstructs {}, AccountConstructs {},
AccountSetTeam { ids: Vec<Uuid> }, AccountSetTeam { ids: Vec<Uuid> },
SubscriptionCancel {},
InstanceQueue {}, InstanceQueue {},
InstancePractice {}, InstancePractice {},
InstanceReady { instance_id: Uuid }, InstanceReady { instance_id: Uuid },
@ -92,6 +99,7 @@ struct Connection {
pub id: usize, pub id: usize,
pub ws: CbSender<RpcMessage>, pub ws: CbSender<RpcMessage>,
pool: PgPool, pool: PgPool,
stripe: StripeClient,
account: Option<Account>, account: Option<Account>,
events: CbSender<Event>, events: CbSender<Event>,
} }
@ -192,6 +200,9 @@ impl Connection {
RpcRequest::MtxBuy { mtx } => RpcRequest::MtxBuy { mtx } =>
Ok(RpcMessage::AccountShop(mtx::buy(&mut tx, account, mtx)?)), Ok(RpcMessage::AccountShop(mtx::buy(&mut tx, account, mtx)?)),
RpcRequest::SubscriptionCancel { } =>
Ok(RpcMessage::AccountSubscription(payments::subscription_cancel(&mut tx, &self.stripe, account)?)),
_ => Err(format_err!("unknown request request={:?}", request)), _ => Err(format_err!("unknown request request={:?}", request)),
}; };
@ -270,8 +281,10 @@ impl Handler for Connection {
// if the user queries the state of something // if the user queries the state of something
// we tell events to push updates to them // we tell events to push updates to them
match reply { match reply {
RpcMessage::AccountState(ref v) => RpcMessage::AccountState(ref v) => {
self.events.send(Event::Subscribe(self.id, v.id)).unwrap(), self.account = Some(v.clone());
self.events.send(Event::Subscribe(self.id, v.id)).unwrap()
},
RpcMessage::GameState(ref v) => RpcMessage::GameState(ref v) =>
self.events.send(Event::Subscribe(self.id, v.id)).unwrap(), self.events.send(Event::Subscribe(self.id, v.id)).unwrap(),
RpcMessage::InstanceState(ref v) => RpcMessage::InstanceState(ref v) =>
@ -332,7 +345,7 @@ impl Handler for Connection {
} }
} }
pub fn start(pool: PgPool, events_tx: CbSender<Event>) { pub fn start(pool: PgPool, events_tx: CbSender<Event>, stripe: StripeClient) {
let mut rng = thread_rng(); let mut rng = thread_rng();
listen("127.0.0.1:40055", move |out| { listen("127.0.0.1:40055", move |out| {
@ -364,6 +377,7 @@ pub fn start(pool: PgPool, events_tx: CbSender<Event>) {
account: None, account: None,
ws: tx, ws: tx,
pool: pool.clone(), pool: pool.clone(),
stripe: stripe.clone(),
events: events_tx.clone(), events: events_tx.clone(),
} }
}).unwrap(); }).unwrap();