Admin financials (#310)

* admin: financials basics

* BE: allow missing emailSettings on user settings EP

* admin: add webpack import loader for ant-design-pro (for styles loading)

* BE: admin financials reporting

* admin: financials page

* admin financials - convert contribution and payout total summations to sql

* Handle some kinda failure case.

* admin: remove "accounting" card from financials
This commit is contained in:
AMStrix 2019-03-12 12:08:16 -05:00 committed by Daniel Ternyak
parent e98f266100
commit c291d41d4e
8 changed files with 351 additions and 2 deletions

View File

@ -19,6 +19,7 @@ import RFPDetail from 'components/RFPDetail';
import Contributions from 'components/Contributions';
import ContributionForm from 'components/ContributionForm';
import ContributionDetail from 'components/ContributionDetail';
import Financials from 'components/Financials';
import Moderation from 'components/Moderation';
import Settings from 'components/Settings';
@ -54,6 +55,7 @@ class Routes extends React.Component<Props> {
<Route path="/contributions/:id/edit" component={ContributionForm} />
<Route path="/contributions/:id" component={ContributionDetail} />
<Route path="/contributions" component={Contributions} />
<Route path="/financials" component={Financials} />
<Route path="/emails/:type?" component={Emails} />
<Route path="/moderation" component={Moderation} />
<Route path="/settings/2fa-reset" render={() => <MFAuth isReset={true} />} />

View File

@ -0,0 +1,56 @@
.Financials {
&-zcash {
font-size: 0.8rem;
vertical-align: text-top;
display: inline-block;
padding-top: 0.15rem;
}
&-bottomLine {
& > div {
display: flex;
justify-content: space-between;
&.is-net {
margin-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.65);
padding-top: 0.5rem;
}
& .Info {
font-size: 0.9rem;
}
& > div + div {
font-family: 'Courier New', Courier, monospace;
min-width: 250px;
}
small {
display: inline-block;
padding-right: 0.1rem;
}
}
font-size: 1.2rem;
}
h1 {
font-size: 1.5rem;
}
.ant-card {
margin-bottom: 16px;
}
.antd-pro-charts-pie-total {
.pie-sub-title {
margin: 0;
}
}
.antd-pro-charts-pie-legend {
li {
margin-bottom: 0;
}
}
}

View File

@ -0,0 +1,138 @@
import React from 'react';
import { Spin, Card, Row, Col } from 'antd';
import { Charts } from 'ant-design-pro';
import { view } from 'react-easy-state';
import store from '../../store';
import Info from 'components/Info';
import './index.less';
class Financials extends React.Component {
componentDidMount() {
store.fetchFinancials();
}
render() {
const { contributions, grants, payouts } = store.financials;
if (!store.financialsFetched) {
return <Spin tip="Loading financials..." />;
}
return (
<div className="Financials">
<Row gutter={16}>
<Col span={12}>
<Card size="small" title="Contributions">
<Charts.Pie
hasLegend
title="Contributions"
subTitle="Total"
total={() => (
<span
dangerouslySetInnerHTML={{
__html: 'ⓩ ' + contributions.total,
}}
/>
)}
data={[
{ x: 'funded', y: parseFloat(contributions.funded) },
{ x: 'funding', y: parseFloat(contributions.funding) },
{ x: 'refunding', y: parseFloat(contributions.refunding) },
{ x: 'refunded', y: parseFloat(contributions.refunded) },
{ x: 'staking', y: parseFloat(contributions.staking) },
]}
valueFormat={val => <span dangerouslySetInnerHTML={{ __html: val }} />}
height={180}
/>
</Card>
<Card
size="small"
title={
<Info
content={
<>
<p>
Matching and bounty obligations for active and completed
proposals.
</p>
<b>matching</b> - total matching amount pleged
<br />
<b>bounties</b> - total bounty amount pledged
<br />
</>
}
>
Grants
</Info>
}
>
<Charts.Pie
hasLegend
title="Grants"
subTitle="Total"
total={() => (
<span
dangerouslySetInnerHTML={{
__html: 'ⓩ ' + grants.total,
}}
/>
)}
data={[
{ x: 'bounties', y: parseFloat(grants.bounty) },
{ x: 'matching', y: parseFloat(grants.matching) },
]}
valueFormat={val => <span dangerouslySetInnerHTML={{ __html: val }} />}
height={180}
/>
</Card>
<Card
size="small"
title={
<Info
content={
<>
<p>Milestone payouts.</p>
<b>due</b> - payouts currently accepted but not paid
<br />
<b>future</b> - payouts that are not yet paid, but expected to be
requested in the future
<br />
<b>paid</b> - total milestone payouts marked as paid, regardless of
proposal status
<br />
</>
}
>
Payouts
</Info>
}
>
<Charts.Pie
hasLegend
title="Payouts"
subTitle="Total"
total={() => (
<span
dangerouslySetInnerHTML={{
__html: 'ⓩ ' + payouts.total,
}}
/>
)}
data={[
{ x: 'due', y: parseFloat(payouts.due) },
{ x: 'future', y: parseFloat(payouts.future) },
{ x: 'paid', y: parseFloat(payouts.paid) },
]}
valueFormat={val => <span dangerouslySetInnerHTML={{ __html: val }} />}
height={180}
/>
</Card>
</Col>
</Row>
</div>
);
}
}
export default view(Financials);

View File

@ -63,6 +63,12 @@ class Template extends React.Component<Props> {
<span className="nav-text">Contributions</span>
</Link>
</Menu.Item>
<Menu.Item key="financials">
<Link to="/financials">
<Icon type="audit" />
<span className="nav-text">Financials</span>
</Link>
</Menu.Item>
<Menu.Item key="emails">
<Link to="/emails">
<Icon type="mail" />

View File

@ -74,6 +74,11 @@ async function fetchStats() {
return data;
}
async function fetchFinancials() {
const { data } = await api.get('/admin/financials');
return data;
}
async function fetchUsers(params: Partial<PageQuery>) {
const { data } = await api.get('/admin/users', { params });
return data;
@ -219,6 +224,31 @@ const app = store({
contributionRefundableCount: 0,
},
financialsFetched: false,
financialsFetching: false,
financials: {
grants: {
total: '0',
matching: '0',
bounty: '0',
},
contributions: {
total: '0',
gross: '0',
staking: '0',
funding: '0',
funded: '0',
refunding: '0',
refunded: '0',
},
payouts: {
total: '0',
due: '0',
paid: '0',
future: '0',
},
},
users: {
page: createDefaultPageData<User>('EMAIL:DESC'),
},
@ -346,6 +376,17 @@ const app = store({
app.statsFetching = false;
},
async fetchFinancials() {
app.financialsFetching = true;
try {
app.financials = await fetchFinancials();
app.financialsFetched = true;
} catch (e) {
handleApiError(e);
}
app.financialsFetching = false;
},
// Users
async fetchUsers() {

View File

@ -41,6 +41,16 @@ module.exports = {
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-proposal-class-properties',
['import', { libraryName: 'antd', style: true }],
[
'import',
{
libraryName: 'ant-design-pro',
libraryDirectory: 'lib',
style: true,
camel2DashComponentName: false,
},
'antdproimport',
],
],
presets: ['@babel/react', ['@babel/env', { useBuiltIns: 'entry' }]],
},

View File

@ -4,7 +4,7 @@ from functools import reduce
from flask import Blueprint, request
from marshmallow import fields, validate
from sqlalchemy import func, or_
from sqlalchemy import func, or_, text
import grant.utils.admin as admin
import grant.utils.auth as auth
@ -712,3 +712,99 @@ def edit_comment(comment_id, hidden, reported):
db.session.commit()
return admin_comment_schema.dump(comment)
# Financials
@blueprint.route("/financials", methods=["GET"])
@admin.admin_auth_required
def financials():
nfmt = '999999.99999999' # smallest unit of ZEC
def sql_pc(where: str):
return f"SELECT SUM(TO_NUMBER(amount, '{nfmt}')) FROM proposal_contribution WHERE {where}"
def sql_pc_p(where: str):
return f'''
SELECT SUM(TO_NUMBER(amount, '{nfmt}'))
FROM proposal_contribution as pc
INNER JOIN proposal as p ON pc.proposal_id = p.id
WHERE {where}
'''
def sql_ms(where: str):
return f'''
SELECT SUM(TO_NUMBER(ms.payout_percent, '999')/100 * TO_NUMBER(p.target, '999999.99999999'))
FROM milestone as ms
INNER JOIN proposal as p ON ms.proposal_id = p.id
WHERE {where}
'''
def ex(sql: str):
res = db.engine.execute(text(sql))
return [row[0] if row[0] else Decimal(0) for row in res][0].normalize()
contributions = {
'total': str(ex(sql_pc("status = 'CONFIRMED' AND staking = FALSE"))),
'staking': str(ex(sql_pc("status = 'CONFIRMED' AND staking = TRUE"))),
'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')"
))),
'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')"
))),
'gross': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.refund_tx_id IS NULL"))),
}
po_due = ex(sql_ms("ms.stage = 'ACCEPTED'")) # payments accepted but not yet marked as paid
po_paid = ex(sql_ms("ms.stage = 'PAID'")) # will catch paid ms from all proposals regardless of status/stage
# expected payments
po_future = ex(sql_ms("ms.stage IN ('IDLE', 'REJECTED', 'REQUESTED') AND p.stage IN ('WIP', 'COMPLETED')"))
po_total = po_due + po_paid + po_future
payouts = {
'total': str(po_total),
'due': str(po_due),
'paid': str(po_paid),
'future': str(po_future),
}
grants = {
'total': '0',
'matching': '0',
'bounty': '0',
}
def add_str_dec(a: str, b: str):
return str(Decimal(a) + Decimal(b))
proposals = Proposal.query.all()
for p in proposals:
# CANCELED proposals excluded, though they could have had milestones paid out with grant funds
if p.stage in [ProposalStage.WIP, ProposalStage.COMPLETED]:
# matching
matching = Decimal(p.contributed) * Decimal(p.contribution_matching)
remaining = Decimal(p.target) - Decimal(p.contributed)
if matching > remaining:
matching = remaining
# bounty
bounty = Decimal(p.contribution_bounty)
remaining = Decimal(p.target) - (matching + Decimal(p.contributed))
if bounty > remaining:
bounty = remaining
grants['matching'] = add_str_dec(grants['matching'], matching)
grants['bounty'] = add_str_dec(grants['bounty'], bounty)
grants['total'] = add_str_dec(grants['total'], matching + bounty)
return {
'grants': grants,
'contributions': contributions,
'payouts': payouts,
'net': str(Decimal(contributions['gross']) - Decimal(payouts['paid']))
}

View File

@ -342,7 +342,7 @@ def get_user_settings(user_id):
@auth.requires_same_user_auth
# TODO guard all (shape, validity)
@body({
"emailSubscriptions": fields.Dict(required=True),
"emailSubscriptions": fields.Dict(required=False, missing=None),
"refundAddress": fields.Str(required=False, missing=None)
})
def set_user_settings(user_id, email_subscriptions, refund_address):