From c291d41d4e696a147aef205e03277675f15df9b1 Mon Sep 17 00:00:00 2001 From: AMStrix Date: Tue, 12 Mar 2019 12:08:16 -0500 Subject: [PATCH] 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 --- admin/src/Routes.tsx | 2 + admin/src/components/Financials/index.less | 56 +++++++++ admin/src/components/Financials/index.tsx | 138 +++++++++++++++++++++ admin/src/components/Template/index.tsx | 6 + admin/src/store.ts | 41 ++++++ admin/webpack.config.js | 10 ++ backend/grant/admin/views.py | 98 ++++++++++++++- backend/grant/user/views.py | 2 +- 8 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 admin/src/components/Financials/index.less create mode 100644 admin/src/components/Financials/index.tsx diff --git a/admin/src/Routes.tsx b/admin/src/Routes.tsx index 5026b646..24a2cbcd 100644 --- a/admin/src/Routes.tsx +++ b/admin/src/Routes.tsx @@ -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 { + } /> diff --git a/admin/src/components/Financials/index.less b/admin/src/components/Financials/index.less new file mode 100644 index 00000000..a72948cd --- /dev/null +++ b/admin/src/components/Financials/index.less @@ -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; + } + } +} diff --git a/admin/src/components/Financials/index.tsx b/admin/src/components/Financials/index.tsx new file mode 100644 index 00000000..e085475e --- /dev/null +++ b/admin/src/components/Financials/index.tsx @@ -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 ; + } + + return ( +
+ + + + ( + + )} + 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 => } + height={180} + /> + + + +

+ Matching and bounty obligations for active and completed + proposals. +

+ matching - total matching amount pleged +
+ bounties - total bounty amount pledged +
+ + } + > + Grants + + } + > + ( + + )} + data={[ + { x: 'bounties', y: parseFloat(grants.bounty) }, + { x: 'matching', y: parseFloat(grants.matching) }, + ]} + valueFormat={val => } + height={180} + /> +
+ + +

Milestone payouts.

+ due - payouts currently accepted but not paid +
+ future - payouts that are not yet paid, but expected to be + requested in the future +
+ paid - total milestone payouts marked as paid, regardless of + proposal status +
+ + } + > + Payouts + + } + > + ( + + )} + data={[ + { x: 'due', y: parseFloat(payouts.due) }, + { x: 'future', y: parseFloat(payouts.future) }, + { x: 'paid', y: parseFloat(payouts.paid) }, + ]} + valueFormat={val => } + height={180} + /> +
+ +
+
+ ); + } +} + +export default view(Financials); diff --git a/admin/src/components/Template/index.tsx b/admin/src/components/Template/index.tsx index 70f840ce..4954f3a2 100644 --- a/admin/src/components/Template/index.tsx +++ b/admin/src/components/Template/index.tsx @@ -63,6 +63,12 @@ class Template extends React.Component { Contributions + + + + Financials + + diff --git a/admin/src/store.ts b/admin/src/store.ts index b562f7f9..e8d6da98 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -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) { 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('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() { diff --git a/admin/webpack.config.js b/admin/webpack.config.js index 27f3d354..4e0cfc30 100644 --- a/admin/webpack.config.js +++ b/admin/webpack.config.js @@ -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' }]], }, diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 91ae8aa9..7443b1a5 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -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'])) + } diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index 4dc0407d..9d510471 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -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):