service backend
This commit is contained in:
239
api/ledger/internal/service/ledger/posting.go
Normal file
239
api/ledger/internal/service/ledger/posting.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
ledgerv1 "github.com/tech/sendico/ledger/internal/generated/service/ledger/v1"
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
"github.com/tech/sendico/ledger/storage/model"
|
||||
storageMongo "github.com/tech/sendico/ledger/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"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")
|
||||
}
|
||||
if req.LedgerAccountRef == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_ref is required")
|
||||
}
|
||||
if err := validateMoney(req.Money, "money"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgRef, err := parseObjectID(req.OrganizationRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accountRef, err := parseObjectID(req.LedgerAccountRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
|
||||
if err == nil && existingEntry != nil {
|
||||
recordDuplicateRequest("credit")
|
||||
s.logger.Info("duplicate credit request (idempotency)",
|
||||
zap.String("idempotencyKey", req.IdempotencyKey),
|
||||
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("credit", "idempotency_check_failed")
|
||||
s.logger.Warn("failed to check idempotency", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to check idempotency")
|
||||
}
|
||||
|
||||
account, err := s.storage.Accounts().Get(ctx, accountRef)
|
||||
if err != nil {
|
||||
if err == storage.ErrAccountNotFound {
|
||||
recordJournalEntryError("credit", "account_not_found")
|
||||
return nil, merrors.NoData("account not found")
|
||||
}
|
||||
recordJournalEntryError("credit", "account_lookup_failed")
|
||||
s.logger.Warn("failed to get account", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to get account")
|
||||
}
|
||||
if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil {
|
||||
recordJournalEntryError("credit", "account_invalid")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accountsByRef := map[primitive.ObjectID]*model.Account{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 {
|
||||
s.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: primitive.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))
|
||||
}
|
||||
s.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: primitive.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("credit", "contra_resolve_failed")
|
||||
return nil, err
|
||||
}
|
||||
contraAccountID := contraAccount.GetID()
|
||||
if contraAccountID == nil {
|
||||
recordJournalEntryError("credit", "contra_missing_id")
|
||||
return nil, merrors.Internal("contra account missing identifier")
|
||||
}
|
||||
|
||||
contraAmount := entryTotal.Neg()
|
||||
if !contraAmount.IsZero() || len(postingLines) == 1 {
|
||||
contraLine := &model.PostingLine{
|
||||
JournalEntryRef: primitive.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("credit", "unbalanced_after_contra")
|
||||
return nil, merrors.Internal("failed to balance journal entry")
|
||||
}
|
||||
|
||||
mongoStore, ok := s.storage.(*storageMongo.Store)
|
||||
if !ok {
|
||||
return nil, merrors.Internal("storage does not support transactions")
|
||||
}
|
||||
|
||||
result, err := mongoStore.TransactionFactory().CreateTransaction().Execute(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 {
|
||||
s.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 {
|
||||
s.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("credit", "transaction_failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
amountFloat, _ := creditAmount.Float64()
|
||||
recordTransactionAmount(req.Money.Currency, "credit", amountFloat)
|
||||
recordJournalEntry("credit", "success", 0)
|
||||
return result.(*ledgerv1.PostResponse), nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user