90e2e3c8c4
invoice item generation Part of https://storjlabs.atlassian.net/browse/USR-823 Change-Id: If0ffa56b68bb0a6b9b4e145fa21468006e7507f6
1097 lines
33 KiB
Go
1097 lines
33 KiB
Go
// Copyright (C) 2019 Storj Labs, Inc.
|
|
// See LICENSE for copying information.
|
|
|
|
package stripecoinpayments
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"math/big"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/shopspring/decimal"
|
|
"github.com/spacemonkeygo/monkit/v3"
|
|
"github.com/stripe/stripe-go"
|
|
"github.com/zeebo/errs"
|
|
"go.uber.org/zap"
|
|
|
|
"storj.io/common/memory"
|
|
"storj.io/common/uuid"
|
|
"storj.io/storj/satellite/accounting"
|
|
"storj.io/storj/satellite/console"
|
|
"storj.io/storj/satellite/payments"
|
|
"storj.io/storj/satellite/payments/coinpayments"
|
|
)
|
|
|
|
var (
|
|
// Error defines stripecoinpayments service error.
|
|
Error = errs.Class("stripecoinpayments service error")
|
|
// ErrNoCouponUsages indicates that there are no coupon usages.
|
|
ErrNoCouponUsages = errs.Class("stripecoinpayments no coupon usages")
|
|
|
|
mon = monkit.Package()
|
|
)
|
|
|
|
// hoursPerMonth is the number of months in a billing month. For the purpose of billing, the billing month is always 30 days.
|
|
const hoursPerMonth = 24 * 30
|
|
|
|
// Config stores needed information for payment service initialization.
|
|
type Config struct {
|
|
StripeSecretKey string `help:"stripe API secret key" default:""`
|
|
StripePublicKey string `help:"stripe API public key" default:""`
|
|
CoinpaymentsPublicKey string `help:"coinpayments API public key" default:""`
|
|
CoinpaymentsPrivateKey string `help:"coinpayments API private key key" default:""`
|
|
TransactionUpdateInterval time.Duration `help:"amount of time we wait before running next transaction update loop" default:"2m"`
|
|
AccountBalanceUpdateInterval time.Duration `help:"amount of time we wait before running next account balance update loop" default:"2m"`
|
|
ConversionRatesCycleInterval time.Duration `help:"amount of time we wait before running next conversion rates update loop" default:"10m"`
|
|
AutoAdvance bool `help:"toogle autoadvance feature for invoice creation" default:"false"`
|
|
ListingLimit int `help:"sets the maximum amount of items before we start paging on requests" default:"100" hidden:"true"`
|
|
}
|
|
|
|
// Service is an implementation for payment service via Stripe and Coinpayments.
|
|
//
|
|
// architecture: Service
|
|
type Service struct {
|
|
log *zap.Logger
|
|
db DB
|
|
projectsDB console.Projects
|
|
usageDB accounting.ProjectAccounting
|
|
stripeClient StripeClient
|
|
coinPayments *coinpayments.Client
|
|
|
|
StorageMBMonthPriceCents decimal.Decimal
|
|
EgressMBPriceCents decimal.Decimal
|
|
ObjectMonthPriceCents decimal.Decimal
|
|
// BonusRate amount of percents
|
|
BonusRate int64
|
|
// Coupon Values
|
|
CouponValue int64
|
|
CouponDuration int64
|
|
CouponProjectLimit memory.Size
|
|
// Minimum CoinPayment to create a coupon
|
|
MinCoinPayment int64
|
|
|
|
//Stripe Extended Features
|
|
AutoAdvance bool
|
|
|
|
mu sync.Mutex
|
|
rates coinpayments.CurrencyRateInfos
|
|
ratesErr error
|
|
|
|
listingLimit int
|
|
nowFn func() time.Time
|
|
}
|
|
|
|
// NewService creates a Service instance.
|
|
func NewService(log *zap.Logger, stripeClient StripeClient, config Config, db DB, projectsDB console.Projects, usageDB accounting.ProjectAccounting, storageTBPrice, egressTBPrice, objectPrice string, bonusRate, couponValue, couponDuration int64, couponProjectLimit memory.Size, minCoinPayment int64) (*Service, error) {
|
|
|
|
coinPaymentsClient := coinpayments.NewClient(
|
|
coinpayments.Credentials{
|
|
PublicKey: config.CoinpaymentsPublicKey,
|
|
PrivateKey: config.CoinpaymentsPrivateKey,
|
|
},
|
|
)
|
|
|
|
storageTBMonthDollars, err := decimal.NewFromString(storageTBPrice)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
egressTBDollars, err := decimal.NewFromString(egressTBPrice)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
objectMonthDollars, err := decimal.NewFromString(objectPrice)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// change the precision from TB dollars to MB cents
|
|
storageMBMonthPriceCents := storageTBMonthDollars.Shift(-6).Shift(2)
|
|
egressMBPriceCents := egressTBDollars.Shift(-6).Shift(2)
|
|
objectMonthPriceCents := objectMonthDollars.Shift(2)
|
|
|
|
return &Service{
|
|
log: log,
|
|
db: db,
|
|
projectsDB: projectsDB,
|
|
usageDB: usageDB,
|
|
stripeClient: stripeClient,
|
|
coinPayments: coinPaymentsClient,
|
|
StorageMBMonthPriceCents: storageMBMonthPriceCents,
|
|
EgressMBPriceCents: egressMBPriceCents,
|
|
ObjectMonthPriceCents: objectMonthPriceCents,
|
|
BonusRate: bonusRate,
|
|
CouponValue: couponValue,
|
|
CouponDuration: couponDuration,
|
|
CouponProjectLimit: couponProjectLimit,
|
|
MinCoinPayment: minCoinPayment,
|
|
AutoAdvance: config.AutoAdvance,
|
|
listingLimit: config.ListingLimit,
|
|
nowFn: time.Now,
|
|
}, nil
|
|
}
|
|
|
|
// Accounts exposes all needed functionality to manage payment accounts.
|
|
func (service *Service) Accounts() payments.Accounts {
|
|
return &accounts{service: service}
|
|
}
|
|
|
|
// updateTransactionsLoop updates all pending transactions in a loop.
|
|
func (service *Service) updateTransactionsLoop(ctx context.Context) (err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
before := service.nowFn()
|
|
|
|
txsPage, err := service.db.Transactions().ListPending(ctx, 0, service.listingLimit, before)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := service.updateTransactions(ctx, txsPage.IDList()); err != nil {
|
|
return err
|
|
}
|
|
|
|
for txsPage.Next {
|
|
if err = ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
txsPage, err = service.db.Transactions().ListPending(ctx, txsPage.NextOffset, service.listingLimit, before)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := service.updateTransactions(ctx, txsPage.IDList()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// updateTransactions updates statuses and received amount for given transactions.
|
|
func (service *Service) updateTransactions(ctx context.Context, ids TransactionAndUserList) (err error) {
|
|
defer mon.Task()(&ctx, ids)(&err)
|
|
|
|
if len(ids) == 0 {
|
|
service.log.Debug("no transactions found, skipping update")
|
|
return nil
|
|
}
|
|
|
|
infos, err := service.coinPayments.Transactions().ListInfos(ctx, ids.IDList())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var updates []TransactionUpdate
|
|
var applies coinpayments.TransactionIDList
|
|
|
|
for id, info := range infos {
|
|
updates = append(updates,
|
|
TransactionUpdate{
|
|
TransactionID: id,
|
|
Status: info.Status,
|
|
Received: info.Received,
|
|
},
|
|
)
|
|
|
|
// moment of transition to completed state, which indicates
|
|
// that customer funds were accepted and transferred to our
|
|
// account, so we can apply this amount to customer balance.
|
|
// Therefore, create intent to update customer balance in the future.
|
|
if info.Status == coinpayments.StatusCompleted {
|
|
applies = append(applies, id)
|
|
}
|
|
|
|
userID := ids[id]
|
|
|
|
rate, err := service.db.Transactions().GetLockedRate(ctx, id)
|
|
if err != nil {
|
|
service.log.Error(fmt.Sprintf("could not add promotional coupon for user %s", userID.String()), zap.Error(err))
|
|
continue
|
|
}
|
|
|
|
cents := convertToCents(rate, &info.Received)
|
|
|
|
if cents >= service.MinCoinPayment {
|
|
err = service.Accounts().Coupons().AddPromotionalCoupon(ctx, userID)
|
|
if err != nil {
|
|
service.log.Error(fmt.Sprintf("could not add promotional coupon for user %s", userID.String()), zap.Error(err))
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
return service.db.Transactions().Update(ctx, updates, applies)
|
|
}
|
|
|
|
// applyAccountBalanceLoop fetches all unapplied transaction in a loop, applying transaction
|
|
// received amount to stripe customer balance.
|
|
func (service *Service) updateAccountBalanceLoop(ctx context.Context) (err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
before := service.nowFn()
|
|
|
|
txsPage, err := service.db.Transactions().ListUnapplied(ctx, 0, service.listingLimit, before)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, tx := range txsPage.Transactions {
|
|
if err = ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = service.applyTransactionBalance(ctx, tx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for txsPage.Next {
|
|
if err = ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
txsPage, err = service.db.Transactions().ListUnapplied(ctx, txsPage.NextOffset, service.listingLimit, before)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, tx := range txsPage.Transactions {
|
|
if err = ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = service.applyTransactionBalance(ctx, tx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// applyTransactionBalance applies transaction received amount to stripe customer balance.
|
|
func (service *Service) applyTransactionBalance(ctx context.Context, tx Transaction) (err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
cusID, err := service.db.Customers().GetCustomerID(ctx, tx.AccountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rate, err := service.db.Transactions().GetLockedRate(ctx, tx.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cents := convertToCents(rate, &tx.Received)
|
|
|
|
if cents <= 0 {
|
|
service.log.Warn("Trying to deposit non-positive amount.",
|
|
zap.Int64("USD cents", cents),
|
|
zap.Stringer("Transaction ID", tx.ID),
|
|
zap.Stringer("User ID", tx.AccountID),
|
|
)
|
|
return service.db.Transactions().Consume(ctx, tx.ID)
|
|
}
|
|
|
|
// Check for balance transactions created from previous failed attempt
|
|
var depositDone, bonusDone bool
|
|
it := service.stripeClient.CustomerBalanceTransactions().List(&stripe.CustomerBalanceTransactionListParams{Customer: stripe.String(cusID)})
|
|
for it.Next() {
|
|
cbt := it.CustomerBalanceTransaction()
|
|
|
|
if cbt.Type != stripe.CustomerBalanceTransactionTypeAdjustment {
|
|
continue
|
|
}
|
|
|
|
txID, ok := cbt.Metadata["txID"]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if txID != tx.ID.String() {
|
|
continue
|
|
}
|
|
|
|
switch cbt.Description {
|
|
case StripeDepositTransactionDescription:
|
|
depositDone = true
|
|
case StripeDepositBonusTransactionDescription:
|
|
bonusDone = true
|
|
}
|
|
}
|
|
|
|
// The first balance transaction is for the actual deposit
|
|
if !depositDone {
|
|
params := &stripe.CustomerBalanceTransactionParams{
|
|
Amount: stripe.Int64(-cents),
|
|
Customer: stripe.String(cusID),
|
|
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
|
Description: stripe.String(StripeDepositTransactionDescription),
|
|
}
|
|
params.AddMetadata("txID", tx.ID.String())
|
|
_, err = service.stripeClient.CustomerBalanceTransactions().New(params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// The second balance transaction for the bonus
|
|
if !bonusDone {
|
|
params := &stripe.CustomerBalanceTransactionParams{
|
|
Amount: stripe.Int64(-cents * service.BonusRate / 100),
|
|
Customer: stripe.String(cusID),
|
|
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
|
Description: stripe.String(StripeDepositBonusTransactionDescription),
|
|
}
|
|
params.AddMetadata("txID", tx.ID.String())
|
|
params.AddMetadata("percentage", strconv.Itoa(int(service.BonusRate)))
|
|
_, err = service.stripeClient.CustomerBalanceTransactions().New(params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return service.db.Transactions().Consume(ctx, tx.ID)
|
|
}
|
|
|
|
// UpdateRates fetches new rates and updates service rate cache.
|
|
func (service *Service) UpdateRates(ctx context.Context) (err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
rates, err := service.coinPayments.ConversionRates().Get(ctx)
|
|
if coinpayments.ErrMissingPublicKey.Has(err) {
|
|
rates = coinpayments.CurrencyRateInfos{}
|
|
err = nil
|
|
|
|
service.log.Info("Coinpayment client is missing public key")
|
|
}
|
|
|
|
service.mu.Lock()
|
|
defer service.mu.Unlock()
|
|
|
|
service.rates = rates
|
|
service.ratesErr = err
|
|
|
|
return err
|
|
}
|
|
|
|
// GetRate returns conversion rate for specified currencies.
|
|
func (service *Service) GetRate(ctx context.Context, curr1, curr2 coinpayments.Currency) (_ *big.Float, err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
service.mu.Lock()
|
|
defer service.mu.Unlock()
|
|
|
|
if service.ratesErr != nil {
|
|
return nil, Error.Wrap(err)
|
|
}
|
|
|
|
info1, ok := service.rates[curr1]
|
|
if !ok {
|
|
return nil, Error.New("no rate for currency %s", curr1)
|
|
}
|
|
info2, ok := service.rates[curr2]
|
|
if !ok {
|
|
return nil, Error.New("no rate for currency %s", curr2)
|
|
}
|
|
|
|
return new(big.Float).Quo(&info1.RateBTC, &info2.RateBTC), nil
|
|
}
|
|
|
|
// PrepareInvoiceProjectRecords iterates through all projects and creates invoice records if
|
|
// none exists.
|
|
func (service *Service) PrepareInvoiceProjectRecords(ctx context.Context, period time.Time) (err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
now := service.nowFn().UTC()
|
|
utc := period.UTC()
|
|
|
|
start := time.Date(utc.Year(), utc.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
end := time.Date(utc.Year(), utc.Month()+1, 0, 0, 0, 0, 0, time.UTC)
|
|
|
|
if end.After(now) {
|
|
return Error.New("allowed for past periods only")
|
|
}
|
|
|
|
var numberOfCustomers, numberOfRecords, numberOfCouponsUsages int
|
|
customersPage, err := service.db.Customers().List(ctx, 0, service.listingLimit, end)
|
|
if err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
numberOfCustomers += len(customersPage.Customers)
|
|
|
|
records, usages, err := service.processCustomers(ctx, customersPage.Customers, start, end)
|
|
if err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
numberOfRecords += records
|
|
numberOfCouponsUsages += usages
|
|
|
|
for customersPage.Next {
|
|
if err = ctx.Err(); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
customersPage, err = service.db.Customers().List(ctx, customersPage.NextOffset, service.listingLimit, end)
|
|
if err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
records, usages, err := service.processCustomers(ctx, customersPage.Customers, start, end)
|
|
if err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
numberOfRecords += records
|
|
numberOfCouponsUsages += usages
|
|
}
|
|
|
|
service.log.Info("Number of processed entries.", zap.Int("Customers", numberOfCustomers), zap.Int("Projects", numberOfRecords), zap.Int("Coupons Usages", numberOfCouponsUsages))
|
|
return nil
|
|
}
|
|
|
|
func (service *Service) processCustomers(ctx context.Context, customers []Customer, start, end time.Time) (int, int, error) {
|
|
var allRecords []CreateProjectRecord
|
|
var usages []CouponUsage
|
|
var creditsSpendings []CreditsSpending
|
|
for _, customer := range customers {
|
|
projects, err := service.projectsDB.GetOwn(ctx, customer.UserID)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
leftToCharge, records, err := service.createProjectRecords(ctx, customer.ID, projects, start, end)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
allRecords = append(allRecords, records...)
|
|
|
|
coupons, err := service.db.Coupons().ListByUserIDAndStatus(ctx, customer.UserID, payments.CouponActive)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
// Apply any promotional credits (a.k.a. coupons) on the remainder.
|
|
for _, coupon := range coupons {
|
|
if coupon.Status == payments.CouponExpired {
|
|
// this coupon has already been marked as expired.
|
|
continue
|
|
}
|
|
|
|
if end.After(coupon.ExpirationDate()) {
|
|
// this coupon is identified as expired for first time, mark it in the database
|
|
if _, err = service.db.Coupons().Update(ctx, coupon.ID, payments.CouponExpired); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
continue
|
|
}
|
|
|
|
alreadyChargedAmount, err := service.db.Coupons().TotalUsage(ctx, coupon.ID)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
remaining := coupon.Amount - alreadyChargedAmount
|
|
|
|
amountToChargeFromCoupon := leftToCharge
|
|
if amountToChargeFromCoupon >= remaining {
|
|
amountToChargeFromCoupon = remaining
|
|
}
|
|
|
|
if amountToChargeFromCoupon > 0 {
|
|
usages = append(usages, CouponUsage{
|
|
Period: start,
|
|
Amount: amountToChargeFromCoupon,
|
|
Status: CouponUsageStatusUnapplied,
|
|
CouponID: coupon.ID,
|
|
})
|
|
|
|
leftToCharge -= amountToChargeFromCoupon
|
|
}
|
|
|
|
if amountToChargeFromCoupon < remaining && end.Equal(coupon.ExpirationDate()) {
|
|
// the coupon was not fully spent, but this is the last month
|
|
// it is valid for, so mark it as expired in database
|
|
if _, err = service.db.Coupons().Update(ctx, coupon.ID, payments.CouponExpired); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
}
|
|
}
|
|
|
|
if leftToCharge == 0 {
|
|
continue
|
|
}
|
|
|
|
// Last, apply any credits from STORJ deposit bonuses on the remainder.
|
|
userBonuses, err := service.db.Credits().Balance(ctx, customer.UserID)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
if userBonuses > 0 {
|
|
amountChargedFromBonuses := leftToCharge
|
|
if amountChargedFromBonuses >= userBonuses {
|
|
amountChargedFromBonuses = userBonuses
|
|
}
|
|
|
|
creditSpendingID, err := uuid.New()
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
if amountChargedFromBonuses > 0 {
|
|
creditsSpendings = append(creditsSpendings, CreditsSpending{
|
|
ID: creditSpendingID,
|
|
Amount: amountChargedFromBonuses,
|
|
UserID: customer.UserID,
|
|
Status: CreditsSpendingStatusUnapplied,
|
|
Period: start,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return len(allRecords), len(usages), service.db.ProjectRecords().Create(ctx, allRecords, usages, creditsSpendings, start, end)
|
|
}
|
|
|
|
// createProjectRecords creates invoice project record if none exists.
|
|
func (service *Service) createProjectRecords(ctx context.Context, customerID string, projects []console.Project, start, end time.Time) (_ int64, _ []CreateProjectRecord, err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
var records []CreateProjectRecord
|
|
sumLeftToCharge := int64(0)
|
|
for _, project := range projects {
|
|
if err = ctx.Err(); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
|
|
if err = service.db.ProjectRecords().Check(ctx, project.ID, start, end); err != nil {
|
|
if err == ErrProjectRecordExists {
|
|
service.log.Warn("Record for this project already exists.", zap.String("Customer ID", customerID), zap.String("Project ID", project.ID.String()))
|
|
continue
|
|
}
|
|
|
|
return 0, nil, err
|
|
}
|
|
|
|
usage, err := service.usageDB.GetProjectTotal(ctx, project.ID, start, end)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
|
|
// TODO: account for usage data.
|
|
records = append(records,
|
|
CreateProjectRecord{
|
|
ProjectID: project.ID,
|
|
Storage: usage.Storage,
|
|
Egress: usage.Egress,
|
|
Objects: usage.ObjectCount,
|
|
},
|
|
)
|
|
|
|
leftToCharge := service.calculateProjectUsagePrice(usage.Egress, usage.Storage, usage.ObjectCount).TotalInt64()
|
|
if leftToCharge == 0 {
|
|
continue
|
|
}
|
|
|
|
// If there is a Stripe coupon applied for the project owner, apply its
|
|
// discount first before applying other credits of this user. This
|
|
// avoids the issue with negative totals in invoices.
|
|
leftToCharge, err = service.discountedProjectUsagePrice(ctx, customerID, leftToCharge)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
|
|
sumLeftToCharge += leftToCharge
|
|
}
|
|
|
|
return sumLeftToCharge, records, nil
|
|
}
|
|
|
|
// InvoiceApplyProjectRecords iterates through unapplied invoice project records and creates invoice line items
|
|
// for stripe customer.
|
|
func (service *Service) InvoiceApplyProjectRecords(ctx context.Context, period time.Time) (err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
now := service.nowFn().UTC()
|
|
utc := period.UTC()
|
|
|
|
start := time.Date(utc.Year(), utc.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
end := time.Date(utc.Year(), utc.Month()+1, 0, 0, 0, 0, 0, time.UTC)
|
|
|
|
if end.After(now) {
|
|
return Error.New("allowed for past periods only")
|
|
}
|
|
|
|
recordsPage, err := service.db.ProjectRecords().ListUnapplied(ctx, 0, service.listingLimit, start, end)
|
|
if err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
if err = service.applyProjectRecords(ctx, recordsPage.Records); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
for recordsPage.Next {
|
|
if err = ctx.Err(); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
// we are always starting from offset 0 because applyProjectRecords is changing project record state to applied
|
|
recordsPage, err = service.db.ProjectRecords().ListUnapplied(ctx, 0, service.listingLimit, start, end)
|
|
if err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
if err = service.applyProjectRecords(ctx, recordsPage.Records); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// applyProjectRecords applies invoice intents as invoice line items to stripe customer.
|
|
func (service *Service) applyProjectRecords(ctx context.Context, records []ProjectRecord) (err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
for _, record := range records {
|
|
if err = ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
proj, err := service.projectsDB.Get(ctx, record.ProjectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cusID, err := service.db.Customers().GetCustomerID(ctx, proj.OwnerID)
|
|
if err != nil {
|
|
if err == ErrNoCustomer {
|
|
service.log.Warn("Stripe customer does not exist for project owner.", zap.Stringer("Owner ID", proj.OwnerID), zap.Stringer("Project ID", proj.ID))
|
|
continue
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
if err = service.createInvoiceItems(ctx, cusID, proj.Name, record); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createInvoiceItems consumes invoice project record and creates invoice line items for stripe customer.
|
|
func (service *Service) createInvoiceItems(ctx context.Context, cusID, projName string, record ProjectRecord) (err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
if err = service.db.ProjectRecords().Consume(ctx, record.ID); err != nil {
|
|
return err
|
|
}
|
|
|
|
projectItem := &stripe.InvoiceItemParams{
|
|
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
|
Customer: stripe.String(cusID),
|
|
}
|
|
projectItem.AddMetadata("projectID", record.ProjectID.String())
|
|
|
|
projectItem.Description = stripe.String(fmt.Sprintf("Project %s - Object Storage (MB-Month)", projName))
|
|
projectItem.Quantity = stripe.Int64(storageMBMonthDecimal(record.Storage).IntPart())
|
|
storagePrice, _ := service.StorageMBMonthPriceCents.Float64()
|
|
projectItem.UnitAmountDecimal = stripe.Float64(storagePrice)
|
|
_, err = service.stripeClient.InvoiceItems().New(projectItem)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
projectItem.Description = stripe.String(fmt.Sprintf("Project %s - Egress Bandwidth (MB)", projName))
|
|
projectItem.Quantity = stripe.Int64(egressMBDecimal(record.Egress).IntPart())
|
|
egressPrice, _ := service.EgressMBPriceCents.Float64()
|
|
projectItem.UnitAmountDecimal = stripe.Float64(egressPrice)
|
|
_, err = service.stripeClient.InvoiceItems().New(projectItem)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
projectItem.Description = stripe.String(fmt.Sprintf("Project %s - Object Fee (Object-Month)", projName))
|
|
projectItem.Quantity = stripe.Int64(objectMonthDecimal(record.Objects).IntPart())
|
|
objectPrice, _ := service.ObjectMonthPriceCents.Float64()
|
|
projectItem.UnitAmountDecimal = stripe.Float64(objectPrice)
|
|
_, err = service.stripeClient.InvoiceItems().New(projectItem)
|
|
return err
|
|
}
|
|
|
|
// InvoiceApplyCoupons iterates through unapplied project coupons and creates invoice line items
|
|
// for stripe customer.
|
|
func (service *Service) InvoiceApplyCoupons(ctx context.Context, period time.Time) (err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
now := service.nowFn().UTC()
|
|
utc := period.UTC()
|
|
|
|
start := time.Date(utc.Year(), utc.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
end := time.Date(utc.Year(), utc.Month()+1, 0, 0, 0, 0, 0, time.UTC)
|
|
|
|
if end.After(now) {
|
|
return Error.New("allowed for past periods only")
|
|
}
|
|
|
|
usagePage, err := service.db.Coupons().ListUnapplied(ctx, 0, service.listingLimit, start)
|
|
if err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
if err = service.applyCoupons(ctx, usagePage.Usages); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
for usagePage.Next {
|
|
if err = ctx.Err(); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
// we are always starting from offset 0 because applyCoupons is changing coupon usage state to applied
|
|
usagePage, err = service.db.Coupons().ListUnapplied(ctx, 0, service.listingLimit, start)
|
|
if err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
if err = service.applyCoupons(ctx, usagePage.Usages); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// applyCoupons applies concrete coupon usage as invoice line item.
|
|
func (service *Service) applyCoupons(ctx context.Context, usages []CouponUsage) (err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
for _, usage := range usages {
|
|
if err = ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
coupon, err := service.db.Coupons().Get(ctx, usage.CouponID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
customerID, err := service.db.Customers().GetCustomerID(ctx, coupon.UserID)
|
|
if err != nil {
|
|
if err == ErrNoCustomer {
|
|
service.log.Warn("Stripe customer does not exist for coupon owner.", zap.Stringer("User ID", coupon.UserID), zap.Stringer("Coupon ID", coupon.ID))
|
|
continue
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
if err = service.createInvoiceCouponItems(ctx, coupon, usage, customerID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createInvoiceCouponItems consumes invoice project record and creates invoice line items for stripe customer.
|
|
func (service *Service) createInvoiceCouponItems(ctx context.Context, coupon payments.Coupon, usage CouponUsage, customerID string) (err error) {
|
|
defer mon.Task()(&ctx, customerID, coupon)(&err)
|
|
|
|
err = service.db.Coupons().ApplyUsage(ctx, usage.CouponID, usage.Period)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
totalUsage, err := service.db.Coupons().TotalUsage(ctx, coupon.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if totalUsage == coupon.Amount {
|
|
_, err = service.db.Coupons().Update(ctx, coupon.ID, payments.CouponUsed)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
projectItem := &stripe.InvoiceItemParams{
|
|
Amount: stripe.Int64(-usage.Amount),
|
|
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
|
Customer: stripe.String(customerID),
|
|
Description: stripe.String(coupon.Description),
|
|
}
|
|
|
|
projectItem.AddMetadata("couponID", coupon.ID.String())
|
|
|
|
_, err = service.stripeClient.InvoiceItems().New(projectItem)
|
|
|
|
return err
|
|
}
|
|
|
|
// InvoiceApplyCredits iterates through credits with status false of project and creates invoice line items
|
|
// for stripe customer.
|
|
func (service *Service) InvoiceApplyCredits(ctx context.Context, period time.Time) (err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
now := service.nowFn().UTC()
|
|
utc := period.UTC()
|
|
|
|
start := time.Date(utc.Year(), utc.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
end := time.Date(utc.Year(), utc.Month()+1, 0, 0, 0, 0, 0, time.UTC)
|
|
|
|
if end.After(now) {
|
|
return Error.New("allowed for past periods only")
|
|
}
|
|
|
|
spendingsPage, err := service.db.Credits().ListCreditsSpendingsPaged(ctx, int(CreditsSpendingStatusUnapplied), 0, service.listingLimit, start)
|
|
if err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
if err = service.applySpendings(ctx, spendingsPage.Spendings); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
for spendingsPage.Next {
|
|
if err = ctx.Err(); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
// we are always starting from offset 0 because applySpendings is changing credits spendings state to applied
|
|
spendingsPage, err = service.db.Credits().ListCreditsSpendingsPaged(ctx, int(CreditsSpendingStatusUnapplied), 0, service.listingLimit, start)
|
|
if err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
if err = service.applySpendings(ctx, spendingsPage.Spendings); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// applyCredits applies concrete spending as invoice line item.
|
|
func (service *Service) applySpendings(ctx context.Context, spendings []CreditsSpending) (err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
for _, spending := range spendings {
|
|
if err = ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = service.createInvoiceCreditItem(ctx, spending); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createInvoiceCreditItem consumes invoice project record and creates invoice line items for stripe customer.
|
|
func (service *Service) createInvoiceCreditItem(ctx context.Context, spending CreditsSpending) (err error) {
|
|
defer mon.Task()(&ctx, spending)(&err)
|
|
|
|
err = service.db.Credits().ApplyCreditsSpending(ctx, spending.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
customerID, err := service.db.Customers().GetCustomerID(ctx, spending.UserID)
|
|
|
|
projectItem := &stripe.InvoiceItemParams{
|
|
Amount: stripe.Int64(-spending.Amount),
|
|
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
|
Customer: stripe.String(customerID),
|
|
Description: stripe.String("Credits from STORJ deposit bonus"),
|
|
}
|
|
|
|
projectItem.AddMetadata("projectID", spending.ProjectID.String())
|
|
projectItem.AddMetadata("userID", spending.UserID.String())
|
|
|
|
_, err = service.stripeClient.InvoiceItems().New(projectItem)
|
|
|
|
return err
|
|
}
|
|
|
|
// CreateInvoices lists through all customers and creates invoices.
|
|
func (service *Service) CreateInvoices(ctx context.Context, period time.Time) (err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
now := service.nowFn().UTC()
|
|
utc := period.UTC()
|
|
|
|
start := time.Date(utc.Year(), utc.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
end := time.Date(utc.Year(), utc.Month()+1, 0, 0, 0, 0, 0, time.UTC)
|
|
|
|
if end.After(now) {
|
|
return Error.New("allowed for past periods only")
|
|
}
|
|
|
|
cusPage, err := service.db.Customers().List(ctx, 0, service.listingLimit, end)
|
|
if err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
for _, cus := range cusPage.Customers {
|
|
if err = ctx.Err(); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
if err = service.createInvoice(ctx, cus.ID, start); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
}
|
|
|
|
for cusPage.Next {
|
|
if err = ctx.Err(); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
cusPage, err = service.db.Customers().List(ctx, cusPage.NextOffset, service.listingLimit, end)
|
|
if err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
for _, cus := range cusPage.Customers {
|
|
if err = ctx.Err(); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
|
|
if err = service.createInvoice(ctx, cus.ID, start); err != nil {
|
|
return Error.Wrap(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createInvoice creates invoice for stripe customer. Returns nil error if there are no
|
|
// pending invoice line items for customer.
|
|
func (service *Service) createInvoice(ctx context.Context, cusID string, period time.Time) (err error) {
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
description := fmt.Sprintf("Tardigrade Cloud Storage for %s %d", period.Month(), period.Year())
|
|
|
|
_, err = service.stripeClient.Invoices().New(
|
|
&stripe.InvoiceParams{
|
|
Customer: stripe.String(cusID),
|
|
AutoAdvance: stripe.Bool(service.AutoAdvance),
|
|
Description: stripe.String(description),
|
|
},
|
|
)
|
|
|
|
if err != nil {
|
|
if stripeErr, ok := err.(*stripe.Error); ok {
|
|
switch stripeErr.Code {
|
|
case stripe.ErrorCodeInvoiceNoCustomerLineItems:
|
|
return nil
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// projectUsagePrice represents pricing for project usage.
|
|
type projectUsagePrice struct {
|
|
Storage decimal.Decimal
|
|
Egress decimal.Decimal
|
|
Objects decimal.Decimal
|
|
}
|
|
|
|
// Total returns project usage price total.
|
|
func (price projectUsagePrice) Total() decimal.Decimal {
|
|
return price.Storage.Add(price.Egress).Add(price.Objects)
|
|
}
|
|
|
|
// Total returns project usage price total.
|
|
func (price projectUsagePrice) TotalInt64() int64 {
|
|
return price.Storage.Add(price.Egress).Add(price.Objects).IntPart()
|
|
}
|
|
|
|
// calculateProjectUsagePrice calculate project usage price.
|
|
func (service *Service) calculateProjectUsagePrice(egress int64, storage, objects float64) projectUsagePrice {
|
|
return projectUsagePrice{
|
|
Storage: service.StorageMBMonthPriceCents.Mul(storageMBMonthDecimal(storage)).Round(0),
|
|
Egress: service.EgressMBPriceCents.Mul(egressMBDecimal(egress)).Round(0),
|
|
Objects: service.ObjectMonthPriceCents.Mul(objectMonthDecimal(objects)).Round(0),
|
|
}
|
|
}
|
|
|
|
// discountedProjectUsagePrice reduces the project usage price with the discount applied for the Stripe customer.
|
|
// The promotional coupons and bonus credits are not applied yet.
|
|
func (service *Service) discountedProjectUsagePrice(ctx context.Context, customerID string, projectUsagePrice int64) (int64, error) {
|
|
customer, err := service.stripeClient.Customers().Get(customerID, nil)
|
|
if err != nil {
|
|
return 0, Error.Wrap(err)
|
|
}
|
|
|
|
if customer.Discount == nil {
|
|
return projectUsagePrice, nil
|
|
}
|
|
|
|
coupon := customer.Discount.Coupon
|
|
|
|
if coupon == nil {
|
|
return projectUsagePrice, nil
|
|
}
|
|
|
|
if !coupon.Valid {
|
|
return projectUsagePrice, nil
|
|
}
|
|
|
|
if coupon.AmountOff > 0 {
|
|
service.log.Info("Applying Stripe discount.", zap.String("Customer ID", customerID), zap.Int64("AmountOff", coupon.AmountOff))
|
|
|
|
discounted := projectUsagePrice - coupon.AmountOff
|
|
if discounted < 0 {
|
|
return 0, nil
|
|
}
|
|
return discounted, nil
|
|
}
|
|
|
|
if coupon.PercentOff > 0 {
|
|
service.log.Info("Applying Stripe discount.", zap.String("Customer ID", customerID), zap.Float64("PercentOff", coupon.PercentOff))
|
|
|
|
discount := int64(math.Round(float64(projectUsagePrice) * coupon.PercentOff / 100))
|
|
return projectUsagePrice - discount, nil
|
|
}
|
|
|
|
return projectUsagePrice, nil
|
|
}
|
|
|
|
// SetNow allows tests to have the Service act as if the current time is whatever
|
|
// they want. This avoids races and sleeping, making tests more reliable and efficient.
|
|
func (service *Service) SetNow(now func() time.Time) {
|
|
service.nowFn = now
|
|
}
|
|
|
|
// storageMBMonthDecimal converts storage usage from Byte-Hours to Megabyte-Months.
|
|
// The result is rounded to the nearest whole number, but returned as Decimal for convenience.
|
|
func storageMBMonthDecimal(storage float64) decimal.Decimal {
|
|
return decimal.NewFromFloat(storage).Shift(-6).Div(decimal.NewFromInt(hoursPerMonth)).Round(0)
|
|
}
|
|
|
|
// egressMBDecimal converts egress usage from bytes to Megabytes
|
|
// The result is rounded to the nearest whole number, but returned as Decimal for convenience.
|
|
func egressMBDecimal(egress int64) decimal.Decimal {
|
|
return decimal.NewFromInt(egress).Shift(-6).Round(0)
|
|
}
|
|
|
|
// objectMonthDecimal converts objects usage from Object-Hours to Object-Months.
|
|
// The result is rounded to the nearest whole number, but returned as Decimal for convenience.
|
|
func objectMonthDecimal(objects float64) decimal.Decimal {
|
|
return decimal.NewFromFloat(objects).Div(decimal.NewFromInt(hoursPerMonth)).Round(0)
|
|
}
|