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:
parent
e98f266100
commit
c291d41d4e
|
@ -19,6 +19,7 @@ import RFPDetail from 'components/RFPDetail';
|
||||||
import Contributions from 'components/Contributions';
|
import Contributions from 'components/Contributions';
|
||||||
import ContributionForm from 'components/ContributionForm';
|
import ContributionForm from 'components/ContributionForm';
|
||||||
import ContributionDetail from 'components/ContributionDetail';
|
import ContributionDetail from 'components/ContributionDetail';
|
||||||
|
import Financials from 'components/Financials';
|
||||||
import Moderation from 'components/Moderation';
|
import Moderation from 'components/Moderation';
|
||||||
import Settings from 'components/Settings';
|
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/edit" component={ContributionForm} />
|
||||||
<Route path="/contributions/:id" component={ContributionDetail} />
|
<Route path="/contributions/:id" component={ContributionDetail} />
|
||||||
<Route path="/contributions" component={Contributions} />
|
<Route path="/contributions" component={Contributions} />
|
||||||
|
<Route path="/financials" component={Financials} />
|
||||||
<Route path="/emails/:type?" component={Emails} />
|
<Route path="/emails/:type?" component={Emails} />
|
||||||
<Route path="/moderation" component={Moderation} />
|
<Route path="/moderation" component={Moderation} />
|
||||||
<Route path="/settings/2fa-reset" render={() => <MFAuth isReset={true} />} />
|
<Route path="/settings/2fa-reset" render={() => <MFAuth isReset={true} />} />
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
|
@ -63,6 +63,12 @@ class Template extends React.Component<Props> {
|
||||||
<span className="nav-text">Contributions</span>
|
<span className="nav-text">Contributions</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Menu.Item>
|
</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">
|
<Menu.Item key="emails">
|
||||||
<Link to="/emails">
|
<Link to="/emails">
|
||||||
<Icon type="mail" />
|
<Icon type="mail" />
|
||||||
|
|
|
@ -74,6 +74,11 @@ async function fetchStats() {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchFinancials() {
|
||||||
|
const { data } = await api.get('/admin/financials');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchUsers(params: Partial<PageQuery>) {
|
async function fetchUsers(params: Partial<PageQuery>) {
|
||||||
const { data } = await api.get('/admin/users', { params });
|
const { data } = await api.get('/admin/users', { params });
|
||||||
return data;
|
return data;
|
||||||
|
@ -219,6 +224,31 @@ const app = store({
|
||||||
contributionRefundableCount: 0,
|
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: {
|
users: {
|
||||||
page: createDefaultPageData<User>('EMAIL:DESC'),
|
page: createDefaultPageData<User>('EMAIL:DESC'),
|
||||||
},
|
},
|
||||||
|
@ -346,6 +376,17 @@ const app = store({
|
||||||
app.statsFetching = false;
|
app.statsFetching = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async fetchFinancials() {
|
||||||
|
app.financialsFetching = true;
|
||||||
|
try {
|
||||||
|
app.financials = await fetchFinancials();
|
||||||
|
app.financialsFetched = true;
|
||||||
|
} catch (e) {
|
||||||
|
handleApiError(e);
|
||||||
|
}
|
||||||
|
app.financialsFetching = false;
|
||||||
|
},
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
|
|
||||||
async fetchUsers() {
|
async fetchUsers() {
|
||||||
|
|
|
@ -41,6 +41,16 @@ module.exports = {
|
||||||
'@babel/plugin-proposal-object-rest-spread',
|
'@babel/plugin-proposal-object-rest-spread',
|
||||||
'@babel/plugin-proposal-class-properties',
|
'@babel/plugin-proposal-class-properties',
|
||||||
['import', { libraryName: 'antd', style: true }],
|
['import', { libraryName: 'antd', style: true }],
|
||||||
|
[
|
||||||
|
'import',
|
||||||
|
{
|
||||||
|
libraryName: 'ant-design-pro',
|
||||||
|
libraryDirectory: 'lib',
|
||||||
|
style: true,
|
||||||
|
camel2DashComponentName: false,
|
||||||
|
},
|
||||||
|
'antdproimport',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
presets: ['@babel/react', ['@babel/env', { useBuiltIns: 'entry' }]],
|
presets: ['@babel/react', ['@babel/env', { useBuiltIns: 'entry' }]],
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,7 +4,7 @@ from functools import reduce
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
from marshmallow import fields, validate
|
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.admin as admin
|
||||||
import grant.utils.auth as auth
|
import grant.utils.auth as auth
|
||||||
|
@ -712,3 +712,99 @@ def edit_comment(comment_id, hidden, reported):
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return admin_comment_schema.dump(comment)
|
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']))
|
||||||
|
}
|
||||||
|
|
|
@ -342,7 +342,7 @@ def get_user_settings(user_id):
|
||||||
@auth.requires_same_user_auth
|
@auth.requires_same_user_auth
|
||||||
# TODO guard all (shape, validity)
|
# TODO guard all (shape, validity)
|
||||||
@body({
|
@body({
|
||||||
"emailSubscriptions": fields.Dict(required=True),
|
"emailSubscriptions": fields.Dict(required=False, missing=None),
|
||||||
"refundAddress": fields.Str(required=False, missing=None)
|
"refundAddress": fields.Str(required=False, missing=None)
|
||||||
})
|
})
|
||||||
def set_user_settings(user_id, email_subscriptions, refund_address):
|
def set_user_settings(user_id, email_subscriptions, refund_address):
|
||||||
|
|
Loading…
Reference in New Issue