Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[cms] Proposal owner invoice approval #1243

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
78 changes: 62 additions & 16 deletions mdstream/mdstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,28 +21,30 @@ import (

const (
// mdstream IDs
IDProposalGeneral = 0
IDRecordStatusChange = 2
IDInvoiceGeneral = 3
IDInvoiceStatusChange = 4
IDInvoicePayment = 5
IDDCCGeneral = 6
IDDCCStatusChange = 7
IDDCCSupportOpposition = 8
IDProposalGeneral = 0
IDRecordStatusChange = 2
IDInvoiceGeneral = 3
IDInvoiceStatusChange = 4
IDInvoicePayment = 5
IDDCCGeneral = 6
IDDCCStatusChange = 7
IDDCCSupportOpposition = 8
IDInvoiceProposalApprove = 9

// Note that 13 is in use by the decred plugin
// Note that 14 is in use by the decred plugin
// Note that 15 is in use by the decred plugin

// mdstream current supported versions
VersionProposalGeneral = 2
VersionRecordStatusChange = 2
VersionInvoiceGeneral = 1
VersionInvoiceStatusChange = 1
VersionInvoicePayment = 1
VersionDCCGeneral = 1
VersionDCCStatusChange = 1
VersionDCCSupposeOpposition = 1
VersionProposalGeneral = 2
VersionRecordStatusChange = 2
VersionInvoiceGeneral = 1
VersionInvoiceStatusChange = 1
VersionInvoicePayment = 1
VersionDCCGeneral = 1
VersionDCCStatusChange = 1
VersionDCCSupposeOpposition = 1
VersionInvoiceProposalApprove = 1

// Filenames of user defined metadata that is stored as politeiad
// files instead of politeiad metadata streams. This is done so
Expand Down Expand Up @@ -531,3 +533,47 @@ func DecodeDCCSupportOpposition(payload []byte) ([]DCCSupportOpposition, error)

return md, nil
}

// InvoiceProposalApprove represents an invoice status change and is stored
// in the metadata IDInvoiceProposalApprove in politeiad.
type InvoiceProposalApprove struct {
Version uint `json:"version"` // Version of the struct
PublicKey string `json:"publickey"` // Identity of the administrator
Signature string `json:"signature"` // Signature of the line item payload included
Token string `json:"token"` // Token of the invoice
Timestamp int64 `json:"timestamp"`
LineItems []byte `json:"lineitems"` // json payload of line items that are being approved
InvoiceVersion string `json:"invoiceversion"` // Version of the invoice that is being approved
}

// EncodeInvoiceProposalApprove encodes a InvoiceProposalApprove into a
// JSON byte slice.
func EncodeInvoiceProposalApprove(md InvoiceProposalApprove) ([]byte, error) {
b, err := json.Marshal(md)
if err != nil {
return nil, err
}

return b, nil
}

// DecodeInvoiceProposalApprove decodes a JSON byte slice into a slice of
// InvoiceProposalApproves.
func DecodeInvoiceProposalApprove(payload []byte) ([]InvoiceProposalApprove, error) {
var md []InvoiceProposalApprove

d := json.NewDecoder(strings.NewReader(string(payload)))
for {
var m InvoiceProposalApprove
err := d.Decode(&m)
if err == io.EOF {
break
} else if err != nil {
return nil, err
}

md = append(md, m)
}

return md, nil
}
17 changes: 17 additions & 0 deletions politeiawww/api/cms/v1/v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const (
RouteProposalBillingSummary = "/proposals/spendingsummary"
RouteProposalBillingDetails = "/proposals/spendingdetails"
RouteUserCodeStats = "/user/codestats"
RouteProposalInvoiceApprove = "/proposals/approve"

// Invoice status codes
InvoiceStatusInvalid InvoiceStatusT = 0 // Invalid status
Expand Down Expand Up @@ -469,6 +470,7 @@ type InvoiceRecord struct {
Input InvoiceInput `json:"input"` // Decoded invoice from invoice.json file
Payment PaymentInformation `json:"payment"` // Payment information for the Invoice
Total int64 `json:"total"` // Total amount that the invoice is billing
ApprovedProposals []string `json:"approvedproposals"` // List of proposals from this invoice that have been approved
CensorshipRecord www.CensorshipRecord `json:"censorshiprecord"`
}

Expand Down Expand Up @@ -511,6 +513,7 @@ type LineItemsInput struct {
SubRate uint `json:"subrate"` // The payrate of the subcontractor
Labor uint `json:"labor"` // Number of minutes (if labor)
Expenses uint `json:"expenses"` // Total cost (in USD cents) of line item (if expense or misc)
Approved bool `json:"approved"` // Proposal owner approved this line item (if proposal token specified)
}

// PolicyReply returns the various policy information while in CMS mode.
Expand Down Expand Up @@ -1085,3 +1088,17 @@ type CodeStats struct {
Reviews []string `json:"reviews"`
Commits []string `json:"commits"`
}

// ProposalOwnerApprove is used to approve or reject an proposal referenced
// invoice.
type ProposalOwnerApprove struct {
Token string `json:"token"`
Status InvoiceStatusT `json:"status"`
LineItems []LineItemsInput `json:"lineitems"`
Signature string `json:"signature"` // Signature of LineItems Approved
PublicKey string `json:"publickey"` // Public key of admin
}

// ProposalOwnerApprove used to reply to a ProposalOwnerApprove command.
type ProposalOwnerApproveReply struct {
}
4 changes: 3 additions & 1 deletion politeiawww/cmsdatabase/cockroachdb/cockroachdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,8 @@ func (c *cockroachdb) InvoicesByLineItemsProposalToken(token string) ([]database
line_items.labor,
line_items.expenses,
line_items.contractor_rate AS sub_rate,
line_items.sub_user_id as sub_user
line_items.sub_user_id AS sub_user,
line_items.approved
FROM invoices
LEFT OUTER JOIN invoices b
ON invoices.token = b.token
Expand Down Expand Up @@ -678,6 +679,7 @@ type MatchingLineItems struct {
ExchangeRate uint
SubRate uint
SubUser string
Approved bool
}

// Close satisfies the database interface.
Expand Down
3 changes: 3 additions & 0 deletions politeiawww/cmsdatabase/cockroachdb/encoding.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ func EncodeInvoiceLineItem(dbLineItem *database.LineItem) LineItem {
lineItem.Expenses = dbLineItem.Expenses
lineItem.ContractorRate = dbLineItem.ContractorRate
lineItem.SubUserID = dbLineItem.SubUserID
lineItem.Approved = dbLineItem.Approved
return lineItem
}

Expand All @@ -130,6 +131,7 @@ func DecodeInvoiceLineItem(lineItem *LineItem) *database.LineItem {
dbLineItem.Expenses = lineItem.Expenses
dbLineItem.ContractorRate = lineItem.ContractorRate
dbLineItem.SubUserID = lineItem.SubUserID
dbLineItem.Approved = lineItem.Approved

return dbLineItem
}
Expand Down Expand Up @@ -274,6 +276,7 @@ func convertMatchingLineItemToInvoices(matching []MatchingLineItems) []database.
ProposalURL: vv.ProposalURL,
ContractorRate: vv.SubRate,
SubUserID: vv.SubUser,
Approved: vv.Approved,
}
inv := database.Invoice{
PublicKey: vv.PublicKey,
Expand Down
1 change: 1 addition & 0 deletions politeiawww/cmsdatabase/cockroachdb/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type LineItem struct {
Expenses uint `gorm:"not null"` // Total cost of line item (in USD cents)
ContractorRate uint `gorm:"not null"` // Optional contractor rate for line item, typically used for Sub Contractors
SubUserID string `gorm:"not null"` // SubContractor User ID if Subcontractor Line Item
Approved bool `gorm:"not null"` // Proposal owner approved line item
}

// TableName returns the table name of the line items table.
Expand Down
2 changes: 2 additions & 0 deletions politeiawww/cmsdatabase/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ type Invoice struct {
ContractorContact string
ContractorRate uint
PaymentAddress string
ApprovedProposals []string

LineItems []LineItem // All line items parsed from the raw invoice provided.
Changes []InvoiceChange // All status changes that the invoice has had.
Expand All @@ -128,6 +129,7 @@ type LineItem struct {
Expenses uint
ContractorRate uint
SubUserID string
Approved bool
}

// InvoiceChange contains entries for any status update that occurs to a given
Expand Down
34 changes: 34 additions & 0 deletions politeiawww/cmswww.go
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,37 @@ func (p *politeiawww) handleUserCodeStats(w http.ResponseWriter, r *http.Request
util.RespondWithJSON(w, http.StatusOK, uscr)
}

// handleProposalInvoiceApprove handles request for proposal owners to approve
// an invoices' line items that reference their proposal.
func (p *politeiawww) handleProposalInvoiceApprove(w http.ResponseWriter, r *http.Request) {
log.Tracef("handleProposalInvoiceApprove")

var poa cms.ProposalOwnerApprove
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&poa); err != nil {
RespondWithError(w, r, 0, "handleProposalInvoiceApprove: unmarshal",
www.UserError{
ErrorCode: www.ErrorStatusInvalidInput,
})
return
}
user, err := p.getSessionUser(w, r)
if err != nil {
RespondWithError(w, r, 0,
"handleProposalInvoiceApprove: getSessionUser %v", err)
return
}

poar, err := p.processProposalInvoiceApprove(r.Context(), poa, user)
if err != nil {
RespondWithError(w, r, 0,
"handleProposalInvoiceApprove: processStartVoteDCC %v", err)
return
}

util.RespondWithJSON(w, http.StatusOK, poar)
}

func (p *politeiawww) setCMSWWWRoutes() {
// Templates
//p.addTemplate(templateNewProposalSubmittedName,
Expand Down Expand Up @@ -1224,6 +1255,9 @@ func (p *politeiawww) setCMSWWWRoutes() {
p.addRoute(http.MethodPost, cms.APIRoute,
cms.RouteUserCodeStats, p.handleUserCodeStats,
permissionLogin)
p.addRoute(http.MethodPost, cms.APIRoute,
cms.RouteProposalInvoiceApprove, p.handleProposalInvoiceApprove,
permissionLogin)

// Unauthenticated websocket
p.addRoute("", www.PoliteiaWWWAPIRoute,
Expand Down
19 changes: 17 additions & 2 deletions politeiawww/comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,8 +367,23 @@ func (p *politeiawww) processNewCommentInvoice(ctx context.Context, nc www.NewCo
// Check to make sure the user is either an admin or the
// author of the invoice.
if !u.Admin && (ir.Username != u.Username) {
return nil, www.UserError{
ErrorCode: www.ErrorStatusUserActionNotAllowed,
// If not an admin or invoice owner, check to see if they own a
// proposal that is being billed against, they are allowed to comment.
cmsUser, err := p.getCMSUserByID(u.ID.String())
if err != nil {
return nil, err
}

proposalFound := false
for _, lineItem := range ir.Input.LineItems {
if stringInSlice(cmsUser.ProposalsOwned, lineItem.ProposalToken) {
proposalFound = true
}
}
if !proposalFound {
return nil, www.UserError{
ErrorCode: www.ErrorStatusUserActionNotAllowed,
}
}
}

Expand Down
37 changes: 37 additions & 0 deletions politeiawww/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,7 @@ func convertDatabaseInvoiceToInvoiceRecord(dbInvoice cmsdatabase.Invoice) (cms.I
Expenses: dbLineItem.Expenses,
SubRate: dbLineItem.ContractorRate,
SubUserID: dbLineItem.SubUserID,
Approved: dbLineItem.Approved,
}
invInputLineItems = append(invInputLineItems, lineItem)
}
Expand Down Expand Up @@ -926,6 +927,7 @@ func convertInvoiceRecordToDatabaseInvoice(invRec *cms.InvoiceRecord) *cmsdataba
Expenses: lineItem.Expenses,
ContractorRate: lineItem.SubRate,
SubUserID: lineItem.SubUserID,
Approved: lineItem.Approved,
}
dbInvoice.LineItems = append(dbInvoice.LineItems, dbLineItem)
}
Expand All @@ -947,6 +949,7 @@ func convertLineItemsToDatabase(token string, l []cms.LineItemsInput) []cmsdatab
// If subrate is populated, use the existing contractor rate field.
ContractorRate: v.SubRate,
SubUserID: v.SubUserID,
Approved: v.Approved,
})
}
return dl
Expand All @@ -963,6 +966,7 @@ func convertDatabaseToLineItems(dl []cmsdatabase.LineItem) []cms.LineItemsInput
ProposalToken: v.ProposalURL,
Labor: v.Labor,
Expenses: v.Expenses,
Approved: v.Approved,
})
}
return l
Expand All @@ -976,6 +980,7 @@ func convertRecordToDatabaseInvoice(p pd.Record) (*cmsdatabase.Invoice, error) {
Version: p.Version,
}

var approvedProposals []string
// Decode invoice file
for _, v := range p.Files {
if v.Name == invoiceFile {
Expand Down Expand Up @@ -1070,6 +1075,28 @@ func convertRecordToDatabaseInvoice(p pd.Record) (*cmsdatabase.Invoice, error) {
payment.TimeLastUpdated = s.Timestamp
payment.AmountReceived = s.AmountReceived
}
dbInvoice.Payments = payment
case mdstream.IDInvoiceProposalApprove:
ipa, err := mdstream.DecodeInvoiceProposalApprove([]byte(m.Payload))
if err != nil {
log.Errorf("convertInvoiceFromCache: decode md stream: "+
"token:%v error:%v payload:%v",
p.CensorshipRecord.Token, err, m)
continue
}
for _, s := range ipa {
// Check to see if the approved invoice version doesn't match
// current invoice version. If so, the proposal owner needs to
// approve again.
if s.InvoiceVersion != p.Version {
continue
}
if approvedProposals == nil {
approvedProposals = make([]string, 0, 1048)
}
approvedProposals = append(approvedProposals, s.Token)
}

default:
// Log error but proceed
log.Errorf("convertRecordToInvoiceDB: invalid "+
Expand All @@ -1079,6 +1106,15 @@ func convertRecordToDatabaseInvoice(p pd.Record) (*cmsdatabase.Invoice, error) {
}
dbInvoice.Payments = payment

// Set all line items to approved based on metadata
for _, approvedProposal := range approvedProposals {
for i := range dbInvoice.LineItems {
if dbInvoice.LineItems[i].ProposalURL == approvedProposal {
dbInvoice.LineItems[i].Approved = true
}
}
}

return &dbInvoice, nil
}

Expand Down Expand Up @@ -1408,6 +1444,7 @@ func convertDatabaseInvoiceToProposalLineItems(inv cmsdatabase.Invoice) cms.Prop
Labor: inv.LineItems[0].Labor,
Expenses: inv.LineItems[0].Expenses,
SubRate: inv.LineItems[0].ContractorRate,
Approved: inv.LineItems[0].Approved,
},
}
}
Expand Down
Loading