Files
sendico/api/ledger/internal/service/ledger/helpers.go
2025-11-18 00:20:25 +01:00

167 lines
5.0 KiB
Go

package ledger
import (
"fmt"
"time"
"github.com/shopspring/decimal"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/protobuf/types/known/timestamppb"
)
// parseObjectID converts a hex string to ObjectID
func parseObjectID(hexID string) (primitive.ObjectID, error) {
if hexID == "" {
return primitive.NilObjectID, merrors.InvalidArgument("empty object ID")
}
oid, err := primitive.ObjectIDFromHex(hexID)
if err != nil {
return primitive.NilObjectID, merrors.InvalidArgument(fmt.Sprintf("invalid object ID: %v", err))
}
return oid, nil
}
// parseDecimal converts a string amount to decimal
func parseDecimal(amount string) (decimal.Decimal, error) {
if amount == "" {
return decimal.Zero, merrors.InvalidArgument("empty amount")
}
dec, err := decimal.NewFromString(amount)
if err != nil {
return decimal.Zero, merrors.InvalidArgument(fmt.Sprintf("invalid decimal amount: %v", err))
}
return dec, nil
}
// validateMoney checks that a Money message is valid
func validateMoney(m *moneyv1.Money, fieldName string) error {
if m == nil {
return merrors.InvalidArgument(fmt.Sprintf("%s: money is required", fieldName))
}
if m.Amount == "" {
return merrors.InvalidArgument(fmt.Sprintf("%s: amount is required", fieldName))
}
if m.Currency == "" {
return merrors.InvalidArgument(fmt.Sprintf("%s: currency is required", fieldName))
}
// Validate it's a valid decimal
if _, err := parseDecimal(m.Amount); err != nil {
return err
}
return nil
}
// validatePostingLines validates charge lines
func validatePostingLines(lines []*ledgerv1.PostingLine) error {
for i, line := range lines {
if line == nil {
return merrors.InvalidArgument(fmt.Sprintf("charges[%d]: nil posting line", i))
}
if line.LedgerAccountRef == "" {
return merrors.InvalidArgument(fmt.Sprintf("charges[%d]: ledger_account_ref is required", i))
}
if line.Money == nil {
return merrors.InvalidArgument(fmt.Sprintf("charges[%d]: money is required", i))
}
if err := validateMoney(line.Money, fmt.Sprintf("charges[%d].money", i)); err != nil {
return err
}
// Charges should not be MAIN type
if line.LineType == ledgerv1.LineType_LINE_MAIN {
return merrors.InvalidArgument(fmt.Sprintf("charges[%d]: cannot have LINE_MAIN type", i))
}
}
return nil
}
// getEventTime extracts event time from proto or defaults to now
func getEventTime(ts *timestamppb.Timestamp) time.Time {
if ts != nil && ts.IsValid() {
return ts.AsTime()
}
return time.Now().UTC()
}
// protoLineTypeToModel converts proto LineType to model LineType
func protoLineTypeToModel(lt ledgerv1.LineType) model.LineType {
switch lt {
case ledgerv1.LineType_LINE_MAIN:
return model.LineTypeMain
case ledgerv1.LineType_LINE_FEE:
return model.LineTypeFee
case ledgerv1.LineType_LINE_SPREAD:
return model.LineTypeSpread
case ledgerv1.LineType_LINE_REVERSAL:
return model.LineTypeReversal
default:
return model.LineTypeMain
}
}
// modelLineTypeToProto converts model LineType to proto LineType
func modelLineTypeToProto(lt model.LineType) ledgerv1.LineType {
switch lt {
case model.LineTypeMain:
return ledgerv1.LineType_LINE_MAIN
case model.LineTypeFee:
return ledgerv1.LineType_LINE_FEE
case model.LineTypeSpread:
return ledgerv1.LineType_LINE_SPREAD
case model.LineTypeReversal:
return ledgerv1.LineType_LINE_REVERSAL
default:
return ledgerv1.LineType_LINE_TYPE_UNSPECIFIED
}
}
// modelEntryTypeToProto converts model EntryType to proto EntryType
func modelEntryTypeToProto(et model.EntryType) ledgerv1.EntryType {
switch et {
case model.EntryTypeCredit:
return ledgerv1.EntryType_ENTRY_CREDIT
case model.EntryTypeDebit:
return ledgerv1.EntryType_ENTRY_DEBIT
case model.EntryTypeTransfer:
return ledgerv1.EntryType_ENTRY_TRANSFER
case model.EntryTypeFX:
return ledgerv1.EntryType_ENTRY_FX
case model.EntryTypeFee:
return ledgerv1.EntryType_ENTRY_FEE
case model.EntryTypeAdjust:
return ledgerv1.EntryType_ENTRY_ADJUST
case model.EntryTypeReverse:
return ledgerv1.EntryType_ENTRY_REVERSE
default:
return ledgerv1.EntryType_ENTRY_TYPE_UNSPECIFIED
}
}
// calculateBalance computes net balance from a set of posting lines
func calculateBalance(lines []*model.PostingLine) (decimal.Decimal, error) {
balance := decimal.Zero
for _, line := range lines {
amount, err := parseDecimal(line.Amount)
if err != nil {
return decimal.Zero, merrors.InvalidArgumentWrap(err, "invalid line amount")
}
balance = balance.Add(amount)
}
return balance, nil
}
// validateBalanced ensures posting lines sum to zero (double-entry accounting)
func validateBalanced(lines []*model.PostingLine) error {
balance, err := calculateBalance(lines)
if err != nil {
return err
}
if !balance.IsZero() {
return merrors.InvalidArgument(fmt.Sprintf("journal entry must balance (sum=0), got: %s", balance.String()))
}
return nil
}