Files
sendico/api/ledger/internal/service/ledger/posting.go

250 lines
8.6 KiB
Go

package ledger
import (
"context"
"fmt"
"strings"
"time"
"github.com/tech/sendico/ledger/storage"
"github.com/tech/sendico/ledger/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/model/account_role"
"github.com/tech/sendico/pkg/mutil/mzap"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
const ledgerOutboxSubject = "ledger.entry.posted"
// postCreditResponder implements credit posting with charges
func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCreditRequest) gsresponse.Responder[ledgerv1.PostResponse] {
return func(ctx context.Context) (*ledgerv1.PostResponse, error) {
if req.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("idempotency_key is required")
}
if req.OrganizationRef == "" {
return nil, merrors.InvalidArgument("organization_ref is required")
}
roleModel := account_role.AccountRole("")
if req.Role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
var err error
roleModel, err = protoAccountRoleToModel(req.Role)
if err != nil {
return nil, err
}
} else if strings.TrimSpace(req.LedgerAccountRef) == "" {
roleModel = account_role.AccountRoleOperating
}
if strings.TrimSpace(req.LedgerAccountRef) == "" && roleModel == "" {
return nil, merrors.InvalidArgument("ledger_account_ref or role is required")
}
if err := validateMoney(req.Money, "money"); err != nil {
return nil, err
}
orgRef, err := parseObjectID(req.OrganizationRef)
if err != nil {
return nil, err
}
logger := s.logger.With(
zap.String("idempotency_key", req.IdempotencyKey),
mzap.ObjRef("organization_ref", orgRef),
zap.String("ledger_account_ref", strings.TrimSpace(req.LedgerAccountRef)),
zap.String("currency", req.Money.Currency),
)
if roleModel != "" {
logger = logger.With(zap.String("role", string(roleModel)))
}
if strings.TrimSpace(req.ContraLedgerAccountRef) != "" {
logger = logger.With(zap.String("contra_ledger_account_ref", strings.TrimSpace(req.ContraLedgerAccountRef)))
}
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil {
recordDuplicateRequest(journalEntryTypeCredit)
logger.Info("Duplicate credit request (idempotency)",
zap.String("existingEntryID", existingEntry.GetID().Hex()))
return &ledgerv1.PostResponse{
JournalEntryRef: existingEntry.GetID().Hex(),
Version: existingEntry.Version,
EntryType: ledgerv1.EntryType_ENTRY_CREDIT,
}, nil
}
if err != nil && err != storage.ErrJournalEntryNotFound {
recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorIdempotencyCheck)
logger.Warn("Failed to check idempotency", zap.Error(err))
return nil, merrors.Internal("failed to check idempotency")
}
account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account")
if err != nil {
recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorAccountResolve)
return nil, err
}
if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil {
recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorAccountInvalid)
return nil, err
}
accountsByRef := map[bson.ObjectID]*pmodel.LedgerAccount{accountRef: account}
eventTime := getEventTime(req.EventTime)
creditAmount, _ := parseDecimal(req.Money.Amount)
entryTotal := creditAmount
charges := req.Charges
if len(charges) == 0 {
if computed, err := s.quoteFeesForCredit(ctx, req); err != nil {
logger.Warn("Failed to quote fees", zap.Error(err))
} else if len(computed) > 0 {
charges = computed
}
}
if err := validatePostingLines(charges); err != nil {
return nil, err
}
postingLines := make([]*model.PostingLine, 0, 2+len(charges))
mainLine := &model.PostingLine{
JournalEntryRef: bson.NilObjectID,
AccountRef: accountRef,
Amount: creditAmount.String(),
Currency: req.Money.Currency,
LineType: model.LineTypeMain,
}
mainLine.OrganizationRef = orgRef
postingLines = append(postingLines, mainLine)
for i, charge := range charges {
chargeAccountRef, err := parseObjectID(charge.LedgerAccountRef)
if err != nil {
return nil, err
}
if charge.Money.Currency != req.Money.Currency {
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: currency mismatch", i))
}
chargeAccount, err := s.getAccount(ctx, accountsByRef, chargeAccountRef)
if err != nil {
if err == storage.ErrAccountNotFound {
return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i))
}
logger.Warn("Failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex()))
return nil, merrors.Internal("failed to get charge account")
}
if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil {
return nil, merrors.InvalidArgument(fmt.Sprintf("charges[%d]: %s", i, err.Error()))
}
chargeAmount, err := parseDecimal(charge.Money.Amount)
if err != nil {
return nil, err
}
entryTotal = entryTotal.Add(chargeAmount)
chargeLine := &model.PostingLine{
JournalEntryRef: bson.NilObjectID,
AccountRef: chargeAccountRef,
Amount: chargeAmount.String(),
Currency: charge.Money.Currency,
LineType: protoLineTypeToModel(charge.LineType),
}
chargeLine.OrganizationRef = orgRef
postingLines = append(postingLines, chargeLine)
}
contraAccount, err := s.resolveSettlementAccount(ctx, orgRef, req.Money.Currency, req.ContraLedgerAccountRef, accountsByRef)
if err != nil {
recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorContraResolve)
return nil, err
}
contraAccountID := contraAccount.GetID()
if contraAccountID == nil {
recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorContraMissingID)
return nil, merrors.Internal("contra account missing identifier")
}
contraAmount := entryTotal.Neg()
if !contraAmount.IsZero() || len(postingLines) == 1 {
contraLine := &model.PostingLine{
JournalEntryRef: bson.NilObjectID,
AccountRef: *contraAccountID,
Amount: contraAmount.String(),
Currency: req.Money.Currency,
LineType: model.LineTypeMain,
}
contraLine.OrganizationRef = orgRef
postingLines = append(postingLines, contraLine)
entryTotal = entryTotal.Add(contraAmount)
}
if !entryTotal.IsZero() {
recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorUnbalancedAfterContra)
return nil, merrors.Internal("failed to balance journal entry")
}
result, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) {
entry := &model.JournalEntry{
IdempotencyKey: req.IdempotencyKey,
EventTime: eventTime,
EntryType: model.EntryTypeCredit,
Description: req.Description,
Metadata: req.Metadata,
Version: time.Now().UnixNano(),
}
entry.OrganizationRef = orgRef
if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil {
logger.Warn("Failed to create journal entry", zap.Error(err))
return nil, merrors.Internal("failed to create journal entry")
}
entryRef := entry.GetID()
if entryRef == nil {
return nil, merrors.Internal("journal entry missing identifier")
}
for _, line := range postingLines {
line.JournalEntryRef = *entryRef
}
if err := validateBalanced(postingLines); err != nil {
return nil, err
}
if err := s.storage.PostingLines().CreateMany(txCtx, postingLines); err != nil {
logger.Warn("Failed to create posting lines", zap.Error(err))
return nil, merrors.Internal("failed to create posting lines")
}
if err := s.upsertBalances(txCtx, postingLines, accountsByRef); err != nil {
return nil, err
}
if err := s.enqueueOutbox(txCtx, entry, postingLines); err != nil {
return nil, err
}
return &ledgerv1.PostResponse{
JournalEntryRef: entryRef.Hex(),
Version: entry.Version,
EntryType: ledgerv1.EntryType_ENTRY_CREDIT,
}, nil
})
if err != nil {
recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorTransactionFailed)
return nil, err
}
amountFloat, _ := creditAmount.Float64()
recordTransactionAmount(req.Money.Currency, journalEntryTypeCredit, amountFloat)
recordJournalEntry(journalEntryTypeCredit, journalEntryStatusSuccess, 0)
return result.(*ledgerv1.PostResponse), nil
}
}