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"
2020-07-14 14:04:38 +01:00
"errors"
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"
2020-05-28 12:31:02 +01:00
"strconv"
2019-11-15 14:59:39 +00:00
"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"
2021-04-27 22:20:53 +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.
2021-04-28 09:06:17 +01:00
Error = errs . Class ( "stripecoinpayments service" )
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:"" `
2020-05-29 21:21:27 +01:00
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" `
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
2021-03-30 00:37:46 +01:00
CouponDuration * int64
2020-03-16 19:34:15 +00:00
CouponProjectLimit memory . Size
// Minimum CoinPayment to create a coupon
MinCoinPayment int64
2019-11-15 14:59:39 +00:00
2020-10-13 13:47:55 +01:00
// Stripe Extended Features
2020-03-13 16:07:39 +00:00
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
2020-07-10 14:05:17 +01:00
listingLimit int
nowFn func ( ) time . Time
PaywallProportion float64
2019-10-10 18:12:23 +01:00
}
2019-10-15 12:23:54 +01:00
// NewService creates a Service instance.
2021-03-30 00:37:46 +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 int64 , couponDuration * int64 , couponProjectLimit memory . Size , minCoinPayment int64 , paywallProportion float64 ) ( * 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-07-10 14:05:17 +01:00
PaywallProportion : paywallProportion ,
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
}
2020-06-25 22:16:39 +01:00
if err := service . updateTransactions ( ctx , txsPage . IDList ( ) , txsPage . CreationTimes ( ) ) ; err != nil {
2019-10-23 13:04:54 +01:00
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
}
2020-06-25 22:16:39 +01:00
if err := service . updateTransactions ( ctx , txsPage . IDList ( ) , txsPage . CreationTimes ( ) ) ; err != nil {
2019-10-23 13:04:54 +01:00
return err
}
}
return nil
}
// updateTransactions updates statuses and received amount for given transactions.
2020-06-25 22:16:39 +01:00
func ( service * Service ) updateTransactions ( ctx context . Context , ids TransactionAndUserList , creationTimes map [ coinpayments . TransactionID ] time . Time ) ( 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 {
2020-06-25 22:16:39 +01:00
service . log . Debug ( "Coinpayments results: " , zap . String ( "status" , info . Status . String ( ) ) , zap . String ( "id" , id . String ( ) ) )
2019-10-23 13:04:54 +01:00
updates = append ( updates ,
TransactionUpdate {
TransactionID : id ,
Status : info . Status ,
Received : info . Received ,
} ,
)
2020-07-01 16:26:23 +01:00
// moment of CoinPayments receives funds, not when STORJ does
// this was a business decision to not wait until StatusCompleted
if info . Status >= coinpayments . StatusReceived {
2020-10-13 13:47:55 +01:00
// monkit currently does not have a DurationVal
2020-06-25 22:16:39 +01:00
mon . IntVal ( "coinpayment_duration" ) . Observe ( int64 ( time . Since ( creationTimes [ id ] ) ) )
2019-10-29 16:04:34 +00:00
applies = append ( applies , id )
}
2020-01-29 00:57:15 +00:00
userID := ids [ id ]
2020-07-10 14:05:17 +01:00
if ! service . Accounts ( ) . PaywallEnabled ( userID ) {
continue
}
2020-01-29 00:57:15 +00:00
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-21 13:23:16 +00:00
cents := convertToCents ( rate , & tx . Received )
2019-10-29 16:04:34 +00:00
2020-05-28 12:31:02 +01:00
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 )
2019-10-29 16:04:34 +00:00
}
2020-05-28 12:31:02 +01:00
// 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 ( )
2019-10-29 16:04:34 +00:00
2020-05-28 12:31:02 +01:00
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 ( ) )
2020-07-17 16:17:21 +01:00
params . AddMetadata ( "storj_amount" , tx . Amount . String ( ) )
params . AddMetadata ( "storj_usd_rate" , rate . String ( ) )
2020-05-28 12:31:02 +01:00
_ , err = service . stripeClient . CustomerBalanceTransactions ( ) . New ( params )
if err != nil {
return err
}
2020-01-24 13:38:53 +00:00
}
2020-05-28 12:31:02 +01:00
// 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
}
2020-01-24 13:38:53 +00:00
}
2020-05-28 12:31:02 +01:00
return service . db . Transactions ( ) . Consume ( ctx , tx . ID )
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-06-03 10:50:53 +01:00
var numberOfCustomers , numberOfRecords , numberOfCouponsUsages int
2020-05-29 11:29:03 +01:00
customersPage , 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 )
}
2020-06-03 10:50:53 +01:00
numberOfCustomers += len ( customersPage . Customers )
2019-11-05 13:16:02 +00:00
2020-06-03 10:50:53 +01:00
records , usages , err := service . processCustomers ( ctx , customersPage . Customers , start , end )
if err != nil {
2019-11-05 13:16:02 +00:00
return Error . Wrap ( err )
}
2020-06-03 10:50:53 +01:00
numberOfRecords += records
numberOfCouponsUsages += usages
2019-11-05 13:16:02 +00:00
2020-05-29 11:29:03 +01:00
for customersPage . Next {
2019-11-05 13:16:02 +00:00
if err = ctx . Err ( ) ; err != nil {
return Error . Wrap ( err )
}
2020-05-29 11:29:03 +01:00
customersPage , err = service . db . Customers ( ) . List ( ctx , customersPage . NextOffset , service . listingLimit , end )
2019-11-05 13:16:02 +00:00
if err != nil {
return Error . Wrap ( err )
}
2020-06-03 10:50:53 +01:00
records , usages , err := service . processCustomers ( ctx , customersPage . Customers , start , end )
if err != nil {
2019-11-05 13:16:02 +00:00
return Error . Wrap ( err )
}
2020-06-03 10:50:53 +01:00
numberOfRecords += records
numberOfCouponsUsages += usages
2019-11-05 13:16:02 +00:00
}
2020-06-03 10:50:53 +01:00
service . log . Info ( "Number of processed entries." , zap . Int ( "Customers" , numberOfCustomers ) , zap . Int ( "Projects" , numberOfRecords ) , zap . Int ( "Coupons Usages" , numberOfCouponsUsages ) )
2019-11-05 13:16:02 +00:00
return nil
}
2020-06-03 10:50:53 +01:00
func ( service * Service ) processCustomers ( ctx context . Context , customers [ ] Customer , start , end time . Time ) ( int , int , error ) {
2020-05-29 11:29:03 +01:00
var allRecords [ ] CreateProjectRecord
2020-01-07 10:41:19 +00:00
var usages [ ] CouponUsage
2020-05-29 11:29:03 +01:00
for _ , customer := range customers {
2020-06-03 18:01:54 +01:00
projects , err := service . projectsDB . GetOwn ( ctx , customer . UserID )
2020-01-07 10:41:19 +00:00
if err != nil {
2020-06-03 10:50:53 +01:00
return 0 , 0 , err
2020-01-07 10:41:19 +00:00
}
2020-05-29 11:29:03 +01:00
leftToCharge , records , err := service . createProjectRecords ( ctx , customer . ID , projects , start , end )
2020-05-08 17:04:04 +01:00
if err != nil {
2020-06-03 10:50:53 +01:00
return 0 , 0 , err
2020-05-08 17:04:04 +01:00
}
2020-01-24 13:38:53 +00:00
2020-05-29 11:29:03 +01:00
allRecords = append ( allRecords , records ... )
coupons , err := service . db . Coupons ( ) . ListByUserIDAndStatus ( ctx , customer . UserID , payments . CouponActive )
2020-05-19 08:42:07 +01:00
if err != nil {
2020-06-03 10:50:53 +01:00
return 0 , 0 , err
2020-05-19 08:42:07 +01:00
}
2020-05-08 17:04:04 +01:00
// Apply any promotional credits (a.k.a. coupons) on the remainder.
for _ , coupon := range coupons {
2020-05-13 18:02:51 +01:00
if coupon . Status == payments . CouponExpired {
// this coupon has already been marked as expired.
continue
}
2021-03-30 00:37:46 +01:00
expirationDate := coupon . ExpirationDate ( )
if expirationDate != nil &&
end . After ( * expirationDate ) {
2020-05-13 18:02:51 +01:00
// this coupon is identified as expired for first time, mark it in the database
2020-05-19 11:36:13 +01:00
if _ , err = service . db . Coupons ( ) . Update ( ctx , coupon . ID , payments . CouponExpired ) ; err != nil {
2020-06-03 10:50:53 +01:00
return 0 , 0 , err
2020-01-07 10:41:19 +00:00
}
continue
}
alreadyChargedAmount , err := service . db . Coupons ( ) . TotalUsage ( ctx , coupon . ID )
if err != nil {
2020-06-03 10:50:53 +01:00
return 0 , 0 , err
2020-01-07 10:41:19 +00:00
}
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
2020-06-01 15:05:24 +01:00
}
2021-03-30 00:37:46 +01:00
if amountToChargeFromCoupon < remaining && expirationDate != nil && end . Equal ( * expirationDate ) {
2020-06-01 15:05:24 +01:00
// 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 {
2020-06-03 10:50:53 +01:00
return 0 , 0 , err
2020-05-08 17:04:04 +01:00
}
}
2020-01-07 10:41:19 +00:00
}
2019-11-05 13:16:02 +00:00
}
2020-07-23 13:36:56 +01:00
return len ( allRecords ) , len ( usages ) , service . db . ProjectRecords ( ) . Create ( ctx , allRecords , usages , start , end )
2020-05-29 11:29:03 +01:00
}
// 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 {
2020-07-14 14:04:38 +01:00
if errors . Is ( err , ErrProjectRecordExists ) {
2020-06-03 10:50:53 +01:00
service . log . Warn ( "Record for this project already exists." , zap . String ( "Customer ID" , customerID ) , zap . String ( "Project ID" , project . ID . String ( ) ) )
2020-05-29 11:29:03 +01:00
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.
2020-06-03 10:50:53 +01:00
leftToCharge , err = service . discountedProjectUsagePrice ( ctx , customerID , leftToCharge )
2020-05-29 11:29:03 +01:00
if err != nil {
return 0 , nil , err
}
sumLeftToCharge += leftToCharge
}
return sumLeftToCharge , records , nil
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-06-09 14:07:06 +01:00
projectRecords := 0
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 )
}
2020-06-09 14:07:06 +01:00
projectRecords += len ( recordsPage . Records )
2019-11-05 13:16:02 +00:00
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 )
}
2020-06-09 14:07:06 +01:00
projectRecords += len ( recordsPage . Records )
2019-11-05 13:16:02 +00:00
}
2020-06-09 14:07:06 +01:00
service . log . Info ( "Number of processed project records." , zap . Int ( "Project Records" , projectRecords ) )
2019-11-05 13:16:02 +00:00
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 {
2020-07-14 14:04:38 +01:00
if errors . Is ( err , ErrNoCustomer ) {
2020-06-03 10:50:53 +01:00
service . log . Warn ( "Stripe customer does not exist for project owner." , zap . Stringer ( "Owner ID" , proj . OwnerID ) , zap . Stringer ( "Project ID" , proj . ID ) )
2019-11-05 13:16:02 +00:00
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
}
2020-05-27 13:08:37 +01:00
items := service . InvoiceItemsFromProjectRecord ( projName , record )
for _ , item := range items {
item . Currency = stripe . String ( string ( stripe . CurrencyUSD ) )
item . Customer = stripe . String ( cusID )
item . AddMetadata ( "projectID" , record . ProjectID . String ( ) )
_ , err = service . stripeClient . InvoiceItems ( ) . New ( item )
if err != nil {
return err
}
2019-11-05 13:16:02 +00:00
}
2020-05-27 13:08:37 +01:00
return nil
}
// InvoiceItemsFromProjectRecord calculates Stripe invoice item from project record.
func ( service * Service ) InvoiceItemsFromProjectRecord ( projName string , record ProjectRecord ) ( result [ ] * stripe . InvoiceItemParams ) {
projectItem := & stripe . InvoiceItemParams { }
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-27 13:08:37 +01:00
result = append ( result , projectItem )
2020-03-13 16:07:39 +00:00
2020-05-27 13:08:37 +01:00
projectItem = & stripe . InvoiceItemParams { }
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-27 13:08:37 +01:00
result = append ( result , projectItem )
2020-03-13 16:07:39 +00:00
2020-05-27 13:08:37 +01:00
projectItem = & stripe . InvoiceItemParams { }
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-27 13:08:37 +01:00
result = append ( result , projectItem )
return result
2019-11-05 13:16:02 +00:00
}
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-06-09 14:07:06 +01:00
couponsUsages := 0
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-06-09 14:07:06 +01:00
couponsUsages += len ( usagePage . Usages )
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 )
}
2020-06-09 14:07:06 +01:00
couponsUsages += len ( usagePage . Usages )
2019-11-26 17:58:51 +00:00
}
2020-06-09 14:07:06 +01:00
service . log . Info ( "Number of processed coupons usages." , zap . Int ( "Coupons Usages" , couponsUsages ) )
2021-04-27 22:20:53 +01:00
// iterate over all customers and give new coupon to users with expired or exhausted coupons
service . log . Info ( "Populating promotional coupons for users without active coupons..." )
couponValue := service . CouponValue
var couponDuration * int
if service . CouponDuration != nil {
d := int ( * service . CouponDuration )
couponDuration = & d
}
cusPage , err := service . db . Customers ( ) . List ( ctx , 0 , service . listingLimit , end )
if err != nil {
return Error . Wrap ( err )
}
userIDList := make ( [ ] uuid . UUID , service . listingLimit )
for _ , cus := range cusPage . Customers {
if err = ctx . Err ( ) ; err != nil {
return Error . Wrap ( err )
}
userIDList = append ( userIDList , cus . UserID )
}
err = service . db . Coupons ( ) . PopulatePromotionalCoupons ( ctx , userIDList , couponDuration , couponValue , 0 )
if 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 )
}
userIDList := make ( [ ] uuid . UUID , service . listingLimit )
for _ , cus := range cusPage . Customers {
if err = ctx . Err ( ) ; err != nil {
return Error . Wrap ( err )
}
userIDList = append ( userIDList , cus . UserID )
}
err = service . db . Coupons ( ) . PopulatePromotionalCoupons ( ctx , userIDList , couponDuration , couponValue , 0 )
if err != nil {
return Error . Wrap ( err )
}
}
service . log . Info ( "Done populating promotional coupons." )
2019-11-26 17:58:51 +00:00
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-07-14 14:04:38 +01:00
if errors . Is ( err , ErrNoCustomer ) {
2020-06-03 10:50:53 +01:00
service . log . Warn ( "Stripe customer does not exist for coupon owner." , zap . Stringer ( "User ID" , coupon . UserID ) , zap . Stringer ( "Coupon ID" , coupon . ID ) )
2020-01-07 10:41:19 +00:00
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 ( "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
}
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-06-09 14:07:06 +01:00
invoices := 0
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 )
}
}
2020-06-09 14:07:06 +01:00
invoices += len ( cusPage . Customers )
2019-11-05 13:16:02 +00:00
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 )
}
}
2020-06-09 14:07:06 +01:00
invoices += len ( cusPage . Customers )
2019-11-05 13:16:02 +00:00
}
2020-06-09 14:07:06 +01:00
service . log . Info ( "Number of created draft invoices." , zap . Int ( "Invoices" , invoices ) )
2019-11-05 13:16:02 +00:00
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 )
2021-04-15 14:57:34 +01:00
description := fmt . Sprintf ( "Storj DCS 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
2020-06-09 16:18:36 +01:00
// FinalizeInvoices sets autoadvance flag on all draft invoices currently available in stripe.
func ( service * Service ) FinalizeInvoices ( ctx context . Context ) ( err error ) {
defer mon . Task ( ) ( & ctx ) ( & err )
params := & stripe . InvoiceListParams {
Status : stripe . String ( "draft" ) ,
}
invoicesIterator := service . stripeClient . Invoices ( ) . List ( params )
for invoicesIterator . Next ( ) {
stripeInvoice := invoicesIterator . Invoice ( )
err := service . finalizeInvoice ( ctx , stripeInvoice . ID )
if err != nil {
return Error . Wrap ( err )
}
}
return Error . Wrap ( invoicesIterator . Err ( ) )
}
func ( service * Service ) finalizeInvoice ( ctx context . Context , invoiceID string ) ( err error ) {
defer mon . Task ( ) ( & ctx ) ( & err )
params := & stripe . InvoiceFinalizeParams { AutoAdvance : stripe . Bool ( true ) }
_ , err = service . stripeClient . Invoices ( ) . FinalizeInvoice ( invoiceID , params )
return err
}
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.
2020-06-03 10:50:53 +01:00
func ( service * Service ) discountedProjectUsagePrice ( ctx context . Context , customerID string , projectUsagePrice int64 ) ( int64 , error ) {
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 {
2020-06-03 10:50:53 +01:00
service . log . Info ( "Applying Stripe discount." , zap . String ( "Customer ID" , customerID ) , zap . Int64 ( "AmountOff" , coupon . AmountOff ) )
2020-05-08 17:04:04 +01:00
discounted := projectUsagePrice - coupon . AmountOff
if discounted < 0 {
return 0 , nil
}
return discounted , nil
}
if coupon . PercentOff > 0 {
2020-06-03 10:50:53 +01:00
service . log . Info ( "Applying Stripe discount." , zap . String ( "Customer ID" , customerID ) , zap . Float64 ( "PercentOff" , coupon . PercentOff ) )
2020-05-08 17:04:04 +01:00
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 )
}