e2cf486fcb
Purchase collects a payment from user using the specified price, description, and payment method. This is implemented at the lower layers using the payments.Invoices interface. There are also additions to stripecoinpayments stripemock to simulate errors returned from stripe invoices New and Pay. If an invoice exists with the same description, if it is a draft, it is deleted and a new one is created and paid. If it is open, pay it and don't create a new one. Change-Id: Ic3147434bc44a0777ecbedb3a4ed4c768eb02ea3
293 lines
8.2 KiB
Go
293 lines
8.2 KiB
Go
// Copyright (C) 2019 Storj Labs, Inc.
|
|
// See LICENSE for copying information.
|
|
|
|
package stripecoinpayments
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/stripe/stripe-go/v72"
|
|
"github.com/zeebo/errs"
|
|
|
|
"storj.io/common/uuid"
|
|
"storj.io/storj/satellite/payments"
|
|
)
|
|
|
|
// invoices is an implementation of payments.Invoices.
|
|
//
|
|
// architecture: Service
|
|
type invoices struct {
|
|
service *Service
|
|
}
|
|
|
|
func (invoices *invoices) Create(ctx context.Context, userID uuid.UUID, price int64, desc string) (*payments.Invoice, error) {
|
|
customerID, err := invoices.service.db.Customers().GetCustomerID(ctx, userID)
|
|
if err != nil {
|
|
return nil, Error.Wrap(err)
|
|
}
|
|
|
|
inv, err := invoices.service.stripeClient.Invoices().New(&stripe.InvoiceParams{
|
|
Customer: stripe.String(customerID),
|
|
Discounts: []*stripe.InvoiceDiscountParams{},
|
|
Description: stripe.String(desc),
|
|
PendingInvoiceItemsBehavior: stripe.String("exclude"),
|
|
})
|
|
if err != nil {
|
|
return nil, Error.Wrap(err)
|
|
}
|
|
|
|
item, err := invoices.service.stripeClient.InvoiceItems().New(&stripe.InvoiceItemParams{
|
|
Customer: stripe.String(customerID),
|
|
Amount: stripe.Int64(price),
|
|
Description: stripe.String(desc),
|
|
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
|
Invoice: stripe.String(inv.ID),
|
|
})
|
|
if err != nil {
|
|
return nil, Error.Wrap(err)
|
|
}
|
|
|
|
return &payments.Invoice{
|
|
ID: inv.ID,
|
|
Description: inv.Description,
|
|
Amount: item.Amount,
|
|
Status: string(inv.Status),
|
|
}, nil
|
|
}
|
|
|
|
func (invoices *invoices) Pay(ctx context.Context, invoiceID, paymentMethodID string) (*payments.Invoice, error) {
|
|
inv, err := invoices.service.stripeClient.Invoices().Pay(invoiceID, &stripe.InvoicePayParams{
|
|
PaymentMethod: stripe.String(paymentMethodID),
|
|
})
|
|
if err != nil {
|
|
return nil, Error.Wrap(err)
|
|
}
|
|
return &payments.Invoice{
|
|
ID: inv.ID,
|
|
Description: inv.Description,
|
|
Amount: inv.AmountPaid,
|
|
Status: string(inv.Status),
|
|
}, nil
|
|
}
|
|
|
|
// AttemptPayOverdueInvoices attempts to pay a user's open, overdue invoices.
|
|
func (invoices *invoices) AttemptPayOverdueInvoices(ctx context.Context, userID uuid.UUID) (err error) {
|
|
customerID, err := invoices.service.db.Customers().GetCustomerID(ctx, userID)
|
|
if err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
params := &stripe.InvoiceListParams{
|
|
Customer: &customerID,
|
|
Status: stripe.String(string(stripe.InvoiceStatusOpen)),
|
|
DueDateRange: &stripe.RangeQueryParams{LesserThan: time.Now().Unix()},
|
|
}
|
|
|
|
var errGrp errs.Group
|
|
|
|
invoicesIterator := invoices.service.stripeClient.Invoices().List(params)
|
|
for invoicesIterator.Next() {
|
|
stripeInvoice := invoicesIterator.Invoice()
|
|
|
|
params := &stripe.InvoicePayParams{}
|
|
invResponse, err := invoices.service.stripeClient.Invoices().Pay(stripeInvoice.ID, params)
|
|
if err != nil {
|
|
errGrp.Add(Error.New("unable to pay invoice %s: %w", stripeInvoice.ID, err))
|
|
continue
|
|
}
|
|
|
|
if invResponse != nil && invResponse.Status != stripe.InvoiceStatusPaid {
|
|
errGrp.Add(Error.New("invoice not paid after payment triggered %s", stripeInvoice.ID))
|
|
}
|
|
|
|
}
|
|
|
|
if err = invoicesIterator.Err(); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
return errGrp.Err()
|
|
}
|
|
|
|
// List returns a list of invoices for a given payment account.
|
|
func (invoices *invoices) List(ctx context.Context, userID uuid.UUID) (invoicesList []payments.Invoice, err error) {
|
|
defer mon.Task()(&ctx, userID)(&err)
|
|
|
|
customerID, err := invoices.service.db.Customers().GetCustomerID(ctx, userID)
|
|
if err != nil {
|
|
return nil, Error.Wrap(err)
|
|
}
|
|
|
|
params := &stripe.InvoiceListParams{
|
|
Customer: &customerID,
|
|
}
|
|
|
|
invoicesIterator := invoices.service.stripeClient.Invoices().List(params)
|
|
for invoicesIterator.Next() {
|
|
stripeInvoice := invoicesIterator.Invoice()
|
|
|
|
total := stripeInvoice.Total
|
|
if stripeInvoice.Lines != nil {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
invoicesList = append(invoicesList, payments.Invoice{
|
|
ID: stripeInvoice.ID,
|
|
Description: stripeInvoice.Description,
|
|
Amount: total,
|
|
Status: convertStatus(stripeInvoice.Status),
|
|
Link: stripeInvoice.InvoicePDF,
|
|
Start: time.Unix(stripeInvoice.PeriodStart, 0),
|
|
})
|
|
}
|
|
|
|
if err = invoicesIterator.Err(); err != nil {
|
|
return nil, Error.Wrap(err)
|
|
}
|
|
|
|
return invoicesList, 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)
|
|
|
|
customerID, err := invoices.service.db.Customers().GetCustomerID(ctx, userID)
|
|
if err != nil {
|
|
return nil, nil, Error.Wrap(err)
|
|
}
|
|
|
|
params := &stripe.InvoiceListParams{
|
|
Customer: &customerID,
|
|
}
|
|
params.AddExpand("data.total_discount_amounts.discount")
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
invoicesList = append(invoicesList, payments.Invoice{
|
|
ID: stripeInvoice.ID,
|
|
Description: stripeInvoice.Description,
|
|
Amount: total,
|
|
Status: convertStatus(stripeInvoice.Status),
|
|
Link: stripeInvoice.InvoicePDF,
|
|
Start: time.Unix(stripeInvoice.PeriodStart, 0),
|
|
})
|
|
|
|
for _, dcAmt := range stripeInvoice.TotalDiscountAmounts {
|
|
if dcAmt == nil {
|
|
return nil, nil, Error.New("discount amount is nil")
|
|
}
|
|
|
|
dc := dcAmt.Discount
|
|
|
|
coupon, err := stripeDiscountToPaymentsCoupon(dc)
|
|
if err != nil {
|
|
return nil, nil, Error.Wrap(err)
|
|
}
|
|
|
|
usage := payments.CouponUsage{
|
|
Coupon: *coupon,
|
|
Amount: dcAmt.Amount,
|
|
PeriodStart: time.Unix(stripeInvoice.PeriodStart, 0),
|
|
PeriodEnd: time.Unix(stripeInvoice.PeriodEnd, 0),
|
|
}
|
|
|
|
if dc.PromotionCode != nil {
|
|
usage.Coupon.PromoCode = dc.PromotionCode.Code
|
|
}
|
|
|
|
couponUsages = append(couponUsages, usage)
|
|
}
|
|
}
|
|
|
|
if err = invoicesIterator.Err(); err != nil {
|
|
return nil, nil, Error.Wrap(err)
|
|
}
|
|
|
|
return invoicesList, couponUsages, nil
|
|
}
|
|
|
|
// CheckPendingItems returns if pending invoice items for a given payment account exist.
|
|
func (invoices *invoices) CheckPendingItems(ctx context.Context, userID uuid.UUID) (existingItems bool, err error) {
|
|
defer mon.Task()(&ctx, userID)(&err)
|
|
|
|
customerID, err := invoices.service.db.Customers().GetCustomerID(ctx, userID)
|
|
if err != nil {
|
|
return false, Error.Wrap(err)
|
|
}
|
|
|
|
params := &stripe.InvoiceItemListParams{
|
|
Customer: &customerID,
|
|
Pending: stripe.Bool(true),
|
|
}
|
|
|
|
itemIterator := invoices.service.stripeClient.InvoiceItems().List(params)
|
|
for itemIterator.Next() {
|
|
item := itemIterator.InvoiceItem()
|
|
if item != nil {
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
if err = itemIterator.Err(); err != nil {
|
|
return false, Error.Wrap(err)
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
// Delete a draft invoice.
|
|
func (invoices *invoices) Delete(ctx context.Context, id string) (_ *payments.Invoice, err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
stripeInvoice, err := invoices.service.stripeClient.Invoices().Del(id, &stripe.InvoiceParams{})
|
|
if err != nil {
|
|
return nil, Error.Wrap(err)
|
|
}
|
|
return &payments.Invoice{
|
|
ID: stripeInvoice.ID,
|
|
Description: stripeInvoice.Description,
|
|
Amount: stripeInvoice.AmountDue,
|
|
Status: convertStatus(stripeInvoice.Status),
|
|
Link: stripeInvoice.InvoicePDF,
|
|
Start: time.Unix(stripeInvoice.PeriodStart, 0),
|
|
}, nil
|
|
}
|
|
|
|
func convertStatus(stripestatus stripe.InvoiceStatus) string {
|
|
var status string
|
|
switch stripestatus {
|
|
case stripe.InvoiceStatusDraft:
|
|
status = payments.InvoiceStatusDraft
|
|
case stripe.InvoiceStatusOpen:
|
|
status = payments.InvoiceStatusOpen
|
|
case stripe.InvoiceStatusPaid:
|
|
status = payments.InvoiceStatusPaid
|
|
case stripe.InvoiceStatusUncollectible:
|
|
status = payments.InvoiceStatusUncollectible
|
|
case stripe.InvoiceStatusVoid:
|
|
status = payments.InvoiceStatusVoid
|
|
default:
|
|
status = string(stripestatus)
|
|
}
|
|
return status
|
|
}
|