2022-04-26 21:23:27 +01:00
|
|
|
// Copyright (C) 2022 Storj Labs, Inc.
|
|
|
|
// See LICENSE for copying information.
|
|
|
|
|
|
|
|
package satellitedb
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
2022-06-16 17:48:07 +01:00
|
|
|
"database/sql"
|
|
|
|
"encoding/json"
|
|
|
|
"errors"
|
|
|
|
"time"
|
2022-04-26 21:23:27 +01:00
|
|
|
|
|
|
|
"github.com/zeebo/errs"
|
|
|
|
|
2022-09-06 13:43:09 +01:00
|
|
|
"storj.io/common/currency"
|
2022-04-26 21:23:27 +01:00
|
|
|
"storj.io/common/uuid"
|
2022-08-04 18:29:55 +01:00
|
|
|
"storj.io/private/dbutil/pgutil/pgerrcode"
|
2022-04-26 21:23:27 +01:00
|
|
|
"storj.io/storj/satellite/payments/billing"
|
|
|
|
"storj.io/storj/satellite/satellitedb/dbx"
|
|
|
|
)
|
|
|
|
|
|
|
|
// ensures that *billingDB implements billing.TransactionsDB.
|
|
|
|
var _ billing.TransactionsDB = (*billingDB)(nil)
|
|
|
|
|
|
|
|
// billingDB is billing DB.
|
|
|
|
//
|
|
|
|
// architecture: Database
|
|
|
|
type billingDB struct {
|
|
|
|
db *satelliteDB
|
|
|
|
}
|
|
|
|
|
2023-03-24 12:08:40 +00:00
|
|
|
func (db billingDB) Insert(ctx context.Context, primaryTx billing.Transaction, supplementalTxs ...billing.Transaction) (_ []int64, err error) {
|
2022-04-26 21:23:27 +01:00
|
|
|
defer mon.Task()(&ctx)(&err)
|
2023-03-24 12:08:40 +00:00
|
|
|
|
|
|
|
// NOTE: if this is changed for bulk insertion we'll need to ensure that
|
|
|
|
// either limits are imposed on the number of inserts, or that the work
|
|
|
|
// is broken up into distinct batches.
|
|
|
|
// If the latter happens, care must be taken to provide an interface where
|
|
|
|
// even if the bulk inserts are broken up, that transactions that
|
|
|
|
// absolutely need to be committed together can continue to do so (e.g.
|
|
|
|
// a storjscan sourced transaction and its related bonus transaction).
|
|
|
|
|
|
|
|
// This limit is somewhat arbitrary and can be revisited. This method is
|
|
|
|
// NOT intended for bulk insertion but rather to provided a way for
|
|
|
|
// related transactions to be committed together.
|
|
|
|
const supplementalTxLimit = 5
|
|
|
|
if len(supplementalTxs) > supplementalTxLimit {
|
|
|
|
return nil, Error.New("Cannot insert more than %d supplemental txs (tried %d)", supplementalTxLimit, len(supplementalTxs))
|
|
|
|
}
|
|
|
|
|
|
|
|
for retryCount := 0; retryCount < 5; retryCount++ {
|
|
|
|
txIDs, err := db.tryInsert(ctx, primaryTx, supplementalTxs)
|
|
|
|
switch {
|
|
|
|
case err == nil:
|
|
|
|
return txIDs, nil
|
|
|
|
case pgerrcode.IsConstraintViolation(err):
|
|
|
|
default:
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil, Error.New("Unable to insert new billing transaction after several retries: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (db billingDB) tryInsert(ctx context.Context, primaryTx billing.Transaction, supplementalTxs []billing.Transaction) (_ []int64, err error) {
|
|
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
|
|
|
|
convertToUSDMicro := func(amount currency.Amount) currency.Amount {
|
|
|
|
return currency.AmountFromDecimal(amount.AsDecimal().Truncate(currency.USDollarsMicro.DecimalPlaces()), currency.USDollarsMicro)
|
|
|
|
}
|
|
|
|
|
|
|
|
type balanceUpdate struct {
|
|
|
|
OldBalance currency.Amount
|
|
|
|
NewBalance currency.Amount
|
|
|
|
}
|
|
|
|
|
|
|
|
updateBalance := func(ctx context.Context, tx *dbx.Tx, userID uuid.UUID, oldBalance, newBalance currency.Amount) error {
|
|
|
|
updatedRow, err := tx.Update_BillingBalance_By_UserId_And_Balance(ctx,
|
|
|
|
dbx.BillingBalance_UserId(userID[:]),
|
|
|
|
dbx.BillingBalance_Balance(oldBalance.BaseUnits()),
|
|
|
|
dbx.BillingBalance_Update_Fields{
|
|
|
|
Balance: dbx.BillingBalance_Balance(newBalance.BaseUnits()),
|
|
|
|
})
|
2022-06-16 17:48:07 +01:00
|
|
|
if err != nil {
|
2023-03-24 12:08:40 +00:00
|
|
|
return Error.Wrap(err)
|
2022-08-04 18:29:55 +01:00
|
|
|
}
|
2023-03-24 12:08:40 +00:00
|
|
|
if updatedRow == nil {
|
|
|
|
// Try an insert here, in case the user never had a record in the table.
|
|
|
|
// If the user already had a record, and the oldBalance was not as expected,
|
|
|
|
// the insert will fail anyways.
|
|
|
|
err = tx.CreateNoReturn_BillingBalance(ctx,
|
|
|
|
dbx.BillingBalance_UserId(userID[:]),
|
|
|
|
dbx.BillingBalance_Balance(newBalance.BaseUnits()))
|
|
|
|
if err != nil {
|
|
|
|
return Error.Wrap(err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
createTransaction := func(ctx context.Context, tx *dbx.Tx, billingTX *billing.Transaction) (int64, error) {
|
|
|
|
amount := convertToUSDMicro(billingTX.Amount)
|
|
|
|
dbxTX, err := tx.Create_BillingTransaction(ctx,
|
|
|
|
dbx.BillingTransaction_UserId(billingTX.UserID[:]),
|
|
|
|
dbx.BillingTransaction_Amount(amount.BaseUnits()),
|
|
|
|
dbx.BillingTransaction_Currency(amount.Currency().Symbol()),
|
|
|
|
dbx.BillingTransaction_Description(billingTX.Description),
|
|
|
|
dbx.BillingTransaction_Source(billingTX.Source),
|
|
|
|
dbx.BillingTransaction_Status(string(billingTX.Status)),
|
|
|
|
dbx.BillingTransaction_Type(string(billingTX.Type)),
|
|
|
|
dbx.BillingTransaction_Metadata(handleMetaDataZeroValue(billingTX.Metadata)),
|
|
|
|
dbx.BillingTransaction_Timestamp(billingTX.Timestamp))
|
2022-08-27 00:08:03 +01:00
|
|
|
if err != nil {
|
|
|
|
return 0, Error.Wrap(err)
|
|
|
|
}
|
2023-03-24 12:08:40 +00:00
|
|
|
return dbxTX.Id, nil
|
|
|
|
}
|
2022-08-04 18:29:55 +01:00
|
|
|
|
2023-03-24 12:08:40 +00:00
|
|
|
balances := make(map[uuid.UUID]*balanceUpdate)
|
|
|
|
|
|
|
|
adjustBalance := func(userID uuid.UUID, amount currency.Amount) error {
|
|
|
|
balance, ok := balances[userID]
|
|
|
|
if !ok {
|
|
|
|
oldBalance, err := db.GetBalance(ctx, userID)
|
2022-06-16 17:48:07 +01:00
|
|
|
if err != nil {
|
|
|
|
return Error.Wrap(err)
|
|
|
|
}
|
2023-03-24 12:08:40 +00:00
|
|
|
balance = &balanceUpdate{OldBalance: oldBalance, NewBalance: oldBalance}
|
|
|
|
balances[userID] = balance
|
|
|
|
}
|
|
|
|
newBalance, err := currency.Add(balance.NewBalance, convertToUSDMicro(amount))
|
|
|
|
switch {
|
|
|
|
case err != nil:
|
|
|
|
return Error.Wrap(err)
|
|
|
|
case newBalance.IsNegative():
|
|
|
|
return billing.ErrInsufficientFunds
|
|
|
|
}
|
|
|
|
balance.NewBalance = newBalance
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := adjustBalance(primaryTx.UserID, primaryTx.Amount); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
for _, supplementalTx := range supplementalTxs {
|
|
|
|
if err := adjustBalance(supplementalTx.UserID, supplementalTx.Amount); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var txIDs []int64
|
|
|
|
err = db.db.WithTx(ctx, func(ctx context.Context, tx *dbx.Tx) error {
|
|
|
|
for userID, update := range balances {
|
|
|
|
if err := updateBalance(ctx, tx, userID, update.OldBalance, update.NewBalance); err != nil {
|
|
|
|
return err
|
2022-08-04 18:29:55 +01:00
|
|
|
}
|
2023-03-24 12:08:40 +00:00
|
|
|
}
|
2022-06-16 17:48:07 +01:00
|
|
|
|
2023-03-24 12:08:40 +00:00
|
|
|
txID, err := createTransaction(ctx, tx, &primaryTx)
|
|
|
|
if err != nil {
|
2022-08-04 18:29:55 +01:00
|
|
|
return err
|
|
|
|
}
|
2023-03-24 12:08:40 +00:00
|
|
|
txIDs = append(txIDs, txID)
|
|
|
|
|
|
|
|
for _, supplementalTx := range supplementalTxs {
|
|
|
|
txID, err := createTransaction(ctx, tx, &supplementalTx)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
txIDs = append(txIDs, txID)
|
2022-08-04 18:29:55 +01:00
|
|
|
}
|
2023-03-24 12:08:40 +00:00
|
|
|
return nil
|
|
|
|
})
|
|
|
|
return txIDs, err
|
2022-04-26 21:23:27 +01:00
|
|
|
}
|
|
|
|
|
2022-05-10 20:19:53 +01:00
|
|
|
func (db billingDB) UpdateStatus(ctx context.Context, txID int64, status billing.TransactionStatus) (err error) {
|
2022-04-26 21:23:27 +01:00
|
|
|
defer mon.Task()(&ctx)(&err)
|
2022-05-10 20:19:53 +01:00
|
|
|
return db.db.UpdateNoReturn_BillingTransaction_By_Id(ctx, dbx.BillingTransaction_Id(txID), dbx.BillingTransaction_Update_Fields{
|
|
|
|
Status: dbx.BillingTransaction_Status(string(status)),
|
|
|
|
})
|
|
|
|
}
|
2022-06-16 17:48:07 +01:00
|
|
|
|
2022-05-10 20:19:53 +01:00
|
|
|
func (db billingDB) UpdateMetadata(ctx context.Context, txID int64, newMetadata []byte) (err error) {
|
2022-06-16 17:48:07 +01:00
|
|
|
|
2022-05-10 20:19:53 +01:00
|
|
|
dbxTX, err := db.db.Get_BillingTransaction_Metadata_By_Id(ctx, dbx.BillingTransaction_Id(txID))
|
|
|
|
if err != nil {
|
|
|
|
return Error.Wrap(err)
|
2022-04-26 21:23:27 +01:00
|
|
|
}
|
|
|
|
|
2022-05-10 20:19:53 +01:00
|
|
|
updatedMetadata, err := updateMetadata(dbxTX.Metadata, newMetadata)
|
|
|
|
if err != nil {
|
|
|
|
return Error.Wrap(err)
|
2022-04-26 21:23:27 +01:00
|
|
|
}
|
|
|
|
|
2022-05-10 20:19:53 +01:00
|
|
|
return db.db.UpdateNoReturn_BillingTransaction_By_Id(ctx, dbx.BillingTransaction_Id(txID), dbx.BillingTransaction_Update_Fields{
|
|
|
|
Metadata: dbx.BillingTransaction_Metadata(updatedMetadata),
|
|
|
|
})
|
2022-04-26 21:23:27 +01:00
|
|
|
}
|
|
|
|
|
2022-06-17 00:29:31 +01:00
|
|
|
func (db billingDB) LastTransaction(ctx context.Context, txSource string, txType billing.TransactionType) (_ time.Time, metadata []byte, err error) {
|
2022-04-26 21:23:27 +01:00
|
|
|
defer mon.Task()(&ctx)(&err)
|
2022-06-16 17:48:07 +01:00
|
|
|
|
2022-08-23 22:59:15 +01:00
|
|
|
lastTransaction, err := db.db.First_BillingTransaction_By_Source_And_Type_OrderBy_Desc_CreatedAt(
|
2022-06-16 17:48:07 +01:00
|
|
|
ctx,
|
|
|
|
dbx.BillingTransaction_Source(txSource),
|
|
|
|
dbx.BillingTransaction_Type(string(txType)))
|
2022-06-17 00:29:31 +01:00
|
|
|
|
2022-06-16 17:48:07 +01:00
|
|
|
if err != nil {
|
2022-06-17 00:29:31 +01:00
|
|
|
return time.Time{}, nil, Error.Wrap(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if lastTransaction == nil {
|
2022-08-15 15:41:19 +01:00
|
|
|
return time.Time{}, nil, billing.ErrNoTransactions
|
2022-06-16 17:48:07 +01:00
|
|
|
}
|
|
|
|
|
2022-06-17 00:29:31 +01:00
|
|
|
return lastTransaction.Timestamp, lastTransaction.Metadata, nil
|
2022-06-16 17:48:07 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
func (db billingDB) List(ctx context.Context, userID uuid.UUID) (txs []billing.Transaction, err error) {
|
|
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
dbxTXs, err := db.db.All_BillingTransaction_By_UserId_OrderBy_Desc_Timestamp(ctx,
|
|
|
|
dbx.BillingTransaction_UserId(userID[:]))
|
2022-04-26 21:23:27 +01:00
|
|
|
if err != nil {
|
|
|
|
return nil, Error.Wrap(err)
|
|
|
|
}
|
|
|
|
|
2023-06-01 13:32:11 +01:00
|
|
|
txs, err = convertSlice(dbxTXs, fromDBXBillingTransaction)
|
|
|
|
return txs, Error.Wrap(err)
|
2022-04-26 21:23:27 +01:00
|
|
|
}
|
|
|
|
|
2023-05-15 16:14:30 +01:00
|
|
|
func (db billingDB) ListSource(ctx context.Context, userID uuid.UUID, txSource string) (txs []billing.Transaction, err error) {
|
|
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
dbxTXs, err := db.db.All_BillingTransaction_By_UserId_And_Source_OrderBy_Desc_Timestamp(ctx,
|
2023-06-01 13:32:11 +01:00
|
|
|
dbx.BillingTransaction_UserId(userID[:]),
|
|
|
|
dbx.BillingTransaction_Source(txSource))
|
2023-05-15 16:14:30 +01:00
|
|
|
if err != nil {
|
|
|
|
return nil, Error.Wrap(err)
|
|
|
|
}
|
|
|
|
|
2023-06-01 13:32:11 +01:00
|
|
|
txs, err = convertSlice(dbxTXs, fromDBXBillingTransaction)
|
|
|
|
return txs, Error.Wrap(err)
|
2023-05-15 16:14:30 +01:00
|
|
|
}
|
|
|
|
|
2022-09-06 13:43:09 +01:00
|
|
|
func (db billingDB) GetBalance(ctx context.Context, userID uuid.UUID) (_ currency.Amount, err error) {
|
2022-04-26 21:23:27 +01:00
|
|
|
defer mon.Task()(&ctx)(&err)
|
2022-06-16 17:48:07 +01:00
|
|
|
dbxBilling, err := db.db.Get_BillingBalance_Balance_By_UserId(ctx,
|
|
|
|
dbx.BillingBalance_UserId(userID[:]))
|
2022-04-26 21:23:27 +01:00
|
|
|
if err != nil {
|
2022-06-16 17:48:07 +01:00
|
|
|
if errors.Is(err, sql.ErrNoRows) {
|
2022-09-06 13:43:09 +01:00
|
|
|
return currency.USDollarsMicro.Zero(), nil
|
2022-04-26 21:23:27 +01:00
|
|
|
}
|
2022-09-06 13:43:09 +01:00
|
|
|
return currency.USDollarsMicro.Zero(), Error.Wrap(err)
|
2022-04-26 21:23:27 +01:00
|
|
|
}
|
|
|
|
|
2022-09-06 13:43:09 +01:00
|
|
|
return currency.AmountFromBaseUnits(dbxBilling.Balance, currency.USDollarsMicro), nil
|
2022-04-26 21:23:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// fromDBXBillingTransaction converts *dbx.BillingTransaction to *billing.Transaction.
|
2023-06-01 13:32:11 +01:00
|
|
|
func fromDBXBillingTransaction(dbxTX *dbx.BillingTransaction) (billing.Transaction, error) {
|
2022-04-26 21:23:27 +01:00
|
|
|
userID, err := uuid.FromBytes(dbxTX.UserId)
|
|
|
|
if err != nil {
|
2023-06-01 13:32:11 +01:00
|
|
|
return billing.Transaction{}, errs.Wrap(err)
|
2022-04-26 21:23:27 +01:00
|
|
|
}
|
2023-06-01 13:32:11 +01:00
|
|
|
return billing.Transaction{
|
2022-06-16 17:48:07 +01:00
|
|
|
ID: dbxTX.Id,
|
|
|
|
UserID: userID,
|
2022-09-06 13:43:09 +01:00
|
|
|
Amount: currency.AmountFromBaseUnits(dbxTX.Amount, currency.USDollarsMicro),
|
2022-04-26 21:23:27 +01:00
|
|
|
Description: dbxTX.Description,
|
2022-06-16 17:48:07 +01:00
|
|
|
Source: dbxTX.Source,
|
|
|
|
Status: billing.TransactionStatus(dbxTX.Status),
|
|
|
|
Type: billing.TransactionType(dbxTX.Type),
|
|
|
|
Metadata: dbxTX.Metadata,
|
2022-04-26 21:23:27 +01:00
|
|
|
Timestamp: dbxTX.Timestamp,
|
|
|
|
CreatedAt: dbxTX.CreatedAt,
|
|
|
|
}, nil
|
|
|
|
}
|
2022-06-16 17:48:07 +01:00
|
|
|
|
|
|
|
func updateMetadata(oldMetaData []byte, newMetaData []byte) ([]byte, error) {
|
|
|
|
var updatedMetadata map[string]interface{}
|
|
|
|
|
|
|
|
err := json.Unmarshal(oldMetaData, &updatedMetadata)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2022-05-10 20:19:53 +01:00
|
|
|
err = json.Unmarshal(handleMetaDataZeroValue(newMetaData), &updatedMetadata)
|
2022-06-16 17:48:07 +01:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return json.Marshal(updatedMetadata)
|
|
|
|
}
|
2022-05-10 20:19:53 +01:00
|
|
|
|
|
|
|
func handleMetaDataZeroValue(metaData []byte) []byte {
|
|
|
|
if metaData != nil {
|
|
|
|
return metaData
|
|
|
|
}
|
|
|
|
return []byte(`{}`)
|
|
|
|
}
|