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 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} />} />
|
||||
|
|
|
@ -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>
|
||||
</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" />
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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' }]],
|
||||
},
|
||||
|
|
|
@ -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']))
|
||||
}
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in New Issue