2019-10-10 18:12:23 +01:00
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package stripecoinpayments
import (
2019-10-23 13:04:54 +01:00
"context"
2019-11-05 13:16:02 +00:00
"fmt"
2020-05-08 17:04:04 +01:00
"math"
2019-11-15 14:59:39 +00:00
"math/big"
"sync"
2019-10-23 13:04:54 +01:00
"time"
2020-01-28 23:36:54 +00:00
"github.com/shopspring/decimal"
2019-11-08 20:40:39 +00:00
"github.com/spacemonkeygo/monkit/v3"
2019-10-29 16:04:34 +00:00
"github.com/stripe/stripe-go"
2019-10-10 18:12:23 +01:00
"github.com/zeebo/errs"
2019-10-23 13:04:54 +01:00
"go.uber.org/zap"
2019-10-15 12:23:54 +01:00
2020-01-29 00:57:15 +00:00
"storj.io/common/memory"
2020-03-30 10:08:50 +01:00
"storj.io/common/uuid"
2019-11-15 14:27:44 +00:00
"storj.io/storj/satellite/accounting"
2019-11-05 13:16:02 +00:00
"storj.io/storj/satellite/console"
2019-10-15 12:23:54 +01:00
"storj.io/storj/satellite/payments"
2019-10-17 15:04:50 +01:00
"storj.io/storj/satellite/payments/coinpayments"
2019-10-10 18:12:23 +01:00
)
2019-11-04 10:54:25 +00:00
var (
// Error defines stripecoinpayments service error.
Error = errs . Class ( "stripecoinpayments service error" )
2019-11-26 17:58:51 +00:00
// ErrNoCouponUsages indicates that there are no coupon usages.
ErrNoCouponUsages = errs . Class ( "stripecoinpayments no coupon usages" )
2019-10-10 18:12:23 +01:00
2019-11-04 10:54:25 +00:00
mon = monkit . Package ( )
)
2019-10-10 18:12:23 +01:00
2020-05-26 12:00:14 +01:00
// 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
2019-10-17 15:04:50 +01:00
// Config stores needed information for payment service initialization.
2019-10-15 12:23:54 +01:00
type Config struct {
2019-10-29 16:04:34 +00:00
StripeSecretKey string ` help:"stripe API secret key" default:"" `
2019-11-18 11:38:43 +00:00
StripePublicKey string ` help:"stripe API public key" default:"" `
2019-10-29 16:04:34 +00:00
CoinpaymentsPublicKey string ` help:"coinpayments API public key" default:"" `
2019-10-31 16:56:54 +00:00
CoinpaymentsPrivateKey string ` help:"coinpayments API private key key" default:"" `
2019-10-29 16:04:34 +00:00
TransactionUpdateInterval time . Duration ` help:"amount of time we wait before running next transaction update loop" devDefault:"1m" releaseDefault:"30m" `
AccountBalanceUpdateInterval time . Duration ` help:"amount of time we wait before running next account balance update loop" devDefault:"3m" releaseDefault:"1h30m" `
2019-11-15 14:59:39 +00:00
ConversionRatesCycleInterval time . Duration ` help:"amount of time we wait before running next conversion rates update loop" devDefault:"1m" releaseDefault:"10m" `
2020-03-13 16:07:39 +00:00
AutoAdvance bool ` help:"toogle autoadvance feature for invoice creation" default:"false" `
2020-05-19 08:42:07 +01:00
ListingLimit int ` help:"sets the maximum amount of items before we start paging on requests" default:"100" hidden:"true" `
2019-10-10 18:12:23 +01:00
}
2019-10-15 12:23:54 +01:00
// Service is an implementation for payment service via Stripe and Coinpayments.
2019-11-04 12:30:07 +00:00
//
// architecture: Service
2019-10-15 12:23:54 +01:00
type Service struct {
2019-11-15 14:59:39 +00:00
log * zap . Logger
db DB
projectsDB console . Projects
usageDB accounting . ProjectAccounting
2020-05-15 09:46:41 +01:00
stripeClient StripeClient
2019-11-15 14:59:39 +00:00
coinPayments * coinpayments . Client
2020-05-26 12:00:14 +01:00
StorageMBMonthPriceCents decimal . Decimal
EgressMBPriceCents decimal . Decimal
ObjectMonthPriceCents decimal . Decimal
2020-01-24 13:38:53 +00:00
// BonusRate amount of percents
BonusRate int64
2020-03-16 19:34:15 +00:00
// Coupon Values
CouponValue int64
CouponDuration int64
CouponProjectLimit memory . Size
// Minimum CoinPayment to create a coupon
MinCoinPayment int64
2019-11-15 14:59:39 +00:00
2020-03-13 16:07:39 +00:00
//Stripe Extended Features
AutoAdvance bool
2019-11-15 14:59:39 +00:00
mu sync . Mutex
rates coinpayments . CurrencyRateInfos
ratesErr error
2020-05-19 08:42:07 +01:00
listingLimit int
nowFn func ( ) time . Time
2019-10-10 18:12:23 +01:00
}
2019-10-15 12:23:54 +01:00
// NewService creates a Service instance.
2020-05-15 09:46:41 +01:00
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 ) {
2019-10-17 15:04:50 +01:00
2019-10-23 13:04:54 +01:00
coinPaymentsClient := coinpayments . NewClient (
2019-10-17 15:04:50 +01:00
coinpayments . Credentials {
PublicKey : config . CoinpaymentsPublicKey ,
PrivateKey : config . CoinpaymentsPrivateKey ,
} ,
)
2019-10-10 18:12:23 +01:00
2020-05-26 12:00:14 +01:00
storageTBMonthDollars , err := decimal . NewFromString ( storageTBPrice )
2020-01-28 23:36:54 +00:00
if err != nil {
return nil , err
}
2020-01-29 05:06:01 +00:00
egressTBDollars , err := decimal . NewFromString ( egressTBPrice )
2020-01-28 23:36:54 +00:00
if err != nil {
return nil , err
}
objectMonthDollars , err := decimal . NewFromString ( objectPrice )
if err != nil {
return nil , err
2019-10-10 18:12:23 +01:00
}
2020-01-28 23:36:54 +00:00
2020-05-26 12:00:14 +01:00
// 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 )
2020-01-28 23:36:54 +00:00
return & Service {
2020-05-26 12:00:14 +01:00
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 ,
2020-01-28 23:36:54 +00:00
} , nil
2019-10-10 18:12:23 +01:00
}
2019-10-11 16:00:35 +01:00
2019-10-15 12:23:54 +01:00
// Accounts exposes all needed functionality to manage payment accounts.
2019-10-17 15:42:18 +01:00
func ( service * Service ) Accounts ( ) payments . Accounts {
2019-10-15 12:23:54 +01:00
return & accounts { service : service }
2019-10-11 16:00:35 +01:00
}
2019-10-23 13:04:54 +01:00
// updateTransactionsLoop updates all pending transactions in a loop.
func ( service * Service ) updateTransactionsLoop ( ctx context . Context ) ( err error ) {
defer mon . Task ( ) ( & ctx ) ( & err )
2020-05-19 08:42:07 +01:00
before := service . nowFn ( )
2019-10-23 13:04:54 +01:00
2020-05-19 08:42:07 +01:00
txsPage , err := service . db . Transactions ( ) . ListPending ( ctx , 0 , service . listingLimit , before )
2019-10-23 13:04:54 +01:00
if err != nil {
return err
}
if err := service . updateTransactions ( ctx , txsPage . IDList ( ) ) ; err != nil {
return err
}
for txsPage . Next {
2019-10-29 16:04:34 +00:00
if err = ctx . Err ( ) ; err != nil {
return err
2019-10-23 13:04:54 +01:00
}
2020-05-19 08:42:07 +01:00
txsPage , err = service . db . Transactions ( ) . ListPending ( ctx , txsPage . NextOffset , service . listingLimit , before )
2019-10-23 13:04:54 +01:00
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.
2020-01-29 00:57:15 +00:00
func ( service * Service ) updateTransactions ( ctx context . Context , ids TransactionAndUserList ) ( err error ) {
2019-10-23 13:04:54 +01:00
defer mon . Task ( ) ( & ctx , ids ) ( & err )
if len ( ids ) == 0 {
service . log . Debug ( "no transactions found, skipping update" )
return nil
}
2020-01-29 00:57:15 +00:00
infos , err := service . coinPayments . Transactions ( ) . ListInfos ( ctx , ids . IDList ( ) )
2019-10-23 13:04:54 +01:00
if err != nil {
return err
}
var updates [ ] TransactionUpdate
2019-10-29 16:04:34 +00:00
var applies coinpayments . TransactionIDList
2019-10-23 13:04:54 +01:00
for id , info := range infos {
updates = append ( updates ,
TransactionUpdate {
TransactionID : id ,
Status : info . Status ,
Received : info . Received ,
} ,
)
2020-01-14 13:38:32 +00:00
// 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 {
2019-10-29 16:04:34 +00:00
applies = append ( applies , id )
}
2020-01-29 00:57:15 +00:00
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 )
2020-03-16 19:34:15 +00:00
if cents >= service . MinCoinPayment {
err = service . Accounts ( ) . Coupons ( ) . AddPromotionalCoupon ( ctx , userID )
2020-01-29 00:57:15 +00:00
if err != nil {
service . log . Error ( fmt . Sprintf ( "could not add promotional coupon for user %s" , userID . String ( ) ) , zap . Error ( err ) )
continue
}
}
2019-10-29 16:04:34 +00:00
}
2019-11-05 13:16:02 +00:00
return service . db . Transactions ( ) . Update ( ctx , updates , applies )
2019-10-29 16:04:34 +00:00
}
// 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 )
2020-05-19 08:42:07 +01:00
before := service . nowFn ( )
2019-10-29 16:04:34 +00:00
2020-05-19 08:42:07 +01:00
txsPage , err := service . db . Transactions ( ) . ListUnapplied ( ctx , 0 , service . listingLimit , before )
2019-10-29 16:04:34 +00:00
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
}
2020-05-19 08:42:07 +01:00
txsPage , err = service . db . Transactions ( ) . ListUnapplied ( ctx , txsPage . NextOffset , service . listingLimit , before )
2019-10-29 16:04:34 +00:00
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
}
}
2019-10-23 13:04:54 +01:00
}
2019-10-29 16:04:34 +00:00
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 )
2019-11-05 13:16:02 +00:00
cusID , err := service . db . Customers ( ) . GetCustomerID ( ctx , tx . AccountID )
2019-10-29 16:04:34 +00:00
if err != nil {
return err
}
2019-11-15 14:59:39 +00:00
rate , err := service . db . Transactions ( ) . GetLockedRate ( ctx , tx . ID )
if err != nil {
return err
}
2019-11-05 13:16:02 +00:00
if err = service . db . Transactions ( ) . Consume ( ctx , tx . ID ) ; err != nil {
2019-10-29 16:04:34 +00:00
return err
}
2019-11-21 13:23:16 +00:00
cents := convertToCents ( rate , & tx . Received )
2019-10-29 16:04:34 +00:00
params := & stripe . CustomerBalanceTransactionParams {
2019-11-19 17:56:18 +00:00
Amount : stripe . Int64 ( - cents ) ,
2019-10-29 16:04:34 +00:00
Customer : stripe . String ( cusID ) ,
Currency : stripe . String ( string ( stripe . CurrencyUSD ) ) ,
Description : stripe . String ( "storj token deposit" ) ,
}
params . AddMetadata ( "txID" , tx . ID . String ( ) )
2019-11-15 14:59:39 +00:00
// TODO: 0 amount will return an error, how to handle that?
2020-05-15 09:46:41 +01:00
_ , err = service . stripeClient . CustomerBalanceTransactions ( ) . New ( params )
2020-01-24 13:38:53 +00:00
if err != nil {
return err
}
credit := payments . Credit {
UserID : tx . AccountID ,
Amount : cents / 100 * service . BonusRate ,
TransactionID : tx . ID ,
}
return service . db . Credits ( ) . InsertCredit ( ctx , credit )
2019-10-23 13:04:54 +01:00
}
2019-11-05 13:16:02 +00:00
2019-11-15 14:59:39 +00:00
// 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 )
2020-05-22 11:42:17 +01:00
if coinpayments . ErrMissingPublicKey . Has ( err ) {
rates = coinpayments . CurrencyRateInfos { }
err = nil
service . log . Info ( "Coinpayment client is missing public key" )
}
2019-11-15 14:59:39 +00:00
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
}
2019-11-05 13:16:02 +00:00
// 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 )
2020-05-19 08:42:07 +01:00
now := service . nowFn ( ) . UTC ( )
2019-11-05 13:16:02 +00:00
utc := period . UTC ( )
start := time . Date ( utc . Year ( ) , utc . Month ( ) , 1 , 0 , 0 , 0 , 0 , time . UTC )
2020-03-13 16:07:39 +00:00
end := time . Date ( utc . Year ( ) , utc . Month ( ) + 1 , 0 , 0 , 0 , 0 , 0 , time . UTC )
2019-11-05 13:16:02 +00:00
if end . After ( now ) {
2020-05-12 09:46:48 +01:00
return Error . New ( "allowed for past periods only" )
2019-11-05 13:16:02 +00:00
}
2020-05-19 08:42:07 +01:00
projsPage , err := service . projectsDB . List ( ctx , 0 , service . listingLimit , end )
2019-11-05 13:16:02 +00:00
if err != nil {
return Error . Wrap ( err )
}
if err = service . createProjectRecords ( ctx , projsPage . Projects , start , end ) ; err != nil {
return Error . Wrap ( err )
}
for projsPage . Next {
if err = ctx . Err ( ) ; err != nil {
return Error . Wrap ( err )
}
2020-05-19 08:42:07 +01:00
projsPage , err = service . projectsDB . List ( ctx , projsPage . NextOffset , service . listingLimit , end )
2019-11-05 13:16:02 +00:00
if err != nil {
return Error . Wrap ( err )
}
if err = service . createProjectRecords ( ctx , projsPage . Projects , start , end ) ; err != nil {
return Error . Wrap ( err )
}
}
return nil
}
// createProjectRecords creates invoice project record if none exists.
func ( service * Service ) createProjectRecords ( ctx context . Context , projects [ ] console . Project , start , end time . Time ) ( err error ) {
defer mon . Task ( ) ( & ctx ) ( & err )
var records [ ] CreateProjectRecord
2020-01-07 10:41:19 +00:00
var usages [ ] CouponUsage
2020-02-11 14:48:28 +00:00
var creditsSpendings [ ] CreditsSpending
2019-11-05 13:16:02 +00:00
for _ , project := range projects {
if err = ctx . Err ( ) ; err != nil {
return err
}
if err = service . db . ProjectRecords ( ) . Check ( ctx , project . ID , start , end ) ; err != nil {
if err == ErrProjectRecordExists {
continue
}
return err
}
2020-01-07 10:41:19 +00:00
usage , err := service . usageDB . GetProjectTotal ( ctx , project . ID , start , end )
if err != nil {
return err
}
2019-11-05 13:16:02 +00:00
// TODO: account for usage data.
records = append ( records ,
CreateProjectRecord {
ProjectID : project . ID ,
2020-01-07 10:41:19 +00:00
Storage : usage . Storage ,
Egress : usage . Egress ,
Objects : usage . ObjectCount ,
2019-11-05 13:16:02 +00:00
} ,
)
2020-01-07 10:41:19 +00:00
2020-05-08 17:04:04 +01:00
leftToCharge := service . calculateProjectUsagePrice ( usage . Egress , usage . Storage , usage . ObjectCount ) . TotalInt64 ( )
if leftToCharge == 0 {
continue
}
2020-01-07 10:41:19 +00:00
2020-05-08 17:04:04 +01:00
// 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 , project , leftToCharge )
if err != nil {
return err
}
2020-01-24 13:38:53 +00:00
2020-05-08 17:04:04 +01:00
if leftToCharge == 0 {
continue
}
2020-02-11 14:48:28 +00:00
2020-05-19 08:42:07 +01:00
coupons , err := service . db . Coupons ( ) . ListByProjectID ( ctx , project . ID )
if err != nil {
return err
}
2020-05-08 17:04:04 +01:00
// Apply any promotional credits (a.k.a. coupons) on the remainder.
// TODO: if multiple coupons are available apply them in order of expiration.
for _ , coupon := range coupons {
2020-01-07 10:41:19 +00:00
if coupon . IsExpired ( ) {
2020-05-19 11:36:13 +01:00
if _ , err = service . db . Coupons ( ) . Update ( ctx , coupon . ID , payments . CouponExpired ) ; err != nil {
2020-01-07 10:41:19 +00:00
return err
}
continue
}
alreadyChargedAmount , err := service . db . Coupons ( ) . TotalUsage ( ctx , coupon . ID )
if err != nil {
return err
}
remaining := coupon . Amount - alreadyChargedAmount
2020-05-08 17:04:04 +01:00
amountToChargeFromCoupon := leftToCharge
2020-01-24 13:38:53 +00:00
if amountToChargeFromCoupon >= remaining {
amountToChargeFromCoupon = remaining
2020-01-07 10:41:19 +00:00
}
2020-05-08 17:04:04 +01:00
if amountToChargeFromCoupon > 0 {
usages = append ( usages , CouponUsage {
Period : start ,
Amount : amountToChargeFromCoupon ,
Status : CouponUsageStatusUnapplied ,
CouponID : coupon . ID ,
} )
leftToCharge -= amountToChargeFromCoupon
if leftToCharge == 0 {
break
}
}
2020-01-07 10:41:19 +00:00
}
2020-02-11 14:48:28 +00:00
2020-05-08 17:04:04 +01:00
if leftToCharge == 0 {
2020-02-11 14:48:28 +00:00
continue
}
2020-05-08 17:04:04 +01:00
// Last, apply any credits from STORJ deposit bonuses on the remainder.
2020-02-11 14:48:28 +00:00
userBonuses , err := service . db . Credits ( ) . Balance ( ctx , project . OwnerID )
if err != nil {
return err
}
if userBonuses > 0 {
2020-05-08 17:04:04 +01:00
amountChargedFromBonuses := leftToCharge
if amountChargedFromBonuses >= userBonuses {
amountChargedFromBonuses = userBonuses
2020-02-11 14:48:28 +00:00
}
creditSpendingID , err := uuid . New ( )
if err != nil {
return err
}
2020-05-08 17:04:04 +01:00
if amountChargedFromBonuses > 0 {
creditsSpendings = append ( creditsSpendings , CreditsSpending {
ID : creditSpendingID ,
Amount : amountChargedFromBonuses ,
UserID : project . OwnerID ,
ProjectID : project . ID ,
Status : CreditsSpendingStatusUnapplied ,
2020-05-12 09:46:48 +01:00
Period : start ,
2020-05-08 17:04:04 +01:00
} )
}
2020-02-11 14:48:28 +00:00
}
2019-11-05 13:16:02 +00:00
}
2020-02-11 14:48:28 +00:00
return service . db . ProjectRecords ( ) . Create ( ctx , records , usages , creditsSpendings , start , end )
2019-11-05 13:16:02 +00:00
}
// InvoiceApplyProjectRecords iterates through unapplied invoice project records and creates invoice line items
// for stripe customer.
2020-05-12 09:46:48 +01:00
func ( service * Service ) InvoiceApplyProjectRecords ( ctx context . Context , period time . Time ) ( err error ) {
2019-11-05 13:16:02 +00:00
defer mon . Task ( ) ( & ctx ) ( & err )
2020-05-19 08:42:07 +01:00
now := service . nowFn ( ) . UTC ( )
2020-05-12 09:46:48 +01:00
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 )
2019-11-05 13:16:02 +00:00
2020-05-12 09:46:48 +01:00
if end . After ( now ) {
return Error . New ( "allowed for past periods only" )
}
2020-05-19 08:42:07 +01:00
recordsPage , err := service . db . ProjectRecords ( ) . ListUnapplied ( ctx , 0 , service . listingLimit , start , end )
2019-11-05 13:16:02 +00:00
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 )
}
2020-05-18 14:01:26 +01:00
// we are always starting from offset 0 because applyProjectRecords is changing project record state to applied
2020-05-19 08:42:07 +01:00
recordsPage , err = service . db . ProjectRecords ( ) . ListUnapplied ( ctx , 0 , service . listingLimit , start , end )
2019-11-05 13:16:02 +00:00
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 {
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 {
2020-03-13 16:07:39 +00:00
Currency : stripe . String ( string ( stripe . CurrencyUSD ) ) ,
Customer : stripe . String ( cusID ) ,
2019-11-05 13:16:02 +00:00
}
projectItem . AddMetadata ( "projectID" , record . ProjectID . String ( ) )
2020-05-26 12:00:14 +01:00
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 )
2020-05-15 09:46:41 +01:00
_ , err = service . stripeClient . InvoiceItems ( ) . New ( projectItem )
2020-03-13 16:07:39 +00:00
if err != nil {
return err
}
2020-05-26 12:00:14 +01:00
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 )
2020-05-15 09:46:41 +01:00
_ , err = service . stripeClient . InvoiceItems ( ) . New ( projectItem )
2020-03-13 16:07:39 +00:00
if err != nil {
return err
}
2020-05-26 12:00:14 +01:00
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 )
2020-05-15 09:46:41 +01:00
_ , err = service . stripeClient . InvoiceItems ( ) . New ( projectItem )
2019-11-05 13:16:02 +00:00
return err
}
2020-01-07 10:41:19 +00:00
// InvoiceApplyCoupons iterates through unapplied project coupons and creates invoice line items
// for stripe customer.
2020-05-12 09:46:48 +01:00
func ( service * Service ) InvoiceApplyCoupons ( ctx context . Context , period time . Time ) ( err error ) {
2019-11-26 17:58:51 +00:00
defer mon . Task ( ) ( & ctx ) ( & err )
2020-05-19 08:42:07 +01:00
now := service . nowFn ( ) . UTC ( )
2020-05-12 09:46:48 +01:00
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 )
2019-11-26 17:58:51 +00:00
2020-05-12 09:46:48 +01:00
if end . After ( now ) {
return Error . New ( "allowed for past periods only" )
}
2020-05-19 08:42:07 +01:00
usagePage , err := service . db . Coupons ( ) . ListUnapplied ( ctx , 0 , service . listingLimit , start )
2019-11-26 17:58:51 +00:00
if err != nil {
return Error . Wrap ( err )
}
2020-01-07 10:41:19 +00:00
if err = service . applyCoupons ( ctx , usagePage . Usages ) ; err != nil {
2019-11-26 17:58:51 +00:00
return Error . Wrap ( err )
}
2020-01-07 10:41:19 +00:00
for usagePage . Next {
2019-11-26 17:58:51 +00:00
if err = ctx . Err ( ) ; err != nil {
return Error . Wrap ( err )
}
2020-05-20 13:54:32 +01:00
// we are always starting from offset 0 because applyCoupons is changing coupon usage state to applied
2020-05-19 08:42:07 +01:00
usagePage , err = service . db . Coupons ( ) . ListUnapplied ( ctx , 0 , service . listingLimit , start )
2019-11-26 17:58:51 +00:00
if err != nil {
return Error . Wrap ( err )
}
2020-01-07 10:41:19 +00:00
if err = service . applyCoupons ( ctx , usagePage . Usages ) ; err != nil {
2019-11-26 17:58:51 +00:00
return Error . Wrap ( err )
}
}
return nil
}
2020-01-07 10:41:19 +00:00
// applyCoupons applies concrete coupon usage as invoice line item.
func ( service * Service ) applyCoupons ( ctx context . Context , usages [ ] CouponUsage ) ( err error ) {
2019-11-26 17:58:51 +00:00
defer mon . Task ( ) ( & ctx ) ( & err )
2020-01-07 10:41:19 +00:00
for _ , usage := range usages {
if err = ctx . Err ( ) ; err != nil {
return err
2019-11-26 17:58:51 +00:00
}
2020-01-07 10:41:19 +00:00
coupon , err := service . db . Coupons ( ) . Get ( ctx , usage . CouponID )
2019-11-26 17:58:51 +00:00
if err != nil {
return err
}
2020-01-07 10:41:19 +00:00
customerID , err := service . db . Customers ( ) . GetCustomerID ( ctx , coupon . UserID )
2019-11-26 17:58:51 +00:00
if err != nil {
2020-01-07 10:41:19 +00:00
if err == ErrNoCustomer {
continue
}
2019-11-26 17:58:51 +00:00
return err
}
2020-01-07 10:41:19 +00:00
if err = service . createInvoiceCouponItems ( ctx , coupon , usage , customerID ) ; err != nil {
2019-11-26 17:58:51 +00:00
return err
}
}
return nil
}
2020-02-11 14:48:28 +00:00
// createInvoiceCouponItems consumes invoice project record and creates invoice line items for stripe customer.
2020-01-07 10:41:19 +00:00
func ( service * Service ) createInvoiceCouponItems ( ctx context . Context , coupon payments . Coupon , usage CouponUsage , customerID string ) ( err error ) {
2019-11-26 17:58:51 +00:00
defer mon . Task ( ) ( & ctx , customerID , coupon ) ( & err )
2020-01-07 10:41:19 +00:00
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 {
2020-05-19 11:36:13 +01:00
_ , err = service . db . Coupons ( ) . Update ( ctx , coupon . ID , payments . CouponUsed )
2020-01-07 10:41:19 +00:00
if err != nil {
return err
}
}
2019-11-26 17:58:51 +00:00
projectItem := & stripe . InvoiceItemParams {
2020-01-07 10:41:19 +00:00
Amount : stripe . Int64 ( - usage . Amount ) ,
2019-11-26 17:58:51 +00:00
Currency : stripe . String ( string ( stripe . CurrencyUSD ) ) ,
Customer : stripe . String ( customerID ) ,
2020-05-08 17:26:33 +01:00
Description : stripe . String ( coupon . Description ) ,
2019-11-26 17:58:51 +00:00
}
projectItem . AddMetadata ( "projectID" , coupon . ProjectID . String ( ) )
projectItem . AddMetadata ( "couponID" , coupon . ID . String ( ) )
2020-05-15 09:46:41 +01:00
_ , err = service . stripeClient . InvoiceItems ( ) . New ( projectItem )
2019-11-26 17:58:51 +00:00
return err
}
2020-02-11 12:42:08 +00:00
// InvoiceApplyCredits iterates through credits with status false of project and creates invoice line items
// for stripe customer.
2020-05-12 09:46:48 +01:00
func ( service * Service ) InvoiceApplyCredits ( ctx context . Context , period time . Time ) ( err error ) {
2020-02-11 12:42:08 +00:00
defer mon . Task ( ) ( & ctx ) ( & err )
2020-05-19 08:42:07 +01:00
now := service . nowFn ( ) . UTC ( )
2020-05-12 09:46:48 +01:00
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 )
2020-02-11 12:42:08 +00:00
2020-05-12 09:46:48 +01:00
if end . After ( now ) {
return Error . New ( "allowed for past periods only" )
}
2020-05-19 08:42:07 +01:00
spendingsPage , err := service . db . Credits ( ) . ListCreditsSpendingsPaged ( ctx , int ( CreditsSpendingStatusUnapplied ) , 0 , service . listingLimit , start )
2020-02-11 12:42:08 +00:00
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 )
}
2020-05-20 13:54:32 +01:00
// we are always starting from offset 0 because applySpendings is changing credits spendings state to applied
2020-05-19 08:42:07 +01:00
spendingsPage , err = service . db . Credits ( ) . ListCreditsSpendingsPaged ( ctx , int ( CreditsSpendingStatusUnapplied ) , 0 , service . listingLimit , start )
2020-02-11 12:42:08 +00:00
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
}
2020-02-11 14:48:28 +00:00
// createInvoiceCreditItem consumes invoice project record and creates invoice line items for stripe customer.
2020-02-11 12:42:08 +00:00
func ( service * Service ) createInvoiceCreditItem ( ctx context . Context , spending CreditsSpending ) ( err error ) {
defer mon . Task ( ) ( & ctx , spending ) ( & err )
2020-02-11 14:48:28 +00:00
err = service . db . Credits ( ) . ApplyCreditsSpending ( ctx , spending . ID )
2020-02-11 12:42:08 +00:00
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 ) ,
2020-05-08 17:26:33 +01:00
Description : stripe . String ( "Credits from STORJ deposit bonus" ) ,
2020-02-11 12:42:08 +00:00
}
projectItem . AddMetadata ( "projectID" , spending . ProjectID . String ( ) )
projectItem . AddMetadata ( "userID" , spending . UserID . String ( ) )
2020-05-15 09:46:41 +01:00
_ , err = service . stripeClient . InvoiceItems ( ) . New ( projectItem )
2020-02-11 12:42:08 +00:00
return err
}
2019-11-05 13:16:02 +00:00
// CreateInvoices lists through all customers and creates invoices.
2020-05-12 09:46:48 +01:00
func ( service * Service ) CreateInvoices ( ctx context . Context , period time . Time ) ( err error ) {
2019-11-05 13:16:02 +00:00
defer mon . Task ( ) ( & ctx ) ( & err )
2020-05-19 08:42:07 +01:00
now := service . nowFn ( ) . UTC ( )
2020-05-12 09:46:48 +01:00
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" )
}
2019-11-05 13:16:02 +00:00
2020-05-19 08:42:07 +01:00
cusPage , err := service . db . Customers ( ) . List ( ctx , 0 , service . listingLimit , end )
2019-11-05 13:16:02 +00:00
if err != nil {
return Error . Wrap ( err )
}
for _ , cus := range cusPage . Customers {
if err = ctx . Err ( ) ; err != nil {
return Error . Wrap ( err )
}
2020-05-12 09:46:48 +01:00
if err = service . createInvoice ( ctx , cus . ID , start ) ; err != nil {
2019-11-05 13:16:02 +00:00
return Error . Wrap ( err )
}
}
for cusPage . Next {
if err = ctx . Err ( ) ; err != nil {
return Error . Wrap ( err )
}
2020-05-19 08:42:07 +01:00
cusPage , err = service . db . Customers ( ) . List ( ctx , cusPage . NextOffset , service . listingLimit , end )
2019-11-05 13:16:02 +00:00
if err != nil {
return Error . Wrap ( err )
}
for _ , cus := range cusPage . Customers {
if err = ctx . Err ( ) ; err != nil {
return Error . Wrap ( err )
}
2020-05-12 09:46:48 +01:00
if err = service . createInvoice ( ctx , cus . ID , start ) ; err != nil {
2019-11-05 13:16:02 +00:00
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.
2020-05-12 09:46:48 +01:00
func ( service * Service ) createInvoice ( ctx context . Context , cusID string , period time . Time ) ( err error ) {
2019-11-05 13:16:02 +00:00
defer mon . Task ( ) ( & ctx ) ( & err )
2020-05-12 09:46:48 +01:00
description := fmt . Sprintf ( "Tardigrade Cloud Storage for %s %d" , period . Month ( ) , period . Year ( ) )
2020-03-13 16:07:39 +00:00
2020-05-15 09:46:41 +01:00
_ , err = service . stripeClient . Invoices ( ) . New (
2019-11-05 13:16:02 +00:00
& stripe . InvoiceParams {
Customer : stripe . String ( cusID ) ,
2020-03-13 16:07:39 +00:00
AutoAdvance : stripe . Bool ( service . AutoAdvance ) ,
Description : stripe . String ( description ) ,
2019-11-05 13:16:02 +00:00
} ,
)
if err != nil {
if stripeErr , ok := err . ( * stripe . Error ) ; ok {
switch stripeErr . Code {
case stripe . ErrorCodeInvoiceNoCustomerLineItems :
return nil
default :
return err
}
}
}
return nil
}
2020-01-28 23:36:54 +00:00
// 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 {
2020-05-26 12:00:14 +01:00
Storage : service . StorageMBMonthPriceCents . Mul ( storageMBMonthDecimal ( storage ) ) . Round ( 0 ) ,
Egress : service . EgressMBPriceCents . Mul ( egressMBDecimal ( egress ) ) . Round ( 0 ) ,
Objects : service . ObjectMonthPriceCents . Mul ( objectMonthDecimal ( objects ) ) . Round ( 0 ) ,
2020-01-28 23:36:54 +00:00
}
}
2020-05-08 17:04:04 +01:00
// 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 , project console . Project , projectUsagePrice int64 ) ( int64 , error ) {
customerID , err := service . db . Customers ( ) . GetCustomerID ( ctx , project . OwnerID )
if err != nil {
return 0 , Error . Wrap ( err )
}
2020-05-15 09:46:41 +01:00
customer , err := service . stripeClient . Customers ( ) . Get ( customerID , nil )
2020-05-08 17:04:04 +01:00
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 {
discounted := projectUsagePrice - coupon . AmountOff
if discounted < 0 {
return 0 , nil
}
return discounted , nil
}
if coupon . PercentOff > 0 {
discount := int64 ( math . Round ( float64 ( projectUsagePrice ) * coupon . PercentOff / 100 ) )
return projectUsagePrice - discount , nil
}
return projectUsagePrice , nil
}
2020-05-19 08:42:07 +01:00
// 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
}
2020-05-26 12:00:14 +01:00
// 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 )
}