Require refund address or confirm donation (#354)

* Add API check to disallow unsetting refund address.

* Require either refund address to be set or explicit consent for a donation. Dont have them show up as refundable in admin.

* Show donations on financials page

* Continue after entry

* Consider no_refund when checking for duplicate contribution.

* Fix types.

* Add a filter for all contributions that are considered donations. Update financial query to include donations.

* Elaborate in message.
This commit is contained in:
William O'Beirne 2019-03-13 19:36:24 -04:00 committed by Daniel Ternyak
parent e018f11018
commit 9d0591918e
17 changed files with 325 additions and 32 deletions

View File

@ -137,6 +137,7 @@ class ContributionDetail extends React.Component<Props, State> {
: <em>N/A</em>
)}
{renderDeetItem('staking tx', JSON.stringify(c.staking))}
{renderDeetItem('no refund', JSON.stringify(c.noRefund))}
</Card>
</Col>
</Row>

View File

@ -20,7 +20,7 @@ class Financials extends React.Component {
return (
<div className="Financials">
<Row gutter={16}>
<Col span={12}>
<Col lg={8} md={12} sm={24}>
<Card size="small" title="Contributions">
<Charts.Pie
hasLegend
@ -38,13 +38,16 @@ class Financials extends React.Component {
{ x: 'funding', y: parseFloat(contributions.funding) },
{ x: 'refunding', y: parseFloat(contributions.refunding) },
{ x: 'refunded', y: parseFloat(contributions.refunded) },
{ x: 'donation', y: parseFloat(contributions.donations) },
{ x: 'staking', y: parseFloat(contributions.staking) },
]}
valueFormat={val => <span dangerouslySetInnerHTML={{ __html: val }} />}
height={180}
/>
</Card>
</Col>
<Col lg={8} md={12} sm={24}>
<Card
size="small"
title={
@ -85,7 +88,9 @@ class Financials extends React.Component {
height={180}
/>
</Card>
</Col>
<Col lg={8} md={12} sm={24}>
<Card
size="small"
title={

View File

@ -240,6 +240,7 @@ const app = store({
funded: '0',
refunding: '0',
refunded: '0',
donations: '0',
},
payouts: {
total: '0',

View File

@ -151,6 +151,7 @@ export interface Contribution {
memo: string;
};
staking: boolean;
noRefund: boolean;
refundAddress?: string;
refundTxId?: string;
}

View File

@ -92,6 +92,11 @@ const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
display: 'Refundable',
color: '#afd500',
group: 'Refundable',
}, {
id: 'DONATION',
display: 'Donations',
color: '#afd500',
group: 'Donations',
}]);
export const contributionFilters: Filters = {

View File

@ -155,6 +155,7 @@ def stats():
contribution_refundable_count = db.session.query(func.count(ProposalContribution.id)) \
.filter(ProposalContribution.refund_tx_id == None) \
.filter(ProposalContribution.staking == False) \
.filter(ProposalContribution.no_refund == False) \
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.join(Proposal) \
.filter(or_(
@ -751,10 +752,13 @@ def financials():
'funding': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage = 'FUNDING_REQUIRED'"))),
'funded': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))),
'refunding': str(ex(sql_pc_p(
"pc.status = 'CONFIRMED' AND pc.staking = FALSE AND pc.refund_tx_id IS NULL AND p.stage IN ('CANCELED', 'FAILED')"
"pc.status = 'CONFIRMED' AND pc.staking = FALSE AND pc.no_refund = FALSE AND pc.refund_tx_id IS NULL AND p.stage IN ('CANCELED', 'FAILED')"
))),
'refunded': str(ex(sql_pc_p(
"pc.status = 'CONFIRMED' AND pc.staking = FALSE AND pc.refund_tx_id IS NOT NULL AND p.stage IN ('CANCELED', 'FAILED')"
"pc.status = 'CONFIRMED' AND pc.staking = FALSE AND pc.no_refund = FALSE AND pc.refund_tx_id IS NOT NULL AND p.stage IN ('CANCELED', 'FAILED')"
))),
'donations': str(ex(sql_pc_p(
"(pc.status = 'CONFIRMED' AND pc.staking = FALSE AND pc.refund_tx_id IS NULL) AND (pc.no_refund = TRUE OR pc.user_id IS NULL) AND p.stage IN ('CANCELED', 'FAILED')"
))),
'gross': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.refund_tx_id IS NULL"))),
}

View File

@ -85,6 +85,7 @@ class ProposalContribution(db.Model):
tx_id = db.Column(db.String(255), nullable=True)
refund_tx_id = db.Column(db.String(255), nullable=True)
staking = db.Column(db.Boolean, nullable=False)
no_refund = db.Column(db.Boolean, nullable=False)
user = db.relationship("User")
@ -94,20 +95,23 @@ class ProposalContribution(db.Model):
amount: str,
user_id: int = None,
staking: bool = False,
no_refund: bool = False,
):
self.proposal_id = proposal_id
self.amount = amount
self.user_id = user_id
self.staking = staking
self.no_refund = no_refund
self.date_created = datetime.datetime.now()
self.status = ContributionStatus.PENDING
@staticmethod
def get_existing_contribution(user_id: int, proposal_id: int, amount: str):
def get_existing_contribution(user_id: int, proposal_id: int, amount: str, no_refund: bool = False):
return ProposalContribution.query.filter_by(
user_id=user_id,
proposal_id=proposal_id,
amount=amount,
no_refund=no_refund,
status=ContributionStatus.PENDING,
).first()
@ -359,12 +363,19 @@ class Proposal(db.Model):
self.set_contribution_matching(0)
self.set_contribution_bounty('0')
def create_contribution(self, amount, user_id: int = None, staking: bool = False):
def create_contribution(
self,
amount,
user_id: int = None,
staking: bool = False,
no_refund: bool = False,
):
contribution = ProposalContribution(
proposal_id=self.id,
amount=amount,
user_id=user_id,
staking=staking,
no_refund=no_refund,
)
db.session.add(contribution)
db.session.flush()
@ -812,7 +823,8 @@ class AdminProposalContributionSchema(ma.Schema):
"addresses",
"refund_address",
"refund_tx_id",
"staking"
"staking",
"no_refund",
)
proposal = ma.Nested("ProposalSchema")

View File

@ -490,9 +490,10 @@ def get_proposal_contribution(proposal_id, contribution_id):
# TODO add gaurd (minimum, maximum)
@body({
"amount": fields.Str(required=True),
"anonymous": fields.Bool(required=False, missing=None)
"anonymous": fields.Bool(required=False, missing=None),
"noRefund": fields.Bool(required=False, missing=False),
})
def post_proposal_contribution(proposal_id, amount, anonymous):
def post_proposal_contribution(proposal_id, amount, anonymous, no_refund):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
@ -505,12 +506,14 @@ def post_proposal_contribution(proposal_id, amount, anonymous):
user = get_authed_user()
if user:
contribution = ProposalContribution.get_existing_contribution(user.id, proposal_id, amount)
contribution = ProposalContribution \
.get_existing_contribution(user.id, proposal_id, amount, no_refund)
if not contribution:
code = 201
contribution = proposal.create_contribution(
amount=amount,
no_refund=no_refund,
user_id=user.id if user else None,
)

View File

@ -357,6 +357,8 @@ def set_user_settings(user_id, email_subscriptions, refund_address):
except ValidationException as e:
return {"message": str(e)}, 400
if refund_address == '' and g.current_user.settings.refund_address:
return {"message": "Refund address cannot be unset, only changed"}, 400
if refund_address:
g.current_user.settings.refund_address = refund_address

View File

@ -121,7 +121,7 @@ class ProposalPagination(Pagination):
class ContributionPagination(Pagination):
def __init__(self):
self.FILTERS = [f'STATUS_{s}' for s in ContributionStatus.list()]
self.FILTERS.extend(['REFUNDABLE'])
self.FILTERS.extend(['REFUNDABLE', 'DONATION'])
self.PAGE_SIZE = 9
self.SORT_MAP = {
'CREATED:DESC': ProposalContribution.date_created.desc(),
@ -153,6 +153,7 @@ class ContributionPagination(Pagination):
if 'REFUNDABLE' in filters:
query = query.filter(ProposalContribution.refund_tx_id == None) \
.filter(ProposalContribution.staking == False) \
.filter(ProposalContribution.no_refund == False) \
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.join(Proposal) \
.filter(or_(
@ -161,7 +162,20 @@ class ContributionPagination(Pagination):
)) \
.join(ProposalContribution.user) \
.join(UserSettings) \
.filter(UserSettings.refund_address != None) \
.filter(UserSettings.refund_address != None)
if 'DONATION' in filters:
query = query.filter(ProposalContribution.refund_tx_id == None) \
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.filter(or_(
ProposalContribution.no_refund == True,
ProposalContribution.user_id == None,
)) \
.join(Proposal) \
.filter(or_(
Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED,
))
# SORT (see self.SORT_MAP)

View File

@ -0,0 +1,29 @@
"""Add no_refund column to contributions, default to false
Revision ID: 484abe10e157
Revises: 13365ffe910e
Create Date: 2019-03-13 16:35:01.520404
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import expression
# revision identifiers, used by Alembic.
revision = '484abe10e157'
down_revision = '13365ffe910e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('proposal_contribution', sa.Column('no_refund', sa.Boolean(), nullable=False, server_default=expression.false()))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('proposal_contribution', 'no_refund')
# ### end Alembic commands ###

View File

@ -13,6 +13,7 @@ import {
RFP,
ProposalPageParams,
PageParams,
UserSettings,
} from 'types';
import {
formatUserForPost,
@ -127,7 +128,9 @@ export function updateUser(user: User): Promise<{ data: User }> {
return axios.put(`/api/v1/users/${user.userid}`, formatUserForPost(user));
}
export function getUserSettings(userId: string | number): Promise<any> {
export function getUserSettings(
userId: string | number,
): Promise<{ data: UserSettings }> {
return axios.get(`/api/v1/users/${userId}/settings`);
}
@ -138,7 +141,7 @@ interface SettingsArgs {
export function updateUserSettings(
userId: string | number,
args?: SettingsArgs,
): Promise<any> {
): Promise<{ data: UserSettings }> {
return axios.put(`/api/v1/users/${userId}/settings`, args);
}
@ -313,11 +316,13 @@ export function putInviteResponse(
export function postProposalContribution(
proposalId: number,
amount: string,
anonymous?: boolean,
anonymous: boolean = false,
noRefund: boolean = false,
): Promise<{ data: ContributionWithAddressesAndUser }> {
return axios.post(`/api/v1/proposals/${proposalId}/contributions`, {
amount,
anonymous,
noRefund,
});
}

View File

@ -0,0 +1,13 @@
.SetRefundAddress {
.ant-alert {
margin-bottom: 1rem;
}
.ant-form-item {
margin-bottom: 0.25rem;
&.ant-form-item-with-help {
margin-bottom: 1rem;
}
}
}

View File

@ -0,0 +1,101 @@
import React from 'react';
import { Form, Input, Button, message, Alert, Divider } from 'antd';
import { updateUserSettings } from 'api/api';
import { isValidAddress } from 'utils/validators';
import './SetRefundAddress.less';
interface OwnProps {
userid: number;
onSetRefundAddress(): void;
onSetNoRefund(): void;
}
type Props = OwnProps;
const STATE = {
refundAddress: '',
isSaving: false,
};
type State = typeof STATE;
export default class SetRefundAddress extends React.Component<Props, State> {
state: State = { ...STATE };
render() {
const { refundAddress, isSaving } = this.state;
let status: 'validating' | 'error' | undefined;
let help;
if (refundAddress && !isValidAddress(refundAddress)) {
status = 'error';
help = 'That doesnt look like a valid address';
}
return (
<Form className="SetRefundAddress" layout="vertical" onSubmit={this.handleSubmit}>
<Alert
type="info"
message="Please set a refund address"
description={`
If the proposal fails to reach its funding goal or gets
canceled, we need an address to refund your contribution to.
Youll only need to set this once, and can change it later from
the Settings page.
`}
/>
<Form.Item label="Refund address" validateStatus={status} help={help}>
<Input
value={refundAddress}
placeholder="Z or T address"
size="large"
onChange={this.handleChange}
disabled={isSaving}
autoFocus
/>
</Form.Item>
<Button
type="primary"
htmlType="submit"
size="large"
disabled={!refundAddress || isSaving || !!status}
loading={isSaving}
block
>
Set refund address & continue
</Button>
<Divider>or</Divider>
<Button type="danger" block onClick={this.props.onSetNoRefund}>
I don't want a refund, consider it a donation instead
</Button>
</Form>
);
}
private handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ refundAddress: ev.currentTarget.value });
};
private handleSubmit = async (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
const { userid } = this.props;
const { refundAddress } = this.state;
if (!refundAddress) {
return;
}
this.setState({ isSaving: true });
try {
await updateUserSettings(userid, { refundAddress });
this.props.onSetRefundAddress();
} catch (err) {
console.error(err);
message.error(err.message || err.toString(), 5);
this.setState({ isSaving: false });
}
};
}

View File

@ -1,10 +1,17 @@
import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { Modal, Alert } from 'antd';
import Result from 'ant-design-pro/lib/Result';
import { postProposalContribution, getProposalContribution } from 'api/api';
import {
postProposalContribution,
getProposalContribution,
getUserSettings,
} from 'api/api';
import { ContributionWithAddressesAndUser } from 'types';
import PaymentInfo from './PaymentInfo';
import SetRefundAddress from './SetRefundAddress';
import { AppState } from 'store/reducers';
interface OwnProps {
isVisible: boolean;
@ -18,21 +25,29 @@ interface OwnProps {
handleClose(): void;
}
type Props = OwnProps;
interface StateProps {
authUser: AppState['auth']['user'];
}
type Props = StateProps & OwnProps;
interface State {
hasConfirmedAnonymous: boolean;
hasSent: boolean;
contribution: ContributionWithAddressesAndUser | null;
needsRefundAddress: boolean;
noRefund: boolean;
isFetchingContribution: boolean;
error: string | null;
}
export default class ContributionModal extends React.Component<Props, State> {
class ContributionModal extends React.Component<Props, State> {
state: State = {
hasConfirmedAnonymous: false,
hasSent: false,
contribution: null,
needsRefundAddress: false,
noRefund: false,
isFetchingContribution: false,
error: null,
};
@ -47,7 +62,7 @@ export default class ContributionModal extends React.Component<Props, State> {
}
}
componentWillUpdate(nextProps: Props) {
componentWillUpdate(nextProps: Props, nextState: State) {
const {
isVisible,
isAnonymous,
@ -59,7 +74,7 @@ export default class ContributionModal extends React.Component<Props, State> {
// But not if we're anonymous, that will happen in confirmAnonymous
if (isVisible && proposalId && !isAnonymous) {
if (this.props.isVisible !== isVisible || proposalId !== this.props.proposalId) {
this.fetchAddresses(proposalId, contributionId);
this.fetchAddresses(proposalId, contributionId, nextState.noRefund);
}
}
// If contribution is provided, update it
@ -72,19 +87,37 @@ export default class ContributionModal extends React.Component<Props, State> {
contribution: null,
hasConfirmedAnonymous: false,
hasSent: false,
needsRefundAddress: false,
noRefund: false,
error: null,
});
}
}
render() {
const { isVisible, isAnonymous, handleClose, hasNoButtons, text } = this.props;
const { hasSent, hasConfirmedAnonymous, contribution, error } = this.state;
const { isVisible, isAnonymous, handleClose, text, authUser } = this.props;
const {
hasSent,
hasConfirmedAnonymous,
needsRefundAddress,
contribution,
error,
} = this.state;
let { hasNoButtons } = this.props;
let okText;
let onOk;
let content;
if (isAnonymous && !hasConfirmedAnonymous) {
if (needsRefundAddress && authUser) {
hasNoButtons = true;
content = (
<SetRefundAddress
userid={authUser.userid}
onSetRefundAddress={this.confirmRefundAddressSet}
onSetNoRefund={this.confirmNoRefund}
/>
);
} else if (isAnonymous && !hasConfirmedAnonymous) {
okText = 'I accept';
onOk = this.confirmAnonymous;
content = (
@ -181,15 +214,38 @@ export default class ContributionModal extends React.Component<Props, State> {
);
}
private async fetchAddresses(proposalId: number, contributionId?: number) {
private async fetchAddresses(
proposalId: number,
contributionId?: number,
noRefund?: boolean,
) {
this.setState({ isFetchingContribution: true });
try {
const { amount, isAnonymous } = this.props;
const { amount, isAnonymous, authUser } = this.props;
// Ensure auth'd users have a refund address unless they've confirmed
if (!isAnonymous && !noRefund) {
// This should never happen, but make Typescript happy
if (!authUser) {
throw new Error('You must be logged in to contribute non-anonymously');
}
const { data: settings } = await getUserSettings(authUser.userid);
if (!settings.refundAddress) {
this.setState({ needsRefundAddress: true });
return;
}
}
let res;
if (contributionId) {
res = await getProposalContribution(proposalId, contributionId);
} else {
res = await postProposalContribution(proposalId, amount || '0', isAnonymous);
res = await postProposalContribution(
proposalId,
amount || '0',
isAnonymous,
noRefund,
);
}
this.setState({ contribution: res.data });
} catch (err) {
@ -199,14 +255,49 @@ export default class ContributionModal extends React.Component<Props, State> {
}
private confirmAnonymous = () => {
const { state, props } = this;
this.setState({ hasConfirmedAnonymous: true });
if (!state.contribution && !props.contribution && props.proposalId) {
this.fetchAddresses(props.proposalId, props.contributionId);
}
this.setState({ hasConfirmedAnonymous: true }, () => {
const { state, props } = this;
if (!state.contribution && !props.contribution && props.proposalId) {
this.fetchAddresses(props.proposalId, props.contributionId, state.noRefund);
}
});
};
private confirmSend = () => {
this.setState({ hasSent: true });
};
private confirmRefundAddressSet = () => {
this.setState(
{
needsRefundAddress: false,
noRefund: false,
},
() => {
const { state, props } = this;
if (!state.contribution && !props.contribution && props.proposalId) {
this.fetchAddresses(props.proposalId, props.contributionId, state.noRefund);
}
},
);
};
private confirmNoRefund = () => {
this.setState(
{
needsRefundAddress: false,
noRefund: true,
},
() => {
const { state, props } = this;
if (!state.contribution && !props.contribution && props.proposalId) {
this.fetchAddresses(props.proposalId, props.contributionId, state.noRefund);
}
},
);
};
}
export default connect<StateProps, {}, OwnProps, AppState>(state => ({
authUser: state.auth.user,
}))(ContributionModal);

View File

@ -92,7 +92,7 @@ class RefundAddress extends React.Component<Props, State> {
try {
const res = await updateUserSettings(userid, { refundAddress });
message.success('Settings saved');
this.setState({ refundAddress: res.data.refundAddress });
this.setState({ refundAddress: res.data.refundAddress || '' });
} catch (err) {
console.error(err);
message.error(err.message || err.toString(), 5);

View File

@ -1,4 +1,5 @@
import { SocialMedia } from 'types';
import { SocialMedia } from './social';
import { EmailSubscriptions } from './email';
export interface User {
userid: number;
@ -9,3 +10,8 @@ export interface User {
socialMedias: SocialMedia[];
avatar: { imageUrl: string } | null;
}
export interface UserSettings {
emailSubscriptions: EmailSubscriptions;
refundAddress?: string | null;
}