package ledger import ( "context" "fmt" "time" "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" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "go.mongodb.org/mongo-driver/bson/primitive" "go.uber.org/zap" ) // postDebitResponder implements debit posting with charges func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitRequest) 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("debit") s.logger.Info("duplicate debit 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_DEBIT, }, nil } if err != nil && err != storage.ErrJournalEntryNotFound { 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 { return nil, merrors.NoData("account not found") } 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 { return nil, err } accountsByRef := map[primitive.ObjectID]*model.Account{accountRef: account} eventTime := getEventTime(req.EventTime) debitAmount, _ := parseDecimal(req.Money.Amount) entryTotal := debitAmount.Neg() charges := req.Charges if len(charges) == 0 { if computed, err := s.quoteFeesForDebit(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: debitAmount.Neg().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("debit", "contra_resolve_failed") return nil, err } contraAccountID := contraAccount.GetID() if contraAccountID == nil { recordJournalEntryError("debit", "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("debit", "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.EntryTypeDebit, 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_DEBIT, }, nil }) if err != nil { recordJournalEntryError("debit", "transaction_failed") return nil, err } amountFloat, _ := debitAmount.Float64() recordTransactionAmount(req.Money.Currency, "debit", amountFloat) recordJournalEntry("debit", "success", 0) return result.(*ledgerv1.PostResponse), nil } }