Cancel / Refund proposal (#100)

* Cancel / refund modal for proposals. Fix some states where frozen contract still allowed interaction.

* Add more refund states. Move styles to less.

* Fix tsc, simplify logic
This commit is contained in:
William O'Beirne 2018-09-25 15:41:47 -04:00 committed by Daniel Ternyak
parent 8be518fff7
commit 75f0b72022
14 changed files with 334 additions and 84 deletions

View File

@ -25,6 +25,7 @@ class CreateFlowPreview extends React.Component<Props> {
banner banner
/> />
<ProposalDetail <ProposalDetail
account="0x0"
proposalId="preview" proposalId="preview"
fetchProposal={() => null} fetchProposal={() => null}
proposal={proposal} proposal={proposal}

View File

@ -3,6 +3,7 @@
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
text-align: center;
border: 2px dashed #d9d9d9; border: 2px dashed #d9d9d9;
padding: 3rem; padding: 3rem;
border-radius: 8px; border-radius: 8px;

View File

@ -89,7 +89,9 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
if (proposal) { if (proposal) {
const { crowdFund } = proposal; const { crowdFund } = proposal;
const isFundingOver = const isFundingOver =
crowdFund.isRaiseGoalReached || crowdFund.deadline < Date.now(); crowdFund.isRaiseGoalReached ||
crowdFund.deadline < Date.now() ||
crowdFund.isFrozen;
const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview; const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview;
const remainingEthNum = parseFloat( const remainingEthNum = parseFloat(
web3.utils.fromWei(crowdFund.target.sub(crowdFund.funded), 'ether'), web3.utils.fromWei(crowdFund.target.sub(crowdFund.funded), 'ether'),
@ -150,7 +152,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
) : ( ) : (
<> <>
<Icon type="close-circle-o" /> <Icon type="close-circle-o" />
<span>Proposal didnt reach target</span> <span>Proposal didnt get funded</span>
</> </>
)} )}
</div> </div>

View File

@ -0,0 +1,105 @@
import React from 'react';
import { connect } from 'react-redux';
import { Modal, Alert } from 'antd';
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
import { web3Actions } from 'modules/web3';
import { AppState } from 'store/reducers';
interface OwnProps {
proposal: ProposalWithCrowdFund;
isVisible: boolean;
handleClose(): void;
}
interface StateProps {
isRefundActionPending: AppState['web3']['isRefundActionPending'];
refundActionError: AppState['web3']['refundActionError'];
}
interface DispatchProps {
triggerRefund: typeof web3Actions['triggerRefund'];
}
type Props = StateProps & DispatchProps & OwnProps;
class CancelModal extends React.Component<Props> {
componentDidUpdate() {
if (this.props.proposal.crowdFund.isFrozen) {
this.props.handleClose();
}
}
render() {
const { proposal, isVisible, isRefundActionPending, refundActionError } = this.props;
const hasBeenFunded = proposal.crowdFund.isRaiseGoalReached;
const hasContributors = !!proposal.crowdFund.contributors.length;
const disabled = isRefundActionPending;
return (
<Modal
title={<>Cancel proposal</>}
visible={isVisible}
okText="Confirm"
cancelText="Never mind"
onOk={this.cancelProposal}
onCancel={this.closeModal}
okButtonProps={{ type: 'danger', loading: disabled }}
cancelButtonProps={{ disabled }}
>
{hasBeenFunded ? (
<p>
Are you sure you would like to issue a refund?{' '}
<strong>This cannot be undone</strong>. Once you issue a refund, all
contributors will be able to receive a refund of the remaining proposal
balance.
</p>
) : (
<p>
Are you sure you would like to cancel this proposal?{' '}
<strong>This cannot be undone</strong>. Once you cancel it, all contributors
will be able to receive refunds.
</p>
)}
<p>
Canceled proposals cannot be deleted and will still be viewable by contributors
or anyone with a direct link. However, they will be de-listed everywhere else on
Grant.io.
</p>
{hasContributors && (
<p>
Should you choose to cancel, we highly recommend posting an update to let your
contributors know why youve decided to do so.
</p>
)}
{refundActionError && (
<Alert
type="error"
message={`Failed to ${hasBeenFunded ? 'refund' : 'cancel'} proposal`}
description={refundActionError}
showIcon
/>
)}
</Modal>
);
}
private closeModal = () => {
if (!this.props.isRefundActionPending) {
this.props.handleClose();
}
};
private cancelProposal = () => {
this.props.triggerRefund(this.props.proposal.crowdFundContract);
};
}
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
state => ({
isRefundActionPending: state.web3.isRefundActionPending,
refundActionError: state.web3.refundActionError,
}),
{
triggerRefund: web3Actions.triggerRefund,
},
)(CancelModal);

View File

@ -7,6 +7,7 @@ import { web3Actions } from 'modules/web3';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import Web3Container, { Web3RenderProps } from 'lib/Web3Container'; import Web3Container, { Web3RenderProps } from 'lib/Web3Container';
import UnitDisplay from 'components/UnitDisplay'; import UnitDisplay from 'components/UnitDisplay';
import Placeholder from 'components/Placeholder';
interface OwnProps { interface OwnProps {
proposal: ProposalWithCrowdFund; proposal: ProposalWithCrowdFund;
@ -38,6 +39,19 @@ export class Milestones extends React.Component<Props> {
milestoneActionError, milestoneActionError,
} = this.props; } = this.props;
const { crowdFund } = proposal; const { crowdFund } = proposal;
if (!crowdFund.isRaiseGoalReached) {
return (
<Placeholder
title="Milestone governance isnt available yet"
subtitle={`
Milestone history and voting status will be displayed here
once the project has been funded
`}
/>
);
}
const contributor = crowdFund.contributors.find(c => c.address === accounts[0]); const contributor = crowdFund.contributors.find(c => c.address === accounts[0]);
const isTrustee = crowdFund.trustees.includes(accounts[0]); const isTrustee = crowdFund.trustees.includes(accounts[0]);
const firstMilestone = crowdFund.milestones[0]; const firstMilestone = crowdFund.milestones[0];

View File

@ -6,6 +6,7 @@ import Web3Container, { Web3RenderProps } from 'lib/Web3Container';
import { web3Actions } from 'modules/web3'; import { web3Actions } from 'modules/web3';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import classnames from 'classnames'; import classnames from 'classnames';
import Placeholder from 'components/Placeholder';
interface OwnProps { interface OwnProps {
proposal: ProposalWithCrowdFund; proposal: ProposalWithCrowdFund;
@ -32,16 +33,63 @@ class GovernanceRefunds extends React.Component<Props> {
render() { render() {
const { proposal, account, isRefundActionPending, refundActionError } = this.props; const { proposal, account, isRefundActionPending, refundActionError } = this.props;
const { crowdFund } = proposal; const { crowdFund } = proposal;
const isStillFunding =
!crowdFund.isRaiseGoalReached && crowdFund.deadline > Date.now();
if (isStillFunding && !crowdFund.isFrozen) {
return (
<Placeholder
title="Refund governance isnt available yet"
subtitle={`
Refund voting and status will be displayed here once the
project has been funded, or if it the project is canceled
or fails to receive funding
`}
/>
);
}
// TODO: Cyclomatic complexity is too damn high here. This state should be
// figured out on the backend and enumerated, not calculated in this component.
const contributor = crowdFund.contributors.find(c => c.address === account); const contributor = crowdFund.contributors.find(c => c.address === account);
const isTrustee = crowdFund.trustees.includes(account); const isTrustee = crowdFund.trustees.includes(account);
const hasVotedForRefund = contributor && contributor.refundVote; const hasVotedForRefund = contributor && contributor.refundVote;
const hasRefunded = contributor && contributor.refunded; const hasRefunded = contributor && contributor.refunded;
const refundPct = crowdFund.percentVotingForRefund; const refundPct = crowdFund.percentVotingForRefund;
const didFundingFail =
!crowdFund.isRaiseGoalReached && crowdFund.deadline < Date.now();
let text; let text;
let button; let button;
if (!isTrustee && contributor) { if (!isTrustee && contributor) {
if (refundPct < 50) { let canRefund = false;
if (hasRefunded) {
return (
<Alert
type="success"
message="Your refund has been processed"
description={`
We apologize for any inconvenience this propsal has caused you. Please
let us know if there's anything we could have done to improve your
experience.
`}
showIcon
/>
);
} else if (refundPct > 50) {
text = `
The majority of funders have voted for a refund. Click below
to receive your refund.
`;
canRefund = true;
} else if (didFundingFail || crowdFund.isFrozen) {
text = `
The project was either canceled, or failed to reach its funding
target before the deadline. Click below to receive a refund of
your contribution.
`;
canRefund = true;
} else {
text = ` text = `
As a funder of this project, you have the right to vote for a refund. If the As a funder of this project, you have the right to vote for a refund. If the
amount of funds contributed by refund voters exceeds half of the project's amount of funds contributed by refund voters exceeds half of the project's
@ -60,47 +108,38 @@ class GovernanceRefunds extends React.Component<Props> {
onClick: () => this.voteRefund(true), onClick: () => this.voteRefund(true),
}; };
} }
} else { }
if (hasRefunded) {
return ( if (canRefund) {
<Alert text = (
type="success" <>
message="Your refund has been processed" {text}
description={` {!crowdFund.isFrozen && (
We apologize for any inconvenience this propsal has caused you. Please <Alert
let us know if there's anything we could have done to improve your style={{ marginTop: '1rem' }}
experience. type="info"
`} showIcon
showIcon message={`
/>
);
} else {
text = (
<>
The majority of funders have voted for a refund. Click below to receive your
refund.
{!crowdFund.isFrozen && (
<Alert
style={{ marginTop: '1rem' }}
type="info"
message={`
This will require multiple transactions to process, sorry This will require multiple transactions to process, sorry
for the inconvenience for the inconvenience
`} `}
showIcon />
/> )}
)} </>
</> );
); button = {
button = { text: 'Get your refund',
text: 'Get your refund', type: 'primary',
type: 'primary', onClick: () => this.withdrawRefund(),
onClick: () => this.withdrawRefund(), };
};
}
} }
} else { } else {
if (refundPct < 50) { if (crowdFund.isFrozen || didFundingFail) {
text = `
The project failed to receive funding or was canceled. Contributors are
open to refund their contributions.
`;
} else if (refundPct < 50) {
text = ` text = `
Funders can vote to request refunds. If the amount of funds contributed by Funders can vote to request refunds. If the amount of funds contributed by
refund voters exceeds half of the funds contributed, all funders will be able refund voters exceeds half of the funds contributed, all funders will be able

View File

@ -2,7 +2,6 @@ import React from 'react';
import GovernanceMilestones from './Milestones'; import GovernanceMilestones from './Milestones';
import GovernanceRefunds from './Refunds'; import GovernanceRefunds from './Refunds';
import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
import Placeholder from 'components/Placeholder';
import './style.less'; import './style.less';
interface Props { interface Props {
@ -12,29 +11,15 @@ interface Props {
export default class ProposalGovernance extends React.Component<Props> { export default class ProposalGovernance extends React.Component<Props> {
render() { render() {
const { proposal } = this.props; const { proposal } = this.props;
if (!proposal.crowdFund.isRaiseGoalReached) {
return (
<Placeholder
style={{ minHeight: '220px' }}
title="Governance isnt available yet"
subtitle={`
Milestone history and voting will be displayed here once the
project has been funded
`}
/>
);
}
return ( return (
<div className="ProposalGovernance"> <div className="ProposalGovernance">
<div style={{ flex: 1 }}> <div className="ProposalGovernance-section">
<h2 style={{ marginBottom: '1rem' }}>Milestone Voting</h2> <h2 className="ProposalGovernance-section-title">Milestone Voting</h2>
<GovernanceMilestones proposal={proposal} /> <GovernanceMilestones proposal={proposal} />
</div> </div>
<div className="ProposalGovernance-divider" /> <div className="ProposalGovernance-divider" />
<div style={{ flex: 1 }}> <div className="ProposalGovernance-section">
<h2 style={{ marginBottom: '1rem' }}>Refunds</h2> <h2 className="ProposalGovernance-section-title">Refunds</h2>
<GovernanceRefunds proposal={proposal} /> <GovernanceRefunds proposal={proposal} />
</div> </div>
</div> </div>

View File

@ -8,6 +8,15 @@
flex-direction: column; flex-direction: column;
} }
&-section {
flex: 1;
&-title {
font-weight: bold;
margin-bottom: 1rem;
}
}
&-divider { &-divider {
width: 1px; width: 1px;
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);

View File

@ -7,16 +7,16 @@ import { bindActionCreators, Dispatch } from 'redux';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { ProposalWithCrowdFund } from 'modules/proposals/reducers'; import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
import { getProposal } from 'modules/proposals/selectors'; import { getProposal } from 'modules/proposals/selectors';
import { Spin, Tabs, Icon } from 'antd'; import { Spin, Tabs, Icon, Dropdown, Menu, Button } from 'antd';
import CampaignBlock from './CampaignBlock'; import CampaignBlock from './CampaignBlock';
import TeamBlock from './TeamBlock'; import TeamBlock from './TeamBlock';
import Milestones from './Milestones'; import Milestones from './Milestones';
import CommentsTab from './Comments'; import CommentsTab from './Comments';
import UpdatesTab from './Updates'; import UpdatesTab from './Updates';
import GovernanceTab from './Governance'; import GovernanceTab from './Governance';
import ContributorsTab from './Contributors'; import ContributorsTab from './Contributors';
// import CommunityTab from './Community'; // import CommunityTab from './Community';
import CancelModal from './CancelModal';
import './style.less'; import './style.less';
import classnames from 'classnames'; import classnames from 'classnames';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
@ -36,11 +36,16 @@ interface DispatchProps {
fetchProposal: proposalActions.TFetchProposal; fetchProposal: proposalActions.TFetchProposal;
} }
type Props = StateProps & DispatchProps & OwnProps; interface Web3Props {
account: string;
}
type Props = StateProps & DispatchProps & Web3Props & OwnProps;
interface State { interface State {
isBodyExpanded: boolean; isBodyExpanded: boolean;
isBodyOverflowing: boolean; isBodyOverflowing: boolean;
isCancelOpen: boolean;
bodyId: string; bodyId: string;
} }
@ -48,6 +53,7 @@ export class ProposalDetail extends React.Component<Props, State> {
state: State = { state: State = {
isBodyExpanded: false, isBodyExpanded: false,
isBodyOverflowing: false, isBodyOverflowing: false,
isCancelOpen: false,
bodyId: `body-${Math.floor(Math.random() * 1000000)}`, bodyId: `body-${Math.floor(Math.random() * 1000000)}`,
}; };
@ -71,14 +77,37 @@ export class ProposalDetail extends React.Component<Props, State> {
} }
render() { render() {
const { proposal, isPreview } = this.props; const { proposal, isPreview, account } = this.props;
const { isBodyExpanded, isBodyOverflowing, bodyId } = this.state; const { isBodyExpanded, isBodyOverflowing, isCancelOpen, bodyId } = this.state;
const showExpand = !isBodyExpanded && isBodyOverflowing; const showExpand = !isBodyExpanded && isBodyOverflowing;
if (!proposal) { if (!proposal) {
return <Spin />; return <Spin />;
} else { } else {
const { crowdFund } = proposal; const { crowdFund } = proposal;
const isTrustee = crowdFund.trustees.includes(account);
const hasBeenFunded = crowdFund.isRaiseGoalReached;
const isProposalActive = !hasBeenFunded && crowdFund.deadline > Date.now();
const canRefund = (hasBeenFunded || isProposalActive) && !crowdFund.isFrozen;
const adminMenu = isTrustee && (
<Menu>
<Menu.Item
onClick={() => alert('Sorry, not yet implemented!')}
disabled={!isProposalActive}
>
Edit proposal
</Menu.Item>
<Menu.Item
style={{ color: canRefund ? '#e74c3c' : undefined }}
onClick={this.openCancelModal}
disabled={!canRefund}
>
{hasBeenFunded ? 'Refund contributors' : 'Cancel proposal'}
</Menu.Item>
</Menu>
);
return ( return (
<div className="Proposal"> <div className="Proposal">
<div className="Proposal-top"> <div className="Proposal-top">
@ -105,6 +134,20 @@ export class ProposalDetail extends React.Component<Props, State> {
</button> </button>
)} )}
</div> </div>
{isTrustee && (
<div className="Proposal-top-main-menu">
<Dropdown
overlay={adminMenu}
trigger={['click']}
placement="bottomRight"
>
<Button>
<span>Actions</span>
<Icon type="down" style={{ marginRight: '-0.25rem' }} />
</Button>
</Dropdown>
</div>
)}
</div> </div>
<div className="Proposal-top-side"> <div className="Proposal-top-side">
<CampaignBlock proposal={proposal} isPreview={isPreview} /> <CampaignBlock proposal={proposal} isPreview={isPreview} />
@ -134,6 +177,13 @@ export class ProposalDetail extends React.Component<Props, State> {
</Tabs.TabPane> </Tabs.TabPane>
</Tabs> </Tabs>
)} )}
{isTrustee && (
<CancelModal
proposal={proposal}
isVisible={isCancelOpen}
handleClose={this.closeCancelModal}
/>
)}
</div> </div>
); );
} }
@ -161,6 +211,9 @@ export class ProposalDetail extends React.Component<Props, State> {
this.setState({ isBodyOverflowing: true }); this.setState({ isBodyOverflowing: true });
} }
}; };
private openCancelModal = () => this.setState({ isCancelOpen: true });
private closeCancelModal = () => this.setState({ isCancelOpen: false });
} }
function mapStateToProps(state: AppState, ownProps: OwnProps) { function mapStateToProps(state: AppState, ownProps: OwnProps) {
@ -178,7 +231,7 @@ const withConnect = connect<StateProps, DispatchProps, OwnProps, AppState>(
mapDispatchToProps, mapDispatchToProps,
); );
const ConnectedProposal = compose<Props, OwnProps>( const ConnectedProposal = compose<Props, OwnProps & Web3Props>(
withRouter, withRouter,
withConnect, withConnect,
)(ProposalDetail); )(ProposalDetail);
@ -194,6 +247,6 @@ export default (props: OwnProps) => (
</div> </div>
</div> </div>
)} )}
render={() => <ConnectedProposal {...props} />} render={({ accounts }) => <ConnectedProposal account={accounts[0]} {...props} />}
/> />
); );

View File

@ -15,6 +15,7 @@
} }
&-main { &-main {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: calc(100% - 19rem); width: calc(100% - 19rem);
@ -104,6 +105,12 @@
} }
} }
} }
&-menu {
position: absolute;
top: 1rem;
right: 0;
}
} }
&-side { &-side {

View File

@ -14,7 +14,7 @@ export interface Web3RenderProps {
} }
interface OwnProps { interface OwnProps {
render(props: Web3RenderProps & any): React.ReactNode; render(props: Web3RenderProps & { props: any }): React.ReactNode;
renderLoading(): React.ReactNode; renderLoading(): React.ReactNode;
} }

View File

@ -332,6 +332,49 @@ export function voteRefund(crowdFundContract: any, vote: boolean) {
}; };
} }
async function freezeContract(crowdFundContract: any, account: string) {
let isFrozen = await crowdFundContract.methods.frozen().call({ from: account });
// Already frozen, all good here
if (isFrozen) {
return;
}
await new Promise((resolve, reject) => {
crowdFundContract.methods
.refund()
.send({ from: account })
.once('confirmation', async () => {
await sleep(5000);
isFrozen = await crowdFundContract.methods.frozen().call({ from: account });
resolve();
})
.catch((err: Error) => reject(err));
});
if (!isFrozen) {
throw new Error('Proposal isnt in a refundable state yet.');
}
}
export function triggerRefund(crowdFundContract: any) {
return async (dispatch: Dispatch<any>, getState: GetState) => {
dispatch({ type: types.WITHDRAW_REFUND_PENDING });
const state = getState();
const account = state.web3.accounts[0];
try {
await freezeContract(crowdFundContract, account);
await dispatch(fetchProposal(crowdFundContract._address));
dispatch({ type: types.TRIGGER_REFUND_FULFILLED });
} catch (err) {
dispatch({
type: types.TRIGGER_REFUND_REJECTED,
payload: err.message || err.toString(),
error: true,
});
}
};
}
export function withdrawRefund(crowdFundContract: any, address: string) { export function withdrawRefund(crowdFundContract: any, address: string) {
return async (dispatch: Dispatch<any>, getState: GetState) => { return async (dispatch: Dispatch<any>, getState: GetState) => {
dispatch({ type: types.WITHDRAW_REFUND_PENDING }); dispatch({ type: types.WITHDRAW_REFUND_PENDING });
@ -339,24 +382,7 @@ export function withdrawRefund(crowdFundContract: any, address: string) {
const account = state.web3.accounts[0]; const account = state.web3.accounts[0];
try { try {
let isFrozen = await crowdFundContract.methods.frozen().call({ from: account }); await freezeContract(crowdFundContract, account);
if (!isFrozen) {
await new Promise(resolve => {
crowdFundContract.methods
.refund()
.send({ from: account })
.once('confirmation', async () => {
await sleep(5000);
isFrozen = await crowdFundContract.methods.frozen().call({ from: account });
resolve();
});
});
}
if (!isFrozen) {
throw new Error('Proposal isnt in a refundable state yet.');
}
await crowdFundContract.methods await crowdFundContract.methods
.withdraw(address) .withdraw(address)
.send({ from: account }) .send({ from: account })

View File

@ -214,6 +214,7 @@ export default (state = INITIAL_STATE, action: any): Web3State => {
case types.VOTE_REFUND_PENDING: case types.VOTE_REFUND_PENDING:
case types.WITHDRAW_REFUND_PENDING: case types.WITHDRAW_REFUND_PENDING:
case types.TRIGGER_REFUND_PENDING:
return { return {
...state, ...state,
isRefundActionPending: true, isRefundActionPending: true,
@ -221,12 +222,14 @@ export default (state = INITIAL_STATE, action: any): Web3State => {
}; };
case types.VOTE_REFUND_FULFILLED: case types.VOTE_REFUND_FULFILLED:
case types.WITHDRAW_REFUND_FULFILLED: case types.WITHDRAW_REFUND_FULFILLED:
case types.TRIGGER_REFUND_FULFILLED:
return { return {
...state, ...state,
isRefundActionPending: false, isRefundActionPending: false,
}; };
case types.VOTE_REFUND_REJECTED: case types.VOTE_REFUND_REJECTED:
case types.WITHDRAW_REFUND_REJECTED: case types.WITHDRAW_REFUND_REJECTED:
case types.TRIGGER_REFUND_REJECTED:
return { return {
...state, ...state,
refundActionError: payload, refundActionError: payload,

View File

@ -46,6 +46,11 @@ enum web3Types {
WITHDRAW_REFUND_REJECTED = 'WITHDRAW_REFUND_REJECTED', WITHDRAW_REFUND_REJECTED = 'WITHDRAW_REFUND_REJECTED',
WITHDRAW_REFUND_PENDING = 'WITHDRAW_REFUND_PENDING', WITHDRAW_REFUND_PENDING = 'WITHDRAW_REFUND_PENDING',
TRIGGER_REFUND = 'TRIGGER_REFUND',
TRIGGER_REFUND_FULFILLED = 'TRIGGER_REFUND_FULFILLED',
TRIGGER_REFUND_REJECTED = 'TRIGGER_REFUND_REJECTED',
TRIGGER_REFUND_PENDING = 'TRIGGER_REFUND_PENDING',
ACCOUNTS = 'ACCOUNTS', ACCOUNTS = 'ACCOUNTS',
ACCOUNTS_FULFILLED = 'ACCOUNTS_FULFILLED', ACCOUNTS_FULFILLED = 'ACCOUNTS_FULFILLED',
ACCOUNTS_REJECTED = 'ACCOUNTS_REJECTED', ACCOUNTS_REJECTED = 'ACCOUNTS_REJECTED',