167 lines
5.0 KiB
Go
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
|
|
}
|