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 }