billing docs service

This commit is contained in:
Stephan D
2026-01-30 15:16:20 +01:00
parent 51f5b0804a
commit 7fbd88b6ef
34 changed files with 2728 additions and 18 deletions

View File

@@ -0,0 +1,92 @@
package model
import (
"strings"
"time"
"github.com/shopspring/decimal"
"github.com/tech/sendico/pkg/db/storable"
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
)
const (
DocumentRecordsCollection = "document_records"
)
// DocumentType mirrors the protobuf enum but stores string names for Mongo compatibility.
type DocumentType string
const (
DocumentTypeUnspecified DocumentType = "DOCUMENT_TYPE_UNSPECIFIED"
DocumentTypeInvoice DocumentType = "DOCUMENT_TYPE_INVOICE"
DocumentTypeAct DocumentType = "DOCUMENT_TYPE_ACT"
DocumentTypeReceipt DocumentType = "DOCUMENT_TYPE_RECEIPT"
)
// DocumentTypeFromProto converts a protobuf enum to the storage representation.
func DocumentTypeFromProto(t documentsv1.DocumentType) DocumentType {
if name, ok := documentsv1.DocumentType_name[int32(t)]; ok {
return DocumentType(name)
}
return DocumentTypeUnspecified
}
// Proto converts the storage representation to a protobuf enum.
func (t DocumentType) Proto() documentsv1.DocumentType {
if value, ok := documentsv1.DocumentType_value[string(t)]; ok {
return documentsv1.DocumentType(value)
}
return documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
}
// ActSnapshot captures the immutable data needed to generate an acceptance act.
type ActSnapshot struct {
PaymentID string `bson:"paymentId" json:"paymentId"`
Date time.Time `bson:"date" json:"date"`
ExecutorFullName string `bson:"executorFullName" json:"executorFullName"`
Amount decimal.Decimal `bson:"amount" json:"amount"`
Currency string `bson:"currency" json:"currency"`
OrgLegalName string `bson:"orgLegalName" json:"orgLegalName"`
OrgAddress string `bson:"orgAddress" json:"orgAddress"`
}
func (s *ActSnapshot) Normalize() {
if s == nil {
return
}
s.PaymentID = strings.TrimSpace(s.PaymentID)
s.ExecutorFullName = strings.TrimSpace(s.ExecutorFullName)
s.Currency = strings.TrimSpace(s.Currency)
s.OrgLegalName = strings.TrimSpace(s.OrgLegalName)
s.OrgAddress = strings.TrimSpace(s.OrgAddress)
}
// DocumentRecord stores document metadata and cached artefacts for a payment.
type DocumentRecord struct {
storable.Base `bson:",inline" json:",inline"`
PaymentRef string `bson:"paymentRef" json:"paymentRef"`
Snapshot ActSnapshot `bson:"snapshot" json:"snapshot"`
Available []DocumentType `bson:"availableTypes,omitempty" json:"availableTypes,omitempty"`
Ready []DocumentType `bson:"readyTypes,omitempty" json:"readyTypes,omitempty"`
StoragePaths map[DocumentType]string `bson:"storagePaths,omitempty" json:"storagePaths,omitempty"`
Hashes map[DocumentType]string `bson:"hashes,omitempty" json:"hashes,omitempty"`
}
func (r *DocumentRecord) Normalize() {
if r == nil {
return
}
r.PaymentRef = strings.TrimSpace(r.PaymentRef)
r.Snapshot.Normalize()
if r.StoragePaths == nil {
r.StoragePaths = map[DocumentType]string{}
}
if r.Hashes == nil {
r.Hashes = map[DocumentType]string{}
}
}
// Collection implements storable.Storable.
func (*DocumentRecord) Collection() string {
return DocumentRecordsCollection
}

View File

@@ -0,0 +1,68 @@
package mongo
import (
"context"
"time"
"github.com/tech/sendico/billing/documents/storage"
"github.com/tech/sendico/billing/documents/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
documents storage.DocumentsStore
}
// New creates a repository backed by MongoDB for the billing documents 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
}
documentsStore, err := store.NewDocuments(result.logger, database)
if err != nil {
result.logger.Error("failed to initialise documents store", zap.Error(err))
return nil, err
}
result.documents = documentsStore
result.logger.Info("Billing documents MongoDB storage initialised")
return result, nil
}
func (s *Store) Ping(ctx context.Context) error {
return s.conn.Ping(ctx)
}
func (s *Store) Documents() storage.DocumentsStore {
return s.documents
}
var _ storage.Repository = (*Store)(nil)

View File

@@ -0,0 +1,145 @@
package store
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/billing/documents/storage"
"github.com/tech/sendico/billing/documents/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"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type Documents struct {
logger mlogger.Logger
repo repository.Repository
}
// NewDocuments constructs a Mongo-backed documents store.
func NewDocuments(logger mlogger.Logger, db *mongo.Database) (*Documents, error) {
if db == nil {
return nil, merrors.InvalidArgument("documentsStore: database is nil")
}
repo := repository.CreateMongoRepository(db, model.DocumentRecordsCollection)
indexes := []*ri.Definition{
{
Keys: []ri.Key{{Field: "paymentRef", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "availableTypes", Sort: ri.Asc}},
},
{
Keys: []ri.Key{{Field: "readyTypes", Sort: ri.Asc}},
},
}
for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure documents index", zap.Error(err), zap.String("collection", repo.Collection()))
return nil, err
}
}
childLogger := logger.Named("documents")
childLogger.Debug("documents store initialised")
return &Documents{
logger: childLogger,
repo: repo,
}, nil
}
func (d *Documents) Create(ctx context.Context, record *model.DocumentRecord) error {
if record == nil {
return merrors.InvalidArgument("documentsStore: nil record")
}
record.Normalize()
if record.PaymentRef == "" {
return merrors.InvalidArgument("documentsStore: empty paymentRef")
}
record.Update()
if err := d.repo.Insert(ctx, record, repository.Filter("paymentRef", record.PaymentRef)); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
return storage.ErrDuplicateDocument
}
return err
}
d.logger.Debug("document record created", zap.String("payment_ref", record.PaymentRef))
return nil
}
func (d *Documents) Update(ctx context.Context, record *model.DocumentRecord) error {
if record == nil {
return merrors.InvalidArgument("documentsStore: nil record")
}
if record.ID.IsZero() {
return merrors.InvalidArgument("documentsStore: missing record id")
}
record.Normalize()
record.Update()
if err := d.repo.Update(ctx, record); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return storage.ErrDocumentNotFound
}
return err
}
return nil
}
func (d *Documents) GetByPaymentRef(ctx context.Context, paymentRef string) (*model.DocumentRecord, error) {
paymentRef = strings.TrimSpace(paymentRef)
if paymentRef == "" {
return nil, merrors.InvalidArgument("documentsStore: empty paymentRef")
}
entity := &model.DocumentRecord{}
if err := d.repo.FindOneByFilter(ctx, repository.Filter("paymentRef", paymentRef), entity); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil, storage.ErrDocumentNotFound
}
return nil, err
}
return entity, nil
}
func (d *Documents) ListByPaymentRefs(ctx context.Context, paymentRefs []string) ([]*model.DocumentRecord, error) {
refs := make([]string, 0, len(paymentRefs))
for _, ref := range paymentRefs {
clean := strings.TrimSpace(ref)
if clean == "" {
continue
}
refs = append(refs, clean)
}
if len(refs) == 0 {
return []*model.DocumentRecord{}, nil
}
query := repository.Query().Comparison(repository.Field("paymentRef"), builder.In, refs)
records := make([]*model.DocumentRecord, 0)
decoder := func(cur *mongo.Cursor) error {
var rec model.DocumentRecord
if err := cur.Decode(&rec); err != nil {
d.logger.Warn("failed to decode document record", zap.Error(err))
return err
}
records = append(records, &rec)
return nil
}
if err := d.repo.FindManyByFilter(ctx, query, decoder); err != nil {
return nil, err
}
return records, nil
}
var _ storage.DocumentsStore = (*Documents)(nil)

View File

@@ -0,0 +1,32 @@
package storage
import (
"context"
"github.com/tech/sendico/billing/documents/storage/model"
)
type storageError string
func (e storageError) Error() string {
return string(e)
}
var (
ErrDocumentNotFound = storageError("billing.documents.storage: document record not found")
ErrDuplicateDocument = storageError("billing.documents.storage: duplicate document record")
)
// Repository defines the root storage contract for the billing documents service.
type Repository interface {
Ping(ctx context.Context) error
Documents() DocumentsStore
}
// DocumentsStore exposes persistence operations for document records.
type DocumentsStore interface {
Create(ctx context.Context, record *model.DocumentRecord) error
Update(ctx context.Context, record *model.DocumentRecord) error
GetByPaymentRef(ctx context.Context, paymentRef string) (*model.DocumentRecord, error)
ListByPaymentRefs(ctx context.Context, paymentRefs []string) ([]*model.DocumentRecord, error)
}