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/mutil/mzap" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "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") } roleModel := pmodel.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 = pmodel.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("credit") 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("credit", "idempotency_check_failed") 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("credit", "account_resolve_failed") return nil, err } if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil { recordJournalEntryError("credit", "account_invalid") return nil, err } accountsByRef := map[primitive.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: 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)) } 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") } 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("credit", "transaction_failed") return nil, err } amountFloat, _ := creditAmount.Float64() recordTransactionAmount(req.Money.Currency, "credit", amountFloat) recordJournalEntry("credit", "success", 0) return result.(*ledgerv1.PostResponse), nil } }