satellite/{payments,admin}: add deletion of user creditcards on account deletion
Change-Id: I38bf7e3995846150268f7b88a70f75b0ac871b62
This commit is contained in:
parent
729079965f
commit
086a3d5348
@ -13,12 +13,14 @@ import (
|
|||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/zeebo/errs"
|
"github.com/zeebo/errs"
|
||||||
|
|
||||||
"storj.io/common/uuid"
|
"storj.io/common/uuid"
|
||||||
"storj.io/storj/satellite/console/consoleauth"
|
"storj.io/storj/satellite/console/consoleauth"
|
||||||
|
"storj.io/storj/satellite/payments"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newConsoleEndpoints(address string) *consoleEndpoints {
|
func newConsoleEndpoints(address string) *consoleEndpoints {
|
||||||
@ -51,6 +53,10 @@ func (ce *consoleEndpoints) SetupAccount() string {
|
|||||||
return ce.appendPath("/api/v0/payments/account")
|
return ce.appendPath("/api/v0/payments/account")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ce *consoleEndpoints) CreditCards() string {
|
||||||
|
return ce.appendPath("/api/v0/payments/cards")
|
||||||
|
}
|
||||||
|
|
||||||
func (ce *consoleEndpoints) Activation(token string) string {
|
func (ce *consoleEndpoints) Activation(token string) string {
|
||||||
return ce.appendPath("/activation/?token=" + token)
|
return ce.appendPath("/activation/?token=" + token)
|
||||||
}
|
}
|
||||||
@ -110,6 +116,24 @@ func (ce *consoleEndpoints) createOrGetAPIKey() (string, error) {
|
|||||||
return "", errs.Wrap(err)
|
return "", errs.Wrap(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = ce.addCreditCard(authToken, "test")
|
||||||
|
if err != nil {
|
||||||
|
return "", errs.Wrap(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cards, err := ce.listCreditCards(authToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", errs.Wrap(err)
|
||||||
|
}
|
||||||
|
if len(cards) == 0 {
|
||||||
|
return "", errs.New("no credit card(s) found")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ce.makeCreditCardDefault(authToken, cards[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
return "", errs.Wrap(err)
|
||||||
|
}
|
||||||
|
|
||||||
projectID, err := ce.getOrCreateProject(authToken)
|
projectID, err := ce.getOrCreateProject(authToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errs.Wrap(err)
|
return "", errs.Wrap(err)
|
||||||
@ -322,6 +346,98 @@ func (ce *consoleEndpoints) setupAccount(token string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ce *consoleEndpoints) addCreditCard(token, cctoken string) error {
|
||||||
|
request, err := http.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
ce.CreditCards(),
|
||||||
|
strings.NewReader(cctoken))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
request.AddCookie(&http.Cookie{
|
||||||
|
Name: ce.cookieName,
|
||||||
|
Value: token,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := ce.client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { err = errs.Combine(err, resp.Body.Close()) }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return errs.New("unexpected status code: %d (%q)",
|
||||||
|
resp.StatusCode, tryReadLine(resp.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ce *consoleEndpoints) listCreditCards(token string) ([]payments.CreditCard, error) {
|
||||||
|
request, err := http.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
ce.CreditCards(),
|
||||||
|
nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
request.AddCookie(&http.Cookie{
|
||||||
|
Name: ce.cookieName,
|
||||||
|
Value: token,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := ce.client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { err = errs.Combine(err, resp.Body.Close()) }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, errs.New("unexpected status code: %d (%q)",
|
||||||
|
resp.StatusCode, tryReadLine(resp.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []payments.CreditCard
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(resp.Body)
|
||||||
|
err = decoder.Decode(&list)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ce *consoleEndpoints) makeCreditCardDefault(token, ccID string) error {
|
||||||
|
request, err := http.NewRequest(
|
||||||
|
http.MethodPatch,
|
||||||
|
ce.CreditCards(),
|
||||||
|
strings.NewReader(ccID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
request.AddCookie(&http.Cookie{
|
||||||
|
Name: ce.cookieName,
|
||||||
|
Value: token,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp, err := ce.client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { err = errs.Combine(err, resp.Body.Close()) }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return errs.New("unexpected status code: %d (%q)",
|
||||||
|
resp.StatusCode, tryReadLine(resp.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (ce *consoleEndpoints) getOrCreateProject(token string) (string, error) {
|
func (ce *consoleEndpoints) getOrCreateProject(token string) (string, error) {
|
||||||
projectID, err := ce.getProject(token)
|
projectID, err := ce.getProject(token)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@ -445,12 +561,12 @@ func generateActivationKey(userID uuid.UUID, email string, createdAt time.Time)
|
|||||||
// TODO: change it in future, when satellite/console secret will be changed
|
// TODO: change it in future, when satellite/console secret will be changed
|
||||||
signer := &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}
|
signer := &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}
|
||||||
|
|
||||||
json, err := claims.JSON()
|
resJSON, err := claims.JSON()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
token := consoleauth.Token{Payload: json}
|
token := consoleauth.Token{Payload: resJSON}
|
||||||
encoded := base64.URLEncoding.EncodeToString(token.Payload)
|
encoded := base64.URLEncoding.EncodeToString(token.Payload)
|
||||||
|
|
||||||
signature, err := signer.Sign([]byte(encoded))
|
signature, err := signer.Sign([]byte(encoded))
|
||||||
|
@ -370,4 +370,10 @@ func (server *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
err.Error(), http.StatusInternalServerError)
|
err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = server.payments.CreditCards().RemoveAll(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
httpJSONError(w, "unable to delete credit card(s) from stripe account",
|
||||||
|
err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,10 @@ type CreditCards interface {
|
|||||||
// Remove is used to detach a credit card from payment account.
|
// Remove is used to detach a credit card from payment account.
|
||||||
Remove(ctx context.Context, userID uuid.UUID, cardID string) error
|
Remove(ctx context.Context, userID uuid.UUID, cardID string) error
|
||||||
|
|
||||||
|
// RemoveAll is used to detach all credit cards from payment account.
|
||||||
|
// It should only be used in case of a user deletion.
|
||||||
|
RemoveAll(ctx context.Context, userID uuid.UUID) error
|
||||||
|
|
||||||
// MakeDefault makes a credit card default payment method.
|
// MakeDefault makes a credit card default payment method.
|
||||||
// this credit card should be attached to account before make it default.
|
// this credit card should be attached to account before make it default.
|
||||||
MakeDefault(ctx context.Context, userID uuid.UUID, cardID string) error
|
MakeDefault(ctx context.Context, userID uuid.UUID, cardID string) error
|
||||||
|
@ -149,3 +149,21 @@ func (creditCards *creditCards) Remove(ctx context.Context, userID uuid.UUID, ca
|
|||||||
|
|
||||||
return Error.Wrap(err)
|
return Error.Wrap(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveAll is used to detach all credit cards from payment account.
|
||||||
|
// It should only be used in case of a user deletion. In case of an error, some cards could have been deleted already.
|
||||||
|
func (creditCards *creditCards) RemoveAll(ctx context.Context, userID uuid.UUID) (err error) {
|
||||||
|
defer mon.Task()(&ctx)(&err)
|
||||||
|
|
||||||
|
ccList, err := creditCards.List(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return Error.Wrap(err)
|
||||||
|
}
|
||||||
|
for _, cc := range ccList {
|
||||||
|
_, err = creditCards.service.stripeClient.PaymentMethods().Detach(cc.ID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Error.Wrap(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
98
satellite/payments/stripecoinpayments/creditcards_test.go
Normal file
98
satellite/payments/stripecoinpayments/creditcards_test.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
// Copyright (C) 2020 Storj Labs, Inc.
|
||||||
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
package stripecoinpayments_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"storj.io/common/testcontext"
|
||||||
|
"storj.io/storj/private/testplanet"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreditCards_List(t *testing.T) {
|
||||||
|
testplanet.Run(t, testplanet.Config{
|
||||||
|
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1,
|
||||||
|
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
||||||
|
satellite := planet.Satellites[0]
|
||||||
|
userID := planet.Uplinks[0].Projects[0].Owner.ID
|
||||||
|
|
||||||
|
cards, err := satellite.API.Payments.Accounts.CreditCards().List(ctx, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, cards)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreditCards_Add(t *testing.T) {
|
||||||
|
testplanet.Run(t, testplanet.Config{
|
||||||
|
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1,
|
||||||
|
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
||||||
|
satellite := planet.Satellites[0]
|
||||||
|
userID := planet.Uplinks[0].Projects[0].Owner.ID
|
||||||
|
|
||||||
|
err := satellite.API.Payments.Accounts.CreditCards().Add(ctx, userID, "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cards, err := satellite.API.Payments.Accounts.CreditCards().List(ctx, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, cards, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreditCards_Remove(t *testing.T) {
|
||||||
|
testplanet.Run(t, testplanet.Config{
|
||||||
|
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1,
|
||||||
|
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
||||||
|
satellite := planet.Satellites[0]
|
||||||
|
userID := planet.Uplinks[0].Projects[0].Owner.ID
|
||||||
|
|
||||||
|
err := satellite.API.Payments.Accounts.CreditCards().Add(ctx, userID, "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = satellite.API.Payments.Accounts.CreditCards().Add(ctx, userID, "test2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cards, err := satellite.API.Payments.Accounts.CreditCards().List(ctx, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, cards, 2)
|
||||||
|
|
||||||
|
// Save card that should remain after deletion.
|
||||||
|
savedCard := cards[0]
|
||||||
|
|
||||||
|
err = satellite.API.Payments.Accounts.CreditCards().Remove(ctx, userID, cards[1].ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cards, err = satellite.API.Payments.Accounts.CreditCards().List(ctx, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, cards, 1)
|
||||||
|
require.Equal(t, savedCard, cards[0])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreditCards_RemoveAll(t *testing.T) {
|
||||||
|
testplanet.Run(t, testplanet.Config{
|
||||||
|
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1,
|
||||||
|
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
||||||
|
satellite := planet.Satellites[0]
|
||||||
|
userID := planet.Uplinks[0].Projects[0].Owner.ID
|
||||||
|
|
||||||
|
err := satellite.API.Payments.Accounts.CreditCards().Add(ctx, userID, "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = satellite.API.Payments.Accounts.CreditCards().Add(ctx, userID, "test2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cards, err := satellite.API.Payments.Accounts.CreditCards().List(ctx, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, cards, 2)
|
||||||
|
|
||||||
|
err = satellite.API.Payments.Accounts.CreditCards().RemoveAll(ctx, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cards, err = satellite.API.Payments.Accounts.CreditCards().List(ctx, userID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, cards, 0)
|
||||||
|
})
|
||||||
|
}
|
@ -5,6 +5,7 @@ package stripecoinpayments
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ import (
|
|||||||
"github.com/stripe/stripe-go/paymentmethod"
|
"github.com/stripe/stripe-go/paymentmethod"
|
||||||
|
|
||||||
"storj.io/common/storj"
|
"storj.io/common/storj"
|
||||||
|
"storj.io/common/testrand"
|
||||||
"storj.io/common/uuid"
|
"storj.io/common/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -63,7 +65,7 @@ func NewStripeMock(id storj.NodeID) StripeClient {
|
|||||||
if !ok {
|
if !ok {
|
||||||
mock = &mockStripeClient{
|
mock = &mockStripeClient{
|
||||||
customers: newMockCustomers(),
|
customers: newMockCustomers(),
|
||||||
paymentMethods: &mockPaymentMethods{},
|
paymentMethods: newMockPaymentMethods(),
|
||||||
invoices: &mockInvoices{},
|
invoices: &mockInvoices{},
|
||||||
invoiceItems: &mockInvoiceItems{},
|
invoiceItems: &mockInvoiceItems{},
|
||||||
customerBalanceTransactions: newMockCustomerBalanceTransactions(),
|
customerBalanceTransactions: newMockCustomerBalanceTransactions(),
|
||||||
@ -166,39 +168,102 @@ func (m *mockCustomers) Update(id string, params *stripe.CustomerParams) (*strip
|
|||||||
}
|
}
|
||||||
|
|
||||||
type mockPaymentMethods struct {
|
type mockPaymentMethods struct {
|
||||||
|
// attached contains a mapping of customerID to its paymentMethods
|
||||||
|
attached map[string][]*stripe.PaymentMethod
|
||||||
|
// unattached contains created but not attached paymentMethods
|
||||||
|
unattached []*stripe.PaymentMethod
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockPaymentMethods() *mockPaymentMethods {
|
||||||
|
return &mockPaymentMethods{
|
||||||
|
attached: map[string][]*stripe.PaymentMethod{},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPaymentMethods) List(listParams *stripe.PaymentMethodListParams) *paymentmethod.Iter {
|
func (m *mockPaymentMethods) List(listParams *stripe.PaymentMethodListParams) *paymentmethod.Iter {
|
||||||
values := []interface{}{
|
listMeta := stripe.ListMeta{
|
||||||
&stripe.PaymentMethod{
|
HasMore: false,
|
||||||
ID: "pm_card_mastercard",
|
TotalCount: uint32(len(m.attached)),
|
||||||
|
}
|
||||||
|
return &paymentmethod.Iter{Iter: stripe.GetIter(nil, func(*stripe.Params, *form.Values) ([]interface{}, stripe.ListMeta, error) {
|
||||||
|
mocks.Lock()
|
||||||
|
defer mocks.Unlock()
|
||||||
|
|
||||||
|
list, ok := m.attached[*listParams.Customer]
|
||||||
|
if !ok {
|
||||||
|
list = []*stripe.PaymentMethod{}
|
||||||
|
}
|
||||||
|
ret := make([]interface{}, len(list))
|
||||||
|
|
||||||
|
for i, v := range list {
|
||||||
|
ret[i] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret, listMeta, nil
|
||||||
|
})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockPaymentMethods) New(params *stripe.PaymentMethodParams) (*stripe.PaymentMethod, error) {
|
||||||
|
|
||||||
|
randID := testrand.BucketName()
|
||||||
|
newMethod := &stripe.PaymentMethod{
|
||||||
|
ID: fmt.Sprintf("pm_card_%s", randID),
|
||||||
Card: &stripe.PaymentMethodCard{
|
Card: &stripe.PaymentMethodCard{
|
||||||
ExpMonth: 12,
|
ExpMonth: 12,
|
||||||
ExpYear: 2050,
|
ExpYear: 2050,
|
||||||
Brand: "Mastercard",
|
Brand: "Mastercard",
|
||||||
Last4: "4444",
|
Last4: "4444",
|
||||||
|
Description: randID,
|
||||||
},
|
},
|
||||||
},
|
Type: stripe.PaymentMethodTypeCard,
|
||||||
}
|
}
|
||||||
listMeta := stripe.ListMeta{
|
|
||||||
HasMore: false,
|
|
||||||
TotalCount: uint32(len(values)),
|
|
||||||
}
|
|
||||||
return &paymentmethod.Iter{Iter: stripe.GetIter(nil, func(*stripe.Params, *form.Values) ([]interface{}, stripe.ListMeta, error) {
|
|
||||||
return values, listMeta, nil
|
|
||||||
})}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockPaymentMethods) New(params *stripe.PaymentMethodParams) (*stripe.PaymentMethod, error) {
|
mocks.Lock()
|
||||||
return nil, nil
|
defer mocks.Unlock()
|
||||||
|
|
||||||
|
m.unattached = append(m.unattached, newMethod)
|
||||||
|
|
||||||
|
return newMethod, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPaymentMethods) Attach(id string, params *stripe.PaymentMethodAttachParams) (*stripe.PaymentMethod, error) {
|
func (m *mockPaymentMethods) Attach(id string, params *stripe.PaymentMethodAttachParams) (*stripe.PaymentMethod, error) {
|
||||||
return nil, nil
|
var method *stripe.PaymentMethod
|
||||||
|
|
||||||
|
mocks.Lock()
|
||||||
|
defer mocks.Unlock()
|
||||||
|
|
||||||
|
for _, candidate := range m.unattached {
|
||||||
|
if candidate.ID == id {
|
||||||
|
method = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attached, ok := m.attached[*params.Customer]
|
||||||
|
if !ok {
|
||||||
|
attached = []*stripe.PaymentMethod{}
|
||||||
|
}
|
||||||
|
m.attached[*params.Customer] = append(attached, method)
|
||||||
|
return method, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPaymentMethods) Detach(id string, params *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
|
func (m *mockPaymentMethods) Detach(id string, params *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) {
|
||||||
return nil, nil
|
var unattached *stripe.PaymentMethod
|
||||||
|
|
||||||
|
mocks.Lock()
|
||||||
|
defer mocks.Unlock()
|
||||||
|
|
||||||
|
for user, userMethods := range m.attached {
|
||||||
|
var remaining []*stripe.PaymentMethod
|
||||||
|
for _, method := range userMethods {
|
||||||
|
if id == method.ID {
|
||||||
|
unattached = method
|
||||||
|
} else {
|
||||||
|
remaining = append(remaining, method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.attached[user] = remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
return unattached, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockInvoices struct {
|
type mockInvoices struct {
|
||||||
|
Loading…
Reference in New Issue
Block a user