diff --git a/satellite/console/billinghistoryitem.go b/satellite/console/billinghistoryitem.go index 0e02ec173..ca5eff11c 100644 --- a/satellite/console/billinghistoryitem.go +++ b/satellite/console/billinghistoryitem.go @@ -21,6 +21,27 @@ type BillingHistoryItem struct { Type BillingHistoryItemType `json:"type"` } +// BillingHistoryCursor holds info for billing history +// cursor pagination. +type BillingHistoryCursor struct { + Limit int + + // StartingAfter is the last ID of the previous page. + // The next page will start after this ID. + StartingAfter string + // EndingBefore is the id before which a page should end. + EndingBefore string +} + +// BillingHistoryPage returns paginated billing history items. +type BillingHistoryPage struct { + Items []BillingHistoryItem `json:"items"` + // Next indicates whether there are more events to retrieve. + Next bool `json:"next"` + // Previous indicates whether there are previous items. + Previous bool `json:"previous"` +} + // BillingHistoryItemType indicates type of billing history item. type BillingHistoryItemType int diff --git a/satellite/console/consoleweb/consoleapi/payments.go b/satellite/console/consoleweb/consoleapi/payments.go index b80743d6c..c68a64e21 100644 --- a/satellite/console/consoleweb/consoleapi/payments.go +++ b/satellite/console/consoleweb/consoleapi/payments.go @@ -342,6 +342,52 @@ func (p *Payments) BillingHistory(w http.ResponseWriter, r *http.Request) { } } +// InvoiceHistory returns a paged list of invoice history items for payment account. +func (p *Payments) InvoiceHistory(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + defer mon.Task()(&ctx)(&err) + + w.Header().Set("Content-Type", "application/json") + + query := r.URL.Query() + + limitParam := query.Get("limit") + if limitParam == "" { + p.serveJSONError(ctx, w, http.StatusBadRequest, errs.New("parameter 'limit' is required")) + return + } + + limit, pErr := strconv.ParseUint(limitParam, 10, 32) + if pErr != nil { + p.serveJSONError(ctx, w, http.StatusBadRequest, err) + return + } + + startParam := query.Get("starting_after") + endParam := query.Get("ending_before") + + history, err := p.service.Payments().InvoiceHistory(ctx, console.BillingHistoryCursor{ + Limit: int(limit), + StartingAfter: startParam, + EndingBefore: endParam, + }) + if err != nil { + if console.ErrUnauthorized.Has(err) { + p.serveJSONError(ctx, w, http.StatusUnauthorized, err) + return + } + + p.serveJSONError(ctx, w, http.StatusInternalServerError, err) + return + } + + err = json.NewEncoder(w).Encode(history) + if err != nil { + p.log.Error("failed to write json history response", zap.Error(ErrPaymentsAPI.Wrap(err))) + } +} + // ApplyCouponCode applies a coupon code to the user's account. func (p *Payments) ApplyCouponCode(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/satellite/console/consoleweb/endpoints_test.go b/satellite/console/consoleweb/endpoints_test.go index 4aea93419..0b94b8739 100644 --- a/satellite/console/consoleweb/endpoints_test.go +++ b/satellite/console/consoleweb/endpoints_test.go @@ -249,6 +249,7 @@ func TestPayments(t *testing.T) { "/payments/cards", "/payments/account/balance", "/payments/billing-history", + "/payments/invoice-history", "/payments/account/charges?from=1619827200&to=1620844320", } { resp, body := test.request(http.MethodGet, path, nil) @@ -277,6 +278,12 @@ func TestPayments(t *testing.T) { require.Equal(t, http.StatusOK, resp.StatusCode) } + { // Get_InvoiceHistory + resp, body := test.request(http.MethodGet, "/payments/invoice-history?limit=1", nil) + require.Contains(t, body, "items") + require.Equal(t, http.StatusOK, resp.StatusCode) + } + { // Get_AccountChargesByDateRange resp, body := test.request(http.MethodGet, "/payments/account/charges?from=1619827200&to=1620844320", nil) require.Contains(t, body, "egress") diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index 22cad14ac..cfea2147c 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -336,6 +336,7 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc paymentsRouter.HandleFunc("/wallet/payments", paymentController.WalletPayments).Methods(http.MethodGet, http.MethodOptions) paymentsRouter.HandleFunc("/wallet/payments-with-confirmations", paymentController.WalletPaymentsWithConfirmations).Methods(http.MethodGet, http.MethodOptions) paymentsRouter.HandleFunc("/billing-history", paymentController.BillingHistory).Methods(http.MethodGet, http.MethodOptions) + paymentsRouter.HandleFunc("/invoice-history", paymentController.InvoiceHistory).Methods(http.MethodGet, http.MethodOptions) paymentsRouter.Handle("/coupon/apply", server.userIDRateLimiter.Limit(http.HandlerFunc(paymentController.ApplyCouponCode))).Methods(http.MethodPatch, http.MethodOptions) paymentsRouter.HandleFunc("/coupon", paymentController.GetCoupon).Methods(http.MethodGet, http.MethodOptions) paymentsRouter.HandleFunc("/pricing", paymentController.GetProjectUsagePriceModel).Methods(http.MethodGet, http.MethodOptions) diff --git a/satellite/console/service.go b/satellite/console/service.go index cd04e617a..07194ad4b 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -571,6 +571,45 @@ func (payment Payments) BillingHistory(ctx context.Context) (billingHistory []*B return billingHistory, nil } +// InvoiceHistory returns a paged list of invoices for payment account. +func (payment Payments) InvoiceHistory(ctx context.Context, cursor BillingHistoryCursor) (history *BillingHistoryPage, err error) { + defer mon.Task()(&ctx)(&err) + + user, err := payment.service.getUserAndAuditLog(ctx, "get invoice history") + if err != nil { + return nil, Error.Wrap(err) + } + + page, err := payment.service.accounts.Invoices().ListPaged(ctx, user.ID, payments.InvoiceCursor{ + Limit: cursor.Limit, + StartingAfter: cursor.StartingAfter, + EndingBefore: cursor.EndingBefore, + }) + if err != nil { + return nil, Error.Wrap(err) + } + + var historyItems []BillingHistoryItem + for _, invoice := range page.Invoices { + historyItems = append(historyItems, BillingHistoryItem{ + ID: invoice.ID, + Description: invoice.Description, + Amount: invoice.Amount, + Status: invoice.Status, + Link: invoice.Link, + End: invoice.End, + Start: invoice.Start, + Type: Invoice, + }) + } + + return &BillingHistoryPage{ + Items: historyItems, + Next: page.Next, + Previous: page.Previous, + }, nil +} + // checkOutstandingInvoice returns if the payment account has any unpaid/outstanding invoices or/and invoice items. func (payment Payments) checkOutstandingInvoice(ctx context.Context) (err error) { defer mon.Task()(&ctx)(&err) diff --git a/satellite/payments/invoices.go b/satellite/payments/invoices.go index 7ccfeac09..633de1878 100644 --- a/satellite/payments/invoices.go +++ b/satellite/payments/invoices.go @@ -35,6 +35,8 @@ type Invoices interface { Pay(ctx context.Context, invoiceID, paymentMethodID string) (*Invoice, error) // List returns a list of invoices for a given payment account. List(ctx context.Context, userID uuid.UUID) ([]Invoice, error) + // ListPaged returns a paged list of invoices. + ListPaged(ctx context.Context, userID uuid.UUID, cursor InvoiceCursor) (*InvoicePage, error) // ListFailed returns a list of failed invoices. ListFailed(ctx context.Context, userID *uuid.UUID) ([]Invoice, error) // ListWithDiscounts returns a list of invoices and coupon usages for a given payment account. @@ -59,6 +61,27 @@ type Invoice struct { End time.Time `json:"end"` } +// InvoiceCursor holds info for invoices +// cursor pagination. +type InvoiceCursor struct { + Limit int + + // StartingAfter is the last invoice ID of the previous page. + // The next page will start after this ID. + StartingAfter string + // EndingBefore is the id before which a page should end. + EndingBefore string +} + +// InvoicePage returns paginated invoices. +type InvoicePage struct { + Invoices []Invoice + // Next indicates whether there are more events to retrieve. + Next bool + // Previous indicates whether there are previous items. + Previous bool +} + // CouponUsage describes the usage of a coupon on an invoice. type CouponUsage struct { Coupon Coupon diff --git a/satellite/payments/stripe/invoices.go b/satellite/payments/stripe/invoices.go index fee106f6d..613a618b2 100644 --- a/satellite/payments/stripe/invoices.go +++ b/satellite/payments/stripe/invoices.go @@ -268,6 +268,61 @@ func (invoices *invoices) ListFailed(ctx context.Context, userID *uuid.UUID) (in return invoicesList, nil } +func (invoices *invoices) ListPaged(ctx context.Context, userID uuid.UUID, cursor payments.InvoiceCursor) (page *payments.InvoicePage, err error) { + defer mon.Task()(&ctx)(&err) + + customerID, err := invoices.service.db.Customers().GetCustomerID(ctx, userID) + if err != nil { + return nil, Error.Wrap(err) + } + + page = &payments.InvoicePage{} + params := &stripe.InvoiceListParams{ + ListParams: stripe.ListParams{Context: ctx}, + Customer: &customerID, + } + + params.Limit = stripe.Int64(int64(cursor.Limit)) + params.Single = true + if cursor.StartingAfter != "" { + page.Previous = true + params.StartingAfter = stripe.String(cursor.StartingAfter) + } else if cursor.EndingBefore != "" { + params.EndingBefore = stripe.String(cursor.EndingBefore) + } + + invoicesIterator := invoices.service.stripeClient.Invoices().List(params) + for invoicesIterator.Next() { + stripeInvoice := invoicesIterator.Invoice() + + total := stripeInvoice.Total + for _, line := range stripeInvoice.Lines.Data { + // If amount is negative, this is a coupon or a credit line item. + // Add them to the total. + if line.Amount < 0 { + total -= line.Amount + } + } + + page.Invoices = append(page.Invoices, payments.Invoice{ + ID: stripeInvoice.ID, + CustomerID: stripeInvoice.Customer.ID, + Description: stripeInvoice.Description, + Amount: total, + Status: string(stripeInvoice.Status), + Link: stripeInvoice.InvoicePDF, + Start: time.Unix(stripeInvoice.PeriodStart, 0), + }) + } + + if err = invoicesIterator.Err(); err != nil { + return nil, Error.Wrap(err) + } + + page.Next = invoicesIterator.Meta().HasMore || cursor.EndingBefore != "" + return page, nil +} + // ListWithDiscounts returns a list of invoices and coupon usages for a given payment account. func (invoices *invoices) ListWithDiscounts(ctx context.Context, userID uuid.UUID) (invoicesList []payments.Invoice, couponUsages []payments.CouponUsage, err error) { defer mon.Task()(&ctx, userID)(&err) @@ -410,6 +465,10 @@ func convertStatus(stripestatus stripe.InvoiceStatus) string { // isInvoiceFailed returns whether an invoice has failed. func (invoices *invoices) isInvoiceFailed(invoice *stripe.Invoice) bool { + if invoice.Status != stripe.InvoiceStatusOpen { + return false + } + if invoice.DueDate > 0 { // https://github.com/storj/storj/blob/77bf88e916a10dc898ebb594eafac667ed4426cd/satellite/payments/stripecoinpayments/service.go#L781-L787 invoices.service.log.Info("Skipping invoice marked for manual payment",