refactored payment orchestration

This commit is contained in:
Stephan D
2026-02-03 00:40:46 +01:00
parent 05d998e0f7
commit 5e87e2f2f9
184 changed files with 3920 additions and 2219 deletions

View File

@@ -0,0 +1,29 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
)
// CardPayout is a Mongo/JSON representation of proto CardPayout
type CardPayout struct {
storable.Base `bson:",inline" json:",inline"`
PayoutID string `bson:"payoutId" json:"payout_id"`
PaymentRef string `bson:"paymentRef" json:"payment_ref"`
OperationRef string `bson:"operationRef" json:"operation_ref"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotency_key"`
IntentRef string `bson:"intentRef" json:"intentRef"`
ProjectID int64 `bson:"projectId" json:"project_id"`
CustomerID string `bson:"customerId" json:"customer_id"`
AmountMinor int64 `bson:"amountMinor" json:"amount_minor"`
Currency string `bson:"currency" json:"currency"`
Status PayoutStatus `bson:"status" json:"status"`
ProviderCode string `bson:"providerCode,omitempty" json:"provider_code,omitempty"`
ProviderMessage string `bson:"providerMessage,omitempty" json:"provider_message,omitempty"`
ProviderPaymentID string `bson:"providerPaymentId,omitempty" json:"provider_payment_id,omitempty"`
FailureReason string `bson:"failureReason,omitempty" json:"failure_reason,omitempty"`
CreatedAt time.Time `bson:"createdAt" json:"created_at"`
UpdatedAt time.Time `bson:"updatedAt" json:"updated_at"`
}

View File

@@ -0,0 +1,13 @@
package model
type PayoutStatus string
const (
PayoutStatusCreated PayoutStatus = "created" // record exists, not started
PayoutStatusProcessing PayoutStatus = "processing" // we are working on it
PayoutStatusWaiting PayoutStatus = "waiting" // waiting external world
PayoutStatusSuccess PayoutStatus = "success" // final success
PayoutStatusFailed PayoutStatus = "failed" // final failure
PayoutStatusCancelled PayoutStatus = "cancelled" // final cancelled
)

View File

@@ -0,0 +1,69 @@
package mongo
import (
"context"
"time"
"github.com/tech/sendico/gateway/mntx/storage"
"github.com/tech/sendico/gateway/mntx/storage/mongo/store"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap"
)
type Repository struct {
logger mlogger.Logger
conn *db.MongoConnection
db *mongo.Database
payouts storage.PayoutsStore
}
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
if logger == nil {
logger = zap.NewNop()
}
if conn == nil {
return nil, merrors.InvalidArgument("mongo connection is nil")
}
client := conn.Client()
if client == nil {
return nil, merrors.Internal("mongo client is not initialised")
}
db := conn.Database()
if db == nil {
return nil, merrors.Internal("mongo database is not initialised")
}
dbName := db.Name()
logger = logger.Named("storage").Named("mongo")
if dbName != "" {
logger = logger.With(zap.String("database", dbName))
}
result := &Repository{
logger: logger,
conn: conn,
db: db,
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := result.conn.Ping(ctx); err != nil {
result.logger.Error("Mongo ping failed during repository initialisation", zap.Error(err))
return nil, err
}
payoutsStore, err := store.NewPayouts(result.logger, result.db)
if err != nil {
result.logger.Error("Failed to initialise payouts store", zap.Error(err), zap.String("store", "payments"))
return nil, err
}
result.payouts = payoutsStore
result.logger.Info("Payouts gateway MongoDB storage initialised")
return result, nil
}
func (r *Repository) Payouts() storage.PayoutsStore {
return r.payouts
}
var _ storage.Repository = (*Repository)(nil)

View File

@@ -0,0 +1,137 @@
package store
import (
"context"
"errors"
"strings"
"time"
storage "github.com/tech/sendico/gateway/mntx/storage"
"github.com/tech/sendico/gateway/mntx/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.uber.org/zap"
)
const (
payoutsCollection = "card_payouts"
payoutIdemField = "idempotencyKey"
payoutIdField = "payoutId"
)
type Payouts struct {
logger mlogger.Logger
coll *mongo.Collection
}
func NewPayouts(logger mlogger.Logger, db *mongo.Database) (*Payouts, error) {
if db == nil {
return nil, merrors.InvalidArgument("mongo database is nil")
}
if logger == nil {
logger = zap.NewNop()
}
logger = logger.Named("payouts").With(zap.String("collection", payoutsCollection))
repo := repository.CreateMongoRepository(db, payoutsCollection)
if err := repo.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: payoutIdemField, Sort: ri.Asc}},
Unique: true,
}); err != nil {
logger.Error("Failed to create payouts idempotency index",
zap.Error(err),
zap.String("index_field", payoutIdemField))
return nil, err
}
p := &Payouts{
logger: logger,
coll: db.Collection(payoutsCollection),
}
p.logger.Debug("Payouts store initialised")
return p, nil
}
func (p *Payouts) findOneByField(ctx context.Context, field, value string) (*model.CardPayout, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, merrors.InvalidArgument("lookup key is required", field)
}
var result model.CardPayout
err := p.coll.FindOne(ctx, bson.M{field: value}).Decode(&result)
if err == mongo.ErrNoDocuments {
return nil, nil
}
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
p.logger.Warn("Payout record lookup failed",
zap.String("field", field),
zap.String("value", value),
zap.Error(err))
}
return nil, err
}
return &result, nil
}
func (p *Payouts) FindByIdempotencyKey(ctx context.Context, key string) (*model.CardPayout, error) {
return p.findOneByField(ctx, payoutIdemField, key) // operationRef
}
func (p *Payouts) FindByPaymentID(ctx context.Context, paymentID string) (*model.CardPayout, error) {
return p.findOneByField(ctx, payoutIdField, paymentID)
}
func (p *Payouts) Upsert(ctx context.Context, record *model.CardPayout) error {
if record == nil {
return merrors.InvalidArgument("payout record is nil", "record")
}
record.OperationRef = strings.TrimSpace(record.OperationRef)
record.PayoutID = strings.TrimSpace(record.PayoutID)
record.CustomerID = strings.TrimSpace(record.CustomerID)
record.ProviderCode = strings.TrimSpace(record.ProviderCode)
record.ProviderPaymentID = strings.TrimSpace(record.ProviderPaymentID)
if record.OperationRef == "" {
return merrors.InvalidArgument("operation ref is required", "operation_ref")
}
now := time.Now()
if record.CreatedAt.IsZero() {
record.CreatedAt = now
}
record.UpdatedAt = now
// critical: never let caller override _id
record.ID = bson.NilObjectID
update := bson.M{
"$set": record,
}
_, err := p.coll.UpdateOne(
ctx,
bson.M{payoutIdemField: record.OperationRef},
update,
options.UpdateOne().SetUpsert(true),
)
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
p.logger.Warn("Failed to upsert payout record",
zap.String("operation_ref", record.OperationRef),
zap.String("payout_id", record.PayoutID),
zap.Error(err))
}
return err
}
return nil
}
var _ storage.PayoutsStore = (*Payouts)(nil)

View File

@@ -0,0 +1,20 @@
package storage
import (
"context"
"github.com/tech/sendico/gateway/mntx/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
var ErrDuplicate = merrors.DataConflict("payment gateway storage: duplicate record")
type Repository interface {
Payouts() PayoutsStore
}
type PayoutsStore interface {
FindByIdempotencyKey(ctx context.Context, key string) (*model.CardPayout, error)
FindByPaymentID(ctx context.Context, key string) (*model.CardPayout, error)
Upsert(ctx context.Context, record *model.CardPayout) error
}