514 lines
18 KiB
Go
514 lines
18 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"
|
|
)
|
|
|
|
func (s *Service) postExternalCreditResponder(_ 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
|
|
}
|
|
}
|
|
if strings.TrimSpace(req.LedgerAccountRef) == "" && roleModel == "" {
|
|
return nil, merrors.InvalidArgument("ledger_account_ref or role is required")
|
|
}
|
|
if strings.TrimSpace(req.ContraLedgerAccountRef) != "" {
|
|
return nil, merrors.InvalidArgument("contra_ledger_account_ref is not allowed for external credit")
|
|
}
|
|
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)))
|
|
}
|
|
|
|
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
|
|
if err == nil && existingEntry != nil {
|
|
recordDuplicateRequest(journalEntryTypeCredit)
|
|
logger.Info("Duplicate external 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
|
|
}
|
|
|
|
systemAccount, err := s.systemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, req.Money.Currency)
|
|
if err != nil {
|
|
recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorSystemAccountResolve)
|
|
return nil, err
|
|
}
|
|
if err := validateSystemAccount(systemAccount, pmodel.SystemAccountPurposeExternalSource, req.Money.Currency); err != nil {
|
|
recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorSystemAccountInvalid)
|
|
return nil, err
|
|
}
|
|
|
|
systemAccountID := systemAccount.GetID()
|
|
if systemAccountID == nil {
|
|
recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorSystemAccountMissing)
|
|
return nil, merrors.Internal("system account missing identifier")
|
|
}
|
|
|
|
accountsByRef := map[bson.ObjectID]*pmodel.LedgerAccount{
|
|
accountRef: account,
|
|
*systemAccountID: systemAccount,
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
contraAmount := entryTotal.Neg()
|
|
if !contraAmount.IsZero() || len(postingLines) == 1 {
|
|
contraLine := &model.PostingLine{
|
|
JournalEntryRef: bson.NilObjectID,
|
|
AccountRef: *systemAccountID,
|
|
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
|
|
}
|
|
}
|
|
|
|
func (s *Service) postExternalDebitResponder(_ 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")
|
|
}
|
|
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
|
|
}
|
|
}
|
|
if strings.TrimSpace(req.LedgerAccountRef) == "" && roleModel == "" {
|
|
return nil, merrors.InvalidArgument("ledger_account_ref or role is required")
|
|
}
|
|
if strings.TrimSpace(req.ContraLedgerAccountRef) != "" {
|
|
return nil, merrors.InvalidArgument("contra_ledger_account_ref is not allowed for external debit")
|
|
}
|
|
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)))
|
|
}
|
|
|
|
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
|
|
if err == nil && existingEntry != nil {
|
|
recordDuplicateRequest(journalEntryTypeDebit)
|
|
logger.Info("Duplicate external debit request (idempotency)",
|
|
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 {
|
|
recordJournalEntryError(journalEntryTypeDebit, 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(journalEntryTypeDebit, journalEntryErrorAccountResolve)
|
|
return nil, err
|
|
}
|
|
if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil {
|
|
recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorAccountInvalid)
|
|
return nil, err
|
|
}
|
|
|
|
systemAccount, err := s.systemAccount(ctx, pmodel.SystemAccountPurposeExternalSink, req.Money.Currency)
|
|
if err != nil {
|
|
recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorSystemAccountResolve)
|
|
return nil, err
|
|
}
|
|
if err := validateSystemAccount(systemAccount, pmodel.SystemAccountPurposeExternalSink, req.Money.Currency); err != nil {
|
|
recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorSystemAccountInvalid)
|
|
return nil, err
|
|
}
|
|
|
|
systemAccountID := systemAccount.GetID()
|
|
if systemAccountID == nil {
|
|
recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorSystemAccountMissing)
|
|
return nil, merrors.Internal("system account missing identifier")
|
|
}
|
|
|
|
accountsByRef := map[bson.ObjectID]*pmodel.LedgerAccount{
|
|
accountRef: account,
|
|
*systemAccountID: systemAccount,
|
|
}
|
|
|
|
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 {
|
|
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: 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))
|
|
}
|
|
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)
|
|
}
|
|
|
|
contraAmount := entryTotal.Neg()
|
|
if !contraAmount.IsZero() || len(postingLines) == 1 {
|
|
contraLine := &model.PostingLine{
|
|
JournalEntryRef: bson.NilObjectID,
|
|
AccountRef: *systemAccountID,
|
|
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(journalEntryTypeDebit, 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.EntryTypeDebit,
|
|
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_DEBIT,
|
|
}, nil
|
|
})
|
|
|
|
if err != nil {
|
|
recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorTransactionFailed)
|
|
return nil, err
|
|
}
|
|
|
|
amountFloat, _ := debitAmount.Float64()
|
|
recordTransactionAmount(req.Money.Currency, journalEntryTypeDebit, amountFloat)
|
|
recordJournalEntry(journalEntryTypeDebit, journalEntryStatusSuccess, 0)
|
|
return result.(*ledgerv1.PostResponse), nil
|
|
}
|
|
}
|
|
|
|
func validateSystemAccount(account *pmodel.LedgerAccount, purpose pmodel.SystemAccountPurpose, currency string) error {
|
|
if account == nil {
|
|
return merrors.InvalidArgument("system account is required")
|
|
}
|
|
if account.Scope != pmodel.LedgerAccountScopeSystem {
|
|
return merrors.InvalidArgument("system account scope mismatch")
|
|
}
|
|
if account.SystemPurpose == nil || *account.SystemPurpose != purpose {
|
|
return merrors.InvalidArgument("system account purpose mismatch")
|
|
}
|
|
if account.OrganizationRef != nil && !account.OrganizationRef.IsZero() {
|
|
return merrors.InvalidArgument("system account must not be scoped to organization")
|
|
}
|
|
if strings.TrimSpace(string(account.Role)) != "" {
|
|
return merrors.InvalidArgument("system account role must be empty")
|
|
}
|
|
if !account.AllowNegative {
|
|
return merrors.InvalidArgument("system account must allow negative balances")
|
|
}
|
|
if account.Status != pmodel.LedgerAccountStatusActive {
|
|
return merrors.InvalidArgument(fmt.Sprintf("system account is %s", account.Status))
|
|
}
|
|
if currency != "" && account.Currency != currency {
|
|
return merrors.InvalidArgument(fmt.Sprintf("system account currency mismatch: account=%s, expected=%s", account.Currency, currency))
|
|
}
|
|
return nil
|
|
}
|