service backend
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful

This commit is contained in:
Stephan D
2025-11-07 18:35:26 +01:00
parent 20e8f9acc4
commit 62a6631b9a
537 changed files with 48453 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
)
const (
FeePlansCollection = "fee_plans"
)
// Trigger represents the event that causes a fee rule to apply.
type Trigger string
const (
TriggerUnspecified Trigger = "unspecified"
TriggerCapture Trigger = "capture"
TriggerRefund Trigger = "refund"
TriggerDispute Trigger = "dispute"
TriggerPayout Trigger = "payout"
TriggerFXConversion Trigger = "fx_conversion"
)
// FeePlan describes a collection of fee rules for an organisation.
type FeePlan struct {
storable.Base `bson:",inline" json:",inline"`
model.OrganizationBoundBase `bson:",inline" json:",inline"`
model.Describable `bson:",inline" json:",inline"`
Active bool `bson:"active" json:"active"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
}
// Collection implements storable.Storable.
func (*FeePlan) Collection() string {
return FeePlansCollection
}
// FeeRule represents a single pricing rule within a plan.
type FeeRule struct {
RuleID string `bson:"ruleId" json:"ruleId"`
Trigger Trigger `bson:"trigger" json:"trigger"`
Priority int `bson:"priority" json:"priority"`
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
}

View File

@@ -0,0 +1,69 @@
package mongo
import (
"context"
"time"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/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/mongo"
"go.uber.org/zap"
)
type Store struct {
logger mlogger.Logger
conn *db.MongoConnection
db *mongo.Database
plans storage.PlansStore
}
// New creates a repository backed by MongoDB for the billing fees service.
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
if conn == nil {
return nil, merrors.InvalidArgument("mongo connection is nil")
}
client := conn.Client()
if client == nil {
return nil, merrors.Internal("mongo client not initialised")
}
database := conn.Database()
result := &Store{
logger: logger.Named("storage").Named("mongo"),
conn: conn,
db: database,
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := result.Ping(ctx); err != nil {
result.logger.Error("mongo ping failed during store init", zap.Error(err))
return nil, err
}
plansStore, err := store.NewPlans(result.logger, database)
if err != nil {
result.logger.Error("failed to initialise plans store", zap.Error(err))
return nil, err
}
result.plans = plansStore
result.logger.Info("Billing fees MongoDB storage initialised")
return result, nil
}
func (s *Store) Ping(ctx context.Context) error {
return s.conn.Ping(ctx)
}
func (s *Store) Plans() storage.PlansStore {
return s.plans
}
var _ storage.Repository = (*Store)(nil)

View File

@@ -0,0 +1,144 @@
package store
import (
"context"
"errors"
"time"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
m "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type plansStore struct {
logger mlogger.Logger
repo repository.Repository
}
// NewPlans constructs a Mongo-backed PlansStore.
func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, error) {
repo := repository.CreateMongoRepository(db, mservice.FeePlans)
// Index for organisation lookups.
orgIndex := &ri.Definition{
Keys: []ri.Key{
{Field: m.OrganizationRefField, Sort: ri.Asc},
{Field: "effectiveFrom", Sort: ri.Desc},
},
}
if err := repo.CreateIndex(orgIndex); err != nil {
logger.Error("failed to ensure fee plan organization index", zap.Error(err))
return nil, err
}
// Unique index for plan versions (per organisation + effectiveFrom).
uniqueIndex := &ri.Definition{
Keys: []ri.Key{
{Field: m.OrganizationRefField, Sort: ri.Asc},
{Field: "effectiveFrom", Sort: ri.Asc},
},
Unique: true,
}
if err := repo.CreateIndex(uniqueIndex); err != nil {
logger.Error("failed to ensure fee plan uniqueness index", zap.Error(err))
return nil, err
}
return &plansStore{
logger: logger.Named("plans"),
repo: repo,
}, nil
}
func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
if plan == nil {
return merrors.InvalidArgument("plansStore: nil fee plan")
}
if err := p.repo.Insert(ctx, plan, nil); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
return storage.ErrDuplicateFeePlan
}
p.logger.Warn("failed to create fee plan", zap.Error(err))
return err
}
return nil
}
func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error {
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
}
if err := p.repo.Update(ctx, plan); err != nil {
p.logger.Warn("failed to update fee plan", zap.Error(err))
return err
}
return nil
}
func (p *plansStore) Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error) {
if planRef.IsZero() {
return nil, merrors.InvalidArgument("plansStore: zero plan reference")
}
result := &model.FeePlan{}
if err := p.repo.Get(ctx, planRef, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil, storage.ErrFeePlanNotFound
}
return nil, err
}
return result, nil
}
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
if orgRef.IsZero() {
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
}
limit := int64(1)
query := repository.Query().
Filter(repository.OrgField(), orgRef).
Filter(repository.Field("active"), true).
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
Sort(repository.Field("effectiveFrom"), false).
Limit(&limit)
query = query.And(
repository.Query().Or(
repository.Query().Filter(repository.Field("effectiveTo"), nil),
repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, at),
),
)
var plan *model.FeePlan
decoder := func(cursor *mongo.Cursor) error {
target := &model.FeePlan{}
if err := cursor.Decode(target); err != nil {
return err
}
plan = target
return nil
}
if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil, storage.ErrFeePlanNotFound
}
return nil, err
}
if plan == nil {
return nil, storage.ErrFeePlanNotFound
}
return plan, nil
}
var _ storage.PlansStore = (*plansStore)(nil)

View File

@@ -0,0 +1,36 @@
package storage
import (
"context"
"time"
"github.com/tech/sendico/billing/fees/storage/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type storageError string
func (e storageError) Error() string {
return string(e)
}
var (
// ErrFeePlanNotFound indicates that a requested fee plan does not exist.
ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found")
// ErrDuplicateFeePlan indicates that a unique plan constraint was violated.
ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan")
)
// Repository defines the root storage contract for the fees service.
type Repository interface {
Ping(ctx context.Context) error
Plans() PlansStore
}
// PlansStore exposes persistence operations for fee plans.
type PlansStore interface {
Create(ctx context.Context, plan *model.FeePlan) error
Update(ctx context.Context, plan *model.FeePlan) error
Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error)
GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
}