Compare commits

18 Commits

Author SHA1 Message Date
d027f2deda Merge pull request 'Fixed po sending comission' (#648) from po-647 into main
All checks were successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #648
2026-03-04 22:22:02 +00:00
Stephan D
ba5a3312b5 Fixed po sending comission 2026-03-04 23:21:35 +01:00
f2c9685eb1 Merge pull request '/start command' (#646) from tg-643 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #646
2026-03-04 22:01:45 +00:00
Stephan D
e80cb3eed1 /start command 2026-03-04 23:01:21 +01:00
5f647904d7 Merge pull request 'Treasury bot + ledger fix' (#644) from tg-643 into main
All checks were successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #644
2026-03-04 19:02:21 +00:00
Stephan D
b6f05f52dc Treasury bot + ledger fix 2026-03-04 20:01:37 +01:00
75555520f3 Merge pull request 'fixed ledger account name propagation when creating ledger account' (#642) from ledger-614 into main
All checks were successful
ci/woodpecker/push/ledger Pipeline was successful
Reviewed-on: #642
2026-03-04 17:53:00 +00:00
Stephan D
d666c4ce51 fixed ledger account name propagation when creating ledger account 2026-03-04 18:52:43 +01:00
706a57e860 Merge pull request 'op payment info added' (#641) from bff-640 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #641
2026-03-04 17:03:11 +00:00
Stephan D
f7b0915303 op payment info added 2026-03-04 18:02:36 +01:00
2bab8371b8 Merge pull request 'billing-637' (#638) from billing-637 into main
All checks were successful
ci/woodpecker/push/billing_documents Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/callbacks Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/gateway_tron Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_methods Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/payments_quotation Pipeline was successful
Reviewed-on: #638
2026-03-04 14:42:40 +00:00
Stephan D
af8ab8238e removeod obsolete file 2026-03-04 15:41:56 +01:00
Stephan D
92a6191014 document generation for ops 2026-03-04 15:41:28 +01:00
80b25a8608 Merge pull request 'added gateway and operation references' (#635) from bff-634 into main
All checks were successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
Reviewed-on: #635
2026-03-04 12:55:46 +00:00
17d954c689 Merge pull request 'removed payments polling' (#633) from SEND062 into main
Some checks failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #633
2026-03-04 12:55:35 +00:00
Stephan D
349e8afdc5 fixed operation ref description 2026-03-04 13:54:56 +01:00
Stephan D
8a1e44c038 removed strict mode from mntx 2026-03-04 13:52:56 +01:00
Stephan D
3fcbbfb08a added gateway and operation references 2026-03-04 13:51:48 +01:00
50 changed files with 4824 additions and 306 deletions

View File

@@ -4,7 +4,6 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"path/filepath"
"strings"
@@ -148,18 +147,17 @@ func (s *Service) Shutdown() {
func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) {
start := time.Now()
var paymentRefs []string
paymentRefs := 0
if req != nil {
paymentRefs = req.GetPaymentRefs()
paymentRefs = len(req.GetPaymentRefs())
}
logger := s.logger.With(zap.Int("payment_refs", len(paymentRefs)))
logger := s.logger.With(zap.Int("payment_refs", paymentRefs))
defer func() {
statusLabel := statusFromError(err)
observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start))
observeBatchSize(len(paymentRefs))
observeBatchSize(paymentRefs)
itemsCount := 0
if resp != nil {
@@ -181,80 +179,16 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba
logger.Info("BatchResolveDocuments finished", fields...)
}()
if len(paymentRefs) == 0 {
resp = &documentsv1.BatchResolveDocumentsResponse{}
_ = ctx
err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
return resp, nil
}
if s.storage == nil {
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
return nil, err
}
refs := make([]string, 0, len(paymentRefs))
for _, ref := range paymentRefs {
clean := strings.TrimSpace(ref)
if clean == "" {
continue
}
refs = append(refs, clean)
}
if len(refs) == 0 {
resp = &documentsv1.BatchResolveDocumentsResponse{}
return resp, nil
}
records, err := s.storage.Documents().ListByPaymentRefs(ctx, refs)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
recordByRef := map[string]*model.DocumentRecord{}
for _, record := range records {
if record == nil {
continue
}
recordByRef[record.PaymentRef] = record
}
items := make([]*documentsv1.DocumentMeta, 0, len(refs))
for _, ref := range refs {
meta := &documentsv1.DocumentMeta{PaymentRef: ref}
if record := recordByRef[ref]; record != nil {
record.Normalize()
available := []model.DocumentType{model.DocumentTypeAct}
ready := make([]model.DocumentType, 0, 1)
if path, ok := record.StoragePaths[model.DocumentTypeAct]; ok && path != "" {
ready = append(ready, model.DocumentTypeAct)
}
meta.AvailableTypes = toProtoTypes(available)
meta.ReadyTypes = toProtoTypes(ready)
}
items = append(items, meta)
}
resp = &documentsv1.BatchResolveDocumentsResponse{Items: items}
return resp, nil
return nil, err
}
func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
start := time.Now()
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
paymentRef := ""
if req != nil {
docType = req.GetType()
paymentRef = strings.TrimSpace(req.GetPaymentRef())
@@ -293,92 +227,94 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
logger.Info("GetDocument finished", fields...)
}()
if paymentRef == "" {
err = status.Error(codes.InvalidArgument, "payment_ref is required")
_ = ctx
err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
return nil, err
return nil, err
}
func (s *Service) GetOperationDocument(_ context.Context, req *documentsv1.GetOperationDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
start := time.Now()
organizationRef := ""
gatewayService := ""
operationRef := ""
if req != nil {
organizationRef = strings.TrimSpace(req.GetOrganizationRef())
gatewayService = strings.TrimSpace(req.GetGatewayService())
operationRef = strings.TrimSpace(req.GetOperationRef())
}
if docType == documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED {
err = status.Error(codes.InvalidArgument, "document type is required")
logger := s.logger.With(
zap.String("organization_ref", organizationRef),
zap.String("gateway_service", gatewayService),
zap.String("operation_ref", operationRef),
)
return nil, err
}
defer func() {
statusLabel := statusFromError(err)
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
observeRequest("get_operation_document", docType, statusLabel, time.Since(start))
if s.storage == nil {
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
return nil, err
}
if s.docStore == nil {
err = status.Error(codes.Unavailable, errDocStoreUnavailable.Error())
return nil, err
}
if s.template == nil {
err = status.Error(codes.FailedPrecondition, errTemplateUnavailable.Error())
return nil, err
}
record, err := s.storage.Documents().GetByPaymentRef(ctx, paymentRef)
if err != nil {
if errors.Is(err, storage.ErrDocumentNotFound) {
return nil, status.Error(codes.NotFound, "document record not found")
if resp != nil {
observeDocumentBytes(docType, len(resp.GetContent()))
}
return nil, status.Error(codes.Internal, err.Error())
}
record.Normalize()
targetType := model.DocumentTypeFromProto(docType)
if docType != documentsv1.DocumentType_DOCUMENT_TYPE_ACT {
return nil, status.Error(codes.Unimplemented, "document type not implemented")
}
if path, ok := record.StoragePaths[targetType]; ok && path != "" {
content, loadErr := s.docStore.Load(ctx, path)
if loadErr != nil {
return nil, status.Error(codes.Internal, loadErr.Error())
contentBytes := 0
if resp != nil {
contentBytes = len(resp.GetContent())
}
return &documentsv1.GetDocumentResponse{
Content: content,
Filename: documentFilename(docType, paymentRef),
MimeType: "application/pdf",
}, nil
fields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Int("content_bytes", contentBytes),
}
if err != nil {
logger.Warn("GetOperationDocument failed", append(fields, zap.Error(err))...)
return
}
logger.Info("GetOperationDocument finished", fields...)
}()
if req == nil {
err = status.Error(codes.InvalidArgument, "request is required")
return nil, err
}
content, hash, genErr := s.generateActPDF(record.Snapshot)
if organizationRef == "" {
err = status.Error(codes.InvalidArgument, "organization_ref is required")
return nil, err
}
if gatewayService == "" {
err = status.Error(codes.InvalidArgument, "gateway_service is required")
return nil, err
}
if operationRef == "" {
err = status.Error(codes.InvalidArgument, "operation_ref is required")
return nil, err
}
snapshot := operationSnapshotFromRequest(req)
content, _, genErr := s.generateOperationPDF(snapshot)
if genErr != nil {
logger.Warn("Failed to generate document", zap.Error(genErr))
err = status.Error(codes.Internal, genErr.Error())
return nil, status.Error(codes.Internal, genErr.Error())
}
path := documentStoragePath(paymentRef, docType)
if saveErr := s.docStore.Save(ctx, path, content); saveErr != nil {
logger.Warn("Failed to store document", zap.Error(saveErr))
return nil, status.Error(codes.Internal, saveErr.Error())
}
record.StoragePaths[targetType] = path
record.Hashes[targetType] = hash
if updateErr := s.storage.Documents().Update(ctx, record); updateErr != nil {
logger.Warn("Failed to update document record", zap.Error(updateErr))
return nil, status.Error(codes.Internal, updateErr.Error())
return nil, err
}
resp = &documentsv1.GetDocumentResponse{
Content: content,
Filename: documentFilename(docType, paymentRef),
Filename: operationDocumentFilename(operationRef),
MimeType: "application/pdf",
}
@@ -392,7 +328,7 @@ func (s *Service) startDiscoveryAnnouncer() {
announce := discovery.Announcement{
Service: mservice.BillingDocuments,
Operations: []string{discovery.OperationDocumentsBatchResolve, discovery.OperationDocumentsGet},
Operations: []string{discovery.OperationDocumentsGet},
InvokeURI: s.invokeURI,
Version: appversion.Create().Short(),
}
@@ -418,10 +354,19 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
return nil, "", err
}
return s.renderPDFWithIntegrity(blocks)
}
func (s *Service) generateOperationPDF(snapshot operationSnapshot) ([]byte, string, error) {
return s.renderPDFWithIntegrity(buildOperationBlocks(snapshot))
}
func (s *Service) renderPDFWithIntegrity(blocks []renderer.Block) ([]byte, string, error) {
generated := renderer.Renderer{
Issuer: s.config.Issuer,
OwnerPassword: s.config.Protection.OwnerPassword,
}
placeholder := strings.Repeat("0", 64)
firstPass, err := generated.Render(blocks, placeholder)
@@ -440,6 +385,157 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
return finalBytes, footerHex, nil
}
type operationSnapshot struct {
OrganizationRef string
GatewayService string
OperationRef string
PaymentRef string
OperationCode string
OperationLabel string
OperationState string
FailureCode string
FailureReason string
Amount string
Currency string
StartedAt time.Time
CompletedAt time.Time
}
func operationSnapshotFromRequest(req *documentsv1.GetOperationDocumentRequest) operationSnapshot {
snapshot := operationSnapshot{
OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
GatewayService: strings.TrimSpace(req.GetGatewayService()),
OperationRef: strings.TrimSpace(req.GetOperationRef()),
PaymentRef: strings.TrimSpace(req.GetPaymentRef()),
OperationCode: strings.TrimSpace(req.GetOperationCode()),
OperationLabel: strings.TrimSpace(req.GetOperationLabel()),
OperationState: strings.TrimSpace(req.GetOperationState()),
FailureCode: strings.TrimSpace(req.GetFailureCode()),
FailureReason: strings.TrimSpace(req.GetFailureReason()),
Amount: strings.TrimSpace(req.GetAmount()),
Currency: strings.TrimSpace(req.GetCurrency()),
}
if ts := req.GetStartedAtUnixMs(); ts > 0 {
snapshot.StartedAt = time.UnixMilli(ts).UTC()
}
if ts := req.GetCompletedAtUnixMs(); ts > 0 {
snapshot.CompletedAt = time.UnixMilli(ts).UTC()
}
return snapshot
}
func buildOperationBlocks(snapshot operationSnapshot) []renderer.Block {
rows := [][]string{
{"Organization", snapshot.OrganizationRef},
{"Gateway Service", snapshot.GatewayService},
{"Operation Ref", snapshot.OperationRef},
{"Payment Ref", safeValue(snapshot.PaymentRef)},
{"Code", safeValue(snapshot.OperationCode)},
{"State", safeValue(snapshot.OperationState)},
{"Label", safeValue(snapshot.OperationLabel)},
{"Started At (UTC)", formatSnapshotTime(snapshot.StartedAt)},
{"Completed At (UTC)", formatSnapshotTime(snapshot.CompletedAt)},
}
if snapshot.Amount != "" || snapshot.Currency != "" {
rows = append(rows, []string{"Amount", strings.TrimSpace(strings.TrimSpace(snapshot.Amount) + " " + strings.TrimSpace(snapshot.Currency))})
}
blocks := []renderer.Block{
{
Tag: renderer.TagTitle,
Lines: []string{"OPERATION BILLING DOCUMENT"},
},
{
Tag: renderer.TagSubtitle,
Lines: []string{"Gateway operation statement"},
},
{
Tag: renderer.TagMeta,
Lines: []string{
"Document Type: Operation",
},
},
{
Tag: renderer.TagSection,
Lines: []string{"OPERATION DETAILS"},
},
{
Tag: renderer.TagKV,
Rows: rows,
},
}
if snapshot.FailureCode != "" || snapshot.FailureReason != "" {
blocks = append(blocks,
renderer.Block{Tag: renderer.TagSection, Lines: []string{"FAILURE DETAILS"}},
renderer.Block{
Tag: renderer.TagKV,
Rows: [][]string{
{"Failure Code", safeValue(snapshot.FailureCode)},
{"Failure Reason", safeValue(snapshot.FailureReason)},
},
},
)
}
return blocks
}
func formatSnapshotTime(value time.Time) string {
if value.IsZero() {
return "n/a"
}
return value.UTC().Format(time.RFC3339)
}
func safeValue(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "n/a"
}
return trimmed
}
func operationDocumentFilename(operationRef string) string {
clean := sanitizeFilenameComponent(operationRef)
if clean == "" {
clean = "operation"
}
return fmt.Sprintf("operation_%s.pdf", clean)
}
func sanitizeFilenameComponent(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
var b strings.Builder
b.Grow(len(trimmed))
for _, r := range trimmed {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
return strings.Trim(b.String(), "_")
}
func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType {
if len(types) == 0 {
return nil

View File

@@ -12,6 +12,8 @@ import (
"github.com/tech/sendico/billing/documents/storage/model"
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type stubRepo struct {
@@ -94,9 +96,7 @@ func (s *stubTemplate) Render(_ model.ActSnapshot) ([]renderer.Block, error) {
return s.blocks, nil
}
func TestGetDocument_IdempotentAndHashed(t *testing.T) {
ctx := context.Background()
func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) {
snapshot := model.ActSnapshot{
PaymentID: "PAY-123",
Date: time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC),
@@ -105,14 +105,6 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
Currency: "USD",
}
record := &model.DocumentRecord{
PaymentRef: "PAY-123",
Snapshot: snapshot,
}
documentsStore := &stubDocumentsStore{record: record}
repo := &stubRepo{store: documentsStore}
store := newMemDocStore()
tmpl := &stubTemplate{
blocks: []renderer.Block{
{Tag: renderer.TagTitle, Lines: []string{"ACT"}},
@@ -127,62 +119,47 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
},
}
svc := NewService(zap.NewNop(), repo, nil,
svc := NewService(zap.NewNop(), nil, nil,
WithConfig(cfg),
WithDocumentStore(store),
WithTemplateRenderer(tmpl),
)
resp1, err := svc.GetDocument(ctx, &documentsv1.GetDocumentRequest{
PaymentRef: "PAY-123",
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
})
pdf1, hash1, err := svc.generateActPDF(snapshot)
if err != nil {
t.Fatalf("GetDocument first call: %v", err)
t.Fatalf("generateActPDF first call: %v", err)
}
if len(resp1.GetContent()) == 0 {
if len(pdf1) == 0 {
t.Fatalf("expected content on first call")
}
stored := record.Hashes[model.DocumentTypeAct]
if stored == "" {
t.Fatalf("expected stored hash")
if hash1 == "" {
t.Fatalf("expected non-empty hash on first call")
}
footerHash := extractFooterHash(resp1.GetContent())
footerHash := extractFooterHash(pdf1)
if footerHash == "" {
t.Fatalf("expected footer hash in PDF")
}
if stored != footerHash {
t.Fatalf("stored hash mismatch: got %s", stored)
if hash1 != footerHash {
t.Fatalf("stored hash mismatch: got %s", hash1)
}
resp2, err := svc.GetDocument(ctx, &documentsv1.GetDocumentRequest{
PaymentRef: "PAY-123",
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
})
pdf2, hash2, err := svc.generateActPDF(snapshot)
if err != nil {
t.Fatalf("GetDocument second call: %v", err)
t.Fatalf("generateActPDF second call: %v", err)
}
if !bytes.Equal(resp1.GetContent(), resp2.GetContent()) {
t.Fatalf("expected identical PDF bytes on second call")
if hash2 == "" {
t.Fatalf("expected non-empty hash on second call")
}
if tmpl.calls != 1 {
t.Fatalf("expected template to be rendered once, got %d", tmpl.calls)
footerHash2 := extractFooterHash(pdf2)
if footerHash2 == "" {
t.Fatalf("expected footer hash in second PDF")
}
if store.saveCount != 1 {
t.Fatalf("expected document save once, got %d", store.saveCount)
}
if store.loadCount == 0 {
t.Fatalf("expected document load on second call")
if footerHash2 != hash2 {
t.Fatalf("second hash mismatch: got=%s want=%s", footerHash2, hash2)
}
}
@@ -212,3 +189,48 @@ func extractFooterHash(pdf []byte) string {
func isHexDigit(b byte) bool {
return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F')
}
func TestGetOperationDocument_GeneratesPDF(t *testing.T) {
svc := NewService(zap.NewNop(), nil, nil, WithConfig(Config{
Issuer: renderer.Issuer{
LegalName: "Sendico Ltd",
},
}))
resp, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
OrganizationRef: "org-1",
GatewayService: "chain_gateway",
OperationRef: "pay-1:step-1",
PaymentRef: "pay-1",
OperationCode: "crypto.transfer",
OperationLabel: "Outbound transfer",
OperationState: "completed",
Amount: "100.50",
Currency: "USDT",
StartedAtUnixMs: time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC).UnixMilli(),
})
if err != nil {
t.Fatalf("GetOperationDocument failed: %v", err)
}
if len(resp.GetContent()) == 0 {
t.Fatalf("expected non-empty PDF content")
}
if got, want := resp.GetMimeType(), "application/pdf"; got != want {
t.Fatalf("mime_type mismatch: got=%q want=%q", got, want)
}
if got, want := resp.GetFilename(), "operation_pay-1_step-1.pdf"; got != want {
t.Fatalf("filename mismatch: got=%q want=%q", got, want)
}
}
func TestGetOperationDocument_RequiresOperationRef(t *testing.T) {
svc := NewService(zap.NewNop(), nil, nil)
_, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
OrganizationRef: "org-1",
GatewayService: "chain_gateway",
})
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("expected InvalidArgument, got=%v err=%v", status.Code(err), err)
}
}

View File

@@ -8,8 +8,10 @@ import (
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
@@ -77,14 +79,18 @@ type Payment struct {
}
type PaymentOperation struct {
StepRef string `json:"stepRef,omitempty"`
Code string `json:"code,omitempty"`
State string `json:"state,omitempty"`
Label string `json:"label,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
FailureReason string `json:"failureReason,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
StepRef string `json:"stepRef,omitempty"`
Code string `json:"code,omitempty"`
State string `json:"state,omitempty"`
Label string `json:"label,omitempty"`
Amount *paymenttypes.Money `json:"amount,omitempty"`
ConvertedAmount *paymenttypes.Money `json:"convertedAmount,omitempty"`
OperationRef string `json:"operationRef,omitempty"`
Gateway string `json:"gateway,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
FailureReason string `json:"failureReason,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
type paymentQuoteResponse struct {
@@ -283,7 +289,7 @@ func toPayment(p *orchestrationv2.Payment) *Payment {
if p == nil {
return nil
}
operations := toUserVisibleOperations(p.GetStepExecutions())
operations := toUserVisibleOperations(p.GetStepExecutions(), p.GetQuoteSnapshot())
failureCode, failureReason := firstFailure(operations)
return &Payment{
PaymentRef: p.GetPaymentRef(),
@@ -308,7 +314,7 @@ func firstFailure(operations []PaymentOperation) (string, string) {
return "", ""
}
func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOperation {
func toUserVisibleOperations(steps []*orchestrationv2.StepExecution, quote *quotationv2.PaymentQuote) []PaymentOperation {
if len(steps) == 0 {
return nil
}
@@ -317,7 +323,7 @@ func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOp
if step == nil || !isUserVisibleStep(step.GetReportVisibility()) {
continue
}
ops = append(ops, toPaymentOperation(step))
ops = append(ops, toPaymentOperation(step, quote))
}
if len(ops) == 0 {
return nil
@@ -325,14 +331,20 @@ func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOp
return ops
}
func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation {
func toPaymentOperation(step *orchestrationv2.StepExecution, quote *quotationv2.PaymentQuote) PaymentOperation {
operationRef, gateway := operationRefAndGateway(step.GetStepCode(), step.GetRefs())
amount, convertedAmount := operationAmounts(step.GetStepCode(), quote)
op := PaymentOperation{
StepRef: step.GetStepRef(),
Code: step.GetStepCode(),
State: enumJSONName(step.GetState().String()),
Label: strings.TrimSpace(step.GetUserLabel()),
StartedAt: timestampAsTime(step.GetStartedAt()),
CompletedAt: timestampAsTime(step.GetCompletedAt()),
StepRef: step.GetStepRef(),
Code: step.GetStepCode(),
State: enumJSONName(step.GetState().String()),
Label: strings.TrimSpace(step.GetUserLabel()),
Amount: amount,
ConvertedAmount: convertedAmount,
OperationRef: operationRef,
Gateway: string(gateway),
StartedAt: timestampAsTime(step.GetStartedAt()),
CompletedAt: timestampAsTime(step.GetCompletedAt()),
}
failure := step.GetFailure()
if failure == nil {
@@ -346,6 +358,165 @@ func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation {
return op
}
func operationAmounts(stepCode string, quote *quotationv2.PaymentQuote) (*paymenttypes.Money, *paymenttypes.Money) {
if quote == nil {
return nil, nil
}
operation := stepOperationToken(stepCode)
primary := firstValidMoney(
toMoney(quote.GetDestinationAmount()),
toMoney(quote.GetTransferPrincipalAmount()),
toMoney(quote.GetPayerTotalDebitAmount()),
)
if operation != "fx_convert" {
return primary, nil
}
base := firstValidMoney(
toMoney(quote.GetTransferPrincipalAmount()),
toMoney(quote.GetPayerTotalDebitAmount()),
toMoney(quote.GetFxQuote().GetBaseAmount()),
)
quoteAmount := firstValidMoney(
toMoney(quote.GetDestinationAmount()),
toMoney(quote.GetFxQuote().GetQuoteAmount()),
)
return base, quoteAmount
}
func stepOperationToken(stepCode string) string {
parts := strings.Split(strings.ToLower(strings.TrimSpace(stepCode)), ".")
if len(parts) == 0 {
return ""
}
return strings.TrimSpace(parts[len(parts)-1])
}
func firstValidMoney(values ...*paymenttypes.Money) *paymenttypes.Money {
for _, value := range values {
if value == nil {
continue
}
if strings.TrimSpace(value.GetAmount()) == "" || strings.TrimSpace(value.GetCurrency()) == "" {
continue
}
return value
}
return nil
}
const (
externalRefKindOperation = "operation_ref"
)
func operationRefAndGateway(stepCode string, refs []*orchestrationv2.ExternalReference) (string, mservice.Type) {
var (
operationRef string
gateway mservice.Type
)
for _, ref := range refs {
if ref == nil {
continue
}
kind := strings.ToLower(strings.TrimSpace(ref.GetKind()))
value := strings.TrimSpace(ref.GetRef())
candidateGateway := inferGatewayType(ref.GetGatewayInstanceId(), ref.GetRail(), stepCode)
if kind == externalRefKindOperation && operationRef == "" && value != "" {
operationRef = value
}
if gateway == "" && candidateGateway != "" {
gateway = candidateGateway
}
}
if gateway == "" {
gateway = inferGatewayType("", gatewayv1.Rail_RAIL_UNSPECIFIED, stepCode)
}
return operationRef, gateway
}
func inferGatewayType(gatewayInstanceID string, rail gatewayv1.Rail, stepCode string) mservice.Type {
if gateway := gatewayTypeFromInstanceID(gatewayInstanceID); gateway != "" {
return gateway
}
if gateway := gatewayTypeFromRail(rail); gateway != "" {
return gateway
}
return gatewayTypeFromStepCode(stepCode)
}
func gatewayTypeFromInstanceID(raw string) mservice.Type {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" {
return ""
}
switch mservice.Type(value) {
case mservice.ChainGateway, mservice.TronGateway, mservice.MntxGateway, mservice.PaymentGateway, mservice.TgSettle, mservice.Ledger:
return mservice.Type(value)
}
switch {
case strings.Contains(value, "ledger"):
return mservice.Ledger
case strings.Contains(value, "tgsettle"):
return mservice.TgSettle
case strings.Contains(value, "payment_gateway"),
strings.Contains(value, "settlement"),
strings.Contains(value, "onramp"),
strings.Contains(value, "offramp"):
return mservice.PaymentGateway
case strings.Contains(value, "mntx"), strings.Contains(value, "mcards"):
return mservice.MntxGateway
case strings.Contains(value, "tron"):
return mservice.TronGateway
case strings.Contains(value, "chain"), strings.Contains(value, "crypto"):
return mservice.ChainGateway
case strings.Contains(value, "card"):
return mservice.MntxGateway
default:
return ""
}
}
func gatewayTypeFromRail(rail gatewayv1.Rail) mservice.Type {
switch rail {
case gatewayv1.Rail_RAIL_LEDGER:
return mservice.Ledger
case gatewayv1.Rail_RAIL_CARD:
return mservice.MntxGateway
case gatewayv1.Rail_RAIL_SETTLEMENT, gatewayv1.Rail_RAIL_ONRAMP, gatewayv1.Rail_RAIL_OFFRAMP:
return mservice.PaymentGateway
case gatewayv1.Rail_RAIL_CRYPTO:
return mservice.ChainGateway
default:
return ""
}
}
func gatewayTypeFromStepCode(stepCode string) mservice.Type {
code := strings.ToLower(strings.TrimSpace(stepCode))
switch {
case strings.Contains(code, "ledger"):
return mservice.Ledger
case strings.Contains(code, "card_payout"), strings.Contains(code, ".card."):
return mservice.MntxGateway
case strings.Contains(code, "provider_settlement"),
strings.Contains(code, "settlement"),
strings.Contains(code, "fx_convert"),
strings.Contains(code, "onramp"),
strings.Contains(code, "offramp"):
return mservice.PaymentGateway
case strings.Contains(code, "crypto"), strings.Contains(code, "chain"):
return mservice.ChainGateway
default:
return ""
}
}
func isUserVisibleStep(visibility orchestrationv2.ReportVisibility) bool {
switch visibility {
case orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,

View File

@@ -3,6 +3,8 @@ package sresponse
import (
"testing"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
@@ -32,7 +34,7 @@ func TestToUserVisibleOperationsFiltersByVisibility(t *testing.T) {
},
}
ops := toUserVisibleOperations(steps)
ops := toUserVisibleOperations(steps, nil)
if len(ops) != 2 {
t.Fatalf("operations count mismatch: got=%d want=2", len(ops))
}
@@ -134,3 +136,118 @@ func TestToPaymentQuote_MapsIntentRef(t *testing.T) {
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentOperation_MapsOperationRefAndGateway(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-1",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
Refs: []*orchestrationv2.ExternalReference{
{
Rail: gatewayv1.Rail_RAIL_CARD,
GatewayInstanceId: "mcards",
Kind: "operation_ref",
Ref: "op-123",
},
},
}, nil)
if got, want := op.OperationRef, "op-123"; got != want {
t.Fatalf("operation_ref mismatch: got=%q want=%q", got, want)
}
if got, want := op.Gateway, "mntx_gateway"; got != want {
t.Fatalf("gateway mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentOperation_InfersGatewayFromStepCode(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-2",
StepCode: "edge.1_2.ledger.debit",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
}, nil)
if got := op.OperationRef; got != "" {
t.Fatalf("expected empty operation_ref, got=%q", got)
}
if got, want := op.Gateway, "ledger"; got != want {
t.Fatalf("gateway mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentOperation_DoesNotFallbackToCardPayoutRef(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-3",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
Refs: []*orchestrationv2.ExternalReference{
{
Rail: gatewayv1.Rail_RAIL_CARD,
GatewayInstanceId: "mcards",
Kind: "card_payout_ref",
Ref: "payout-123",
},
},
}, nil)
if got := op.OperationRef; got != "" {
t.Fatalf("expected empty operation_ref, got=%q", got)
}
if got, want := op.Gateway, "mntx_gateway"; got != want {
t.Fatalf("gateway mismatch: got=%q want=%q", got, want)
}
}
func TestToPaymentOperation_MapsAmount(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-4",
StepCode: "hop.4.card_payout.send",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
}, &quotationv2.PaymentQuote{
TransferPrincipalAmount: &moneyv1.Money{Amount: "110.00", Currency: "USDT"},
DestinationAmount: &moneyv1.Money{Amount: "100.00", Currency: "EUR"},
})
if op.Amount == nil {
t.Fatal("expected amount to be mapped")
}
if got, want := op.Amount.Amount, "100.00"; got != want {
t.Fatalf("amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Amount.Currency, "EUR"; got != want {
t.Fatalf("amount.currency mismatch: got=%q want=%q", got, want)
}
if got := op.ConvertedAmount; got != nil {
t.Fatalf("expected no converted_amount for non-fx operation, got=%+v", got)
}
}
func TestToPaymentOperation_MapsFxTwoAmounts(t *testing.T) {
op := toPaymentOperation(&orchestrationv2.StepExecution{
StepRef: "step-5",
StepCode: "hop.2.settlement.fx_convert",
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
}, &quotationv2.PaymentQuote{
TransferPrincipalAmount: &moneyv1.Money{Amount: "110.00", Currency: "USDT"},
DestinationAmount: &moneyv1.Money{Amount: "100.00", Currency: "EUR"},
})
if op.Amount == nil {
t.Fatal("expected fx base amount to be mapped")
}
if got, want := op.Amount.Amount, "110.00"; got != want {
t.Fatalf("base amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.Amount.Currency, "USDT"; got != want {
t.Fatalf("base amount.currency mismatch: got=%q want=%q", got, want)
}
if op.ConvertedAmount == nil {
t.Fatal("expected fx converted amount to be mapped")
}
if got, want := op.ConvertedAmount.Amount, "100.00"; got != want {
t.Fatalf("converted amount.value mismatch: got=%q want=%q", got, want)
}
if got, want := op.ConvertedAmount.Currency, "EUR"; got != want {
t.Fatalf("converted amount.currency mismatch: got=%q want=%q", got, want)
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -23,43 +24,90 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
const (
documentsServiceName = "BILLING_DOCUMENTS"
documentsOperationGet = discovery.OperationDocumentsGet
documentsDialTimeout = 5 * time.Second
documentsCallTimeout = 10 * time.Second
gatewayCallTimeout = 10 * time.Second
)
func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
var allowedOperationGatewayServices = map[mservice.Type]struct{}{
mservice.ChainGateway: {},
mservice.TronGateway: {},
mservice.MntxGateway: {},
mservice.PaymentGateway: {},
mservice.TgSettle: {},
}
func (a *PaymentAPI) getOperationDocument(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
orgRef, denied := a.authorizeDocumentDownload(r, account)
if denied != nil {
return denied
}
query := r.URL.Query()
gatewayService := normalizeGatewayService(query.Get("gateway_service"))
if gatewayService == "" {
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "gateway_service is required")
}
if _, ok := allowedOperationGatewayServices[gatewayService]; !ok {
return response.BadRequest(a.logger, a.Name(), "invalid_parameter", "unsupported gateway_service")
}
operationRef := strings.TrimSpace(query.Get("operation_ref"))
if operationRef == "" {
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "operation_ref is required")
}
service, gateway, h := a.resolveOperationDocumentDeps(r.Context(), gatewayService)
if h != nil {
return h
}
op, err := a.fetchGatewayOperation(r.Context(), gateway.InvokeURI, operationRef)
if err != nil {
a.logger.Warn("Failed to fetch gateway operation for document generation", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), zap.String("gateway_service", string(gatewayService)), zap.String("operation_ref", operationRef))
return documentErrorResponse(a.logger, a.Name(), err)
}
req := operationDocumentRequest(orgRef.Hex(), gatewayService, operationRef, op)
docResp, err := a.fetchOperationDocument(r.Context(), service.InvokeURI, req)
if err != nil {
a.logger.Warn("Failed to fetch operation document", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), zap.String("gateway_service", string(gatewayService)), zap.String("operation_ref", operationRef))
return documentErrorResponse(a.logger, a.Name(), err)
}
return operationDocumentResponse(a.logger, a.Name(), docResp, fmt.Sprintf("operation_%s.pdf", sanitizeFilenameComponent(operationRef)))
}
func (a *PaymentAPI) authorizeDocumentDownload(r *http.Request, account *model.Account) (bson.ObjectID, http.HandlerFunc) {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for document request", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
return bson.NilObjectID, response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
return bson.NilObjectID, response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when downloading act", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
a.logger.Debug("Access denied when downloading document", mutil.PLog(a.oph, r))
return bson.NilObjectID, response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
}
paymentRef := strings.TrimSpace(r.URL.Query().Get("payment_ref"))
if paymentRef == "" {
paymentRef = strings.TrimSpace(r.URL.Query().Get("paymentRef"))
}
if paymentRef == "" {
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "payment_ref is required")
}
return orgRef, nil
}
func (a *PaymentAPI) resolveOperationDocumentDeps(ctx context.Context, gatewayService mservice.Type) (*discovery.ServiceSummary, *discovery.GatewaySummary, http.HandlerFunc) {
if a.discovery == nil {
return response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "discovery client is not configured")
return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "discovery client is not configured")
}
lookupCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout)
@@ -68,27 +116,35 @@ func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ *
lookupResp, err := a.discovery.Lookup(lookupCtx)
if err != nil {
a.logger.Warn("Failed to lookup discovery registry", zap.Error(err))
return response.Auto(a.logger, a.Name(), err)
return nil, nil, response.Auto(a.logger, a.Name(), err)
}
service := findDocumentsService(lookupResp.Services)
if service == nil {
return response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "billing documents service unavailable")
return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "billing documents service unavailable")
}
docResp, err := a.fetchActDocument(ctx, service.InvokeURI, paymentRef)
if err != nil {
a.logger.Warn("Failed to fetch act document", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
return documentErrorResponse(a.logger, a.Name(), err)
gateway := findGatewayForService(lookupResp.Gateways, gatewayService)
if gateway == nil {
return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "gateway service unavailable")
}
if len(docResp.GetContent()) == 0 {
return response.Error(a.logger, a.Name(), http.StatusInternalServerError, "empty_document", "document service returned empty payload")
return service, gateway, nil
}
func operationDocumentResponse(logger mlogger.Logger, source mservice.Type, docResp *documentsv1.GetDocumentResponse, fallbackFilename string) http.HandlerFunc {
if docResp == nil || len(docResp.GetContent()) == 0 {
return response.Error(logger, source, http.StatusInternalServerError, "empty_document", "document service returned empty payload")
}
filename := strings.TrimSpace(docResp.GetFilename())
if filename == "" {
filename = fmt.Sprintf("act_%s.pdf", paymentRef)
filename = strings.TrimSpace(fallbackFilename)
}
if filename == "" {
filename = "document.pdf"
}
mimeType := strings.TrimSpace(docResp.GetMimeType())
if mimeType == "" {
mimeType = "application/pdf"
@@ -98,13 +154,67 @@ func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ *
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.WriteHeader(http.StatusOK)
if _, writeErr := w.Write(docResp.GetContent()); writeErr != nil {
a.logger.Warn("Failed to write document response", zap.Error(writeErr))
if _, err := w.Write(docResp.GetContent()); err != nil {
logger.Warn("Failed to write document response", zap.Error(err))
}
}
}
func (a *PaymentAPI) fetchActDocument(ctx context.Context, invokeURI, paymentRef string) (*documentsv1.GetDocumentResponse, error) {
func normalizeGatewayService(raw string) mservice.Type {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" {
return ""
}
switch value {
case string(mservice.ChainGateway):
return mservice.ChainGateway
case string(mservice.TronGateway):
return mservice.TronGateway
case string(mservice.MntxGateway):
return mservice.MntxGateway
case string(mservice.PaymentGateway):
return mservice.PaymentGateway
case string(mservice.TgSettle):
return mservice.TgSettle
default:
return ""
}
}
func sanitizeFilenameComponent(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
var b strings.Builder
b.Grow(len(trimmed))
for _, r := range trimmed {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r)
case r >= 'A' && r <= 'Z':
b.WriteRune(r)
case r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
clean := strings.Trim(b.String(), "_")
if clean == "" {
return "operation"
}
return clean
}
func (a *PaymentAPI) fetchOperationDocument(ctx context.Context, invokeURI string, req *documentsv1.GetOperationDocumentRequest) (*documentsv1.GetDocumentResponse, error) {
conn, err := grpc.NewClient(invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, merrors.InternalWrap(err, "dial billing documents")
@@ -116,10 +226,160 @@ func (a *PaymentAPI) fetchActDocument(ctx context.Context, invokeURI, paymentRef
callCtx, callCancel := context.WithTimeout(ctx, documentsCallTimeout)
defer callCancel()
return client.GetDocument(callCtx, &documentsv1.GetDocumentRequest{
PaymentRef: paymentRef,
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
})
return client.GetOperationDocument(callCtx, req)
}
func (a *PaymentAPI) fetchGatewayOperation(ctx context.Context, invokeURI, operationRef string) (*connectorv1.Operation, error) {
conn, err := grpc.NewClient(invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, merrors.InternalWrap(err, "dial gateway connector")
}
defer conn.Close()
client := connectorv1.NewConnectorServiceClient(conn)
callCtx, callCancel := context.WithTimeout(ctx, gatewayCallTimeout)
defer callCancel()
resp, err := client.GetOperation(callCtx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(operationRef)})
if err != nil {
return nil, err
}
op := resp.GetOperation()
if op == nil {
return nil, merrors.NoData("gateway returned empty operation payload")
}
return op, nil
}
func findGatewayForService(gateways []discovery.GatewaySummary, gatewayService mservice.Type) *discovery.GatewaySummary {
candidates := make([]discovery.GatewaySummary, 0, len(gateways))
for _, gw := range gateways {
if !gw.Healthy || strings.TrimSpace(gw.InvokeURI) == "" {
continue
}
rail := discovery.NormalizeRail(gw.Rail)
network := strings.ToLower(strings.TrimSpace(gw.Network))
switch gatewayService {
case mservice.MntxGateway:
if rail == discovery.NormalizeRail(discovery.RailCardPayout) {
candidates = append(candidates, gw)
}
case mservice.PaymentGateway, mservice.TgSettle:
if rail == discovery.NormalizeRail(discovery.RailProviderSettlement) {
candidates = append(candidates, gw)
}
case mservice.TronGateway:
if rail == discovery.NormalizeRail(discovery.RailCrypto) && strings.Contains(network, "tron") {
candidates = append(candidates, gw)
}
case mservice.ChainGateway:
if rail == discovery.NormalizeRail(discovery.RailCrypto) && !strings.Contains(network, "tron") {
candidates = append(candidates, gw)
}
}
}
if len(candidates) == 0 && gatewayService == mservice.ChainGateway {
for _, gw := range gateways {
if gw.Healthy && strings.TrimSpace(gw.InvokeURI) != "" && discovery.NormalizeRail(gw.Rail) == discovery.NormalizeRail(discovery.RailCrypto) {
candidates = append(candidates, gw)
}
}
}
if len(candidates) == 0 {
return nil
}
best := candidates[0]
for _, candidate := range candidates[1:] {
if candidate.RoutingPriority > best.RoutingPriority {
best = candidate
}
}
return &best
}
func operationDocumentRequest(organizationRef string, gatewayService mservice.Type, requestedOperationRef string, op *connectorv1.Operation) *documentsv1.GetOperationDocumentRequest {
req := &documentsv1.GetOperationDocumentRequest{
OrganizationRef: strings.TrimSpace(organizationRef),
GatewayService: string(gatewayService),
OperationRef: firstNonEmpty(strings.TrimSpace(op.GetOperationRef()), strings.TrimSpace(requestedOperationRef)),
OperationCode: strings.TrimSpace(op.GetType().String()),
OperationLabel: operationLabel(op.GetType()),
OperationState: strings.TrimSpace(op.GetStatus().String()),
Amount: strings.TrimSpace(op.GetMoney().GetAmount()),
Currency: strings.TrimSpace(op.GetMoney().GetCurrency()),
}
if ts := op.GetCreatedAt(); ts != nil {
req.StartedAtUnixMs = ts.AsTime().UnixMilli()
}
if ts := op.GetUpdatedAt(); ts != nil {
req.CompletedAtUnixMs = ts.AsTime().UnixMilli()
}
req.PaymentRef = operationParamValue(op.GetParams(), "payment_ref", "parent_payment_ref", "paymentRef", "parentPaymentRef")
req.FailureCode = firstNonEmpty(
operationParamValue(op.GetParams(), "failure_code", "provider_code", "error_code"),
failureCodeFromStatus(op.GetStatus()),
)
req.FailureReason = operationParamValue(op.GetParams(), "failure_reason", "provider_message", "error", "message")
return req
}
func operationLabel(opType connectorv1.OperationType) string {
switch opType {
case connectorv1.OperationType_CREDIT:
return "Credit"
case connectorv1.OperationType_DEBIT:
return "Debit"
case connectorv1.OperationType_TRANSFER:
return "Transfer"
case connectorv1.OperationType_PAYOUT:
return "Payout"
case connectorv1.OperationType_FEE_ESTIMATE:
return "Fee Estimate"
case connectorv1.OperationType_FX:
return "FX"
case connectorv1.OperationType_GAS_TOPUP:
return "Gas Top Up"
default:
return strings.TrimSpace(opType.String())
}
}
func failureCodeFromStatus(status connectorv1.OperationStatus) string {
switch status {
case connectorv1.OperationStatus_OPERATION_FAILED, connectorv1.OperationStatus_OPERATION_CANCELLED:
return strings.TrimSpace(status.String())
default:
return ""
}
}
func operationParamValue(params *structpb.Struct, keys ...string) string {
if params == nil {
return ""
}
values := params.AsMap()
for _, key := range keys {
raw, ok := values[key]
if !ok {
continue
}
if text := strings.TrimSpace(fmt.Sprint(raw)); text != "" && text != "<nil>" {
return text
}
}
return ""
}
func findDocumentsService(services []discovery.ServiceSummary) *discovery.ServiceSummary {

View File

@@ -106,7 +106,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-quote"), api.Post, p.initiateByQuote)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-multiquote"), api.Post, p.initiatePaymentsByQuote)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listPayments)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/documents/act"), api.Get, p.getActDocument)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/documents/operation"), api.Get, p.getOperationDocument)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry"), api.Get, p.listDiscoveryRegistry)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry/refresh"), api.Get, p.getDiscoveryRefresh)
@@ -207,7 +207,7 @@ type grpcQuotationClient struct {
callTimeout time.Duration
}
func newQuotationClient(ctx context.Context, cfg quotationClientConfig, opts ...grpc.DialOption) (quotationClient, error) {
func newQuotationClient(_ context.Context, cfg quotationClientConfig, opts ...grpc.DialOption) (quotationClient, error) {
cfg.setDefaults()
if strings.TrimSpace(cfg.Address) == "" {
return nil, merrors.InvalidArgument("payment quotation: address is required")

View File

@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/gateway/chain/internal/appversion"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
chainstoragemodel "github.com/tech/sendico/gateway/chain/storage/model"
chainasset "github.com/tech/sendico/pkg/chain"
"github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/merrors"
@@ -17,6 +18,7 @@ import (
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
)
const chainConnectorID = "chain"
@@ -293,11 +295,21 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
}
resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())})
operationRef := strings.TrimSpace(req.GetOperationId())
if s.storage == nil || s.storage.Transfers() == nil {
return nil, merrors.Internal("get_operation: storage is not configured")
}
transfer, err := s.storage.Transfers().FindByOperationRef(ctx, "", operationRef)
if err != nil {
return nil, err
}
return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(resp.GetTransfer())}, nil
if transfer == nil {
return nil, merrors.NoData("transfer not found")
}
return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(storageTransferToProto(transfer))}, nil
}
func (s *Service) ListOperations(ctx context.Context, req *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
@@ -493,6 +505,61 @@ func feeEstimateResult(resp *chainv1.EstimateTransferFeeResponse) *structpb.Stru
return result
}
func storageTransferToProto(transfer *chainstoragemodel.Transfer) *chainv1.Transfer {
if transfer == nil {
return nil
}
destination := &chainv1.TransferDestination{Memo: strings.TrimSpace(transfer.Destination.Memo)}
if managedWalletRef := strings.TrimSpace(transfer.Destination.ManagedWalletRef); managedWalletRef != "" {
destination.Destination = &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: managedWalletRef}
} else if externalAddress := strings.TrimSpace(transfer.Destination.ExternalAddress); externalAddress != "" {
destination.Destination = &chainv1.TransferDestination_ExternalAddress{ExternalAddress: externalAddress}
}
fees := make([]*chainv1.ServiceFeeBreakdown, 0, len(transfer.Fees))
for _, fee := range transfer.Fees {
fees = append(fees, &chainv1.ServiceFeeBreakdown{
FeeCode: strings.TrimSpace(fee.FeeCode),
Amount: fee.Amount,
Description: strings.TrimSpace(fee.Description),
})
}
asset := &chainv1.Asset{
Chain: shared.ChainEnumFromName(transfer.Network),
TokenSymbol: strings.TrimSpace(transfer.TokenSymbol),
ContractAddress: strings.TrimSpace(transfer.ContractAddress),
}
protoTransfer := &chainv1.Transfer{
TransferRef: strings.TrimSpace(transfer.TransferRef),
IdempotencyKey: strings.TrimSpace(transfer.IdempotencyKey),
IntentRef: strings.TrimSpace(transfer.IntentRef),
OperationRef: strings.TrimSpace(transfer.OperationRef),
OrganizationRef: strings.TrimSpace(transfer.OrganizationRef),
SourceWalletRef: strings.TrimSpace(transfer.SourceWalletRef),
Destination: destination,
Asset: asset,
RequestedAmount: shared.MonenyToProto(transfer.RequestedAmount),
NetAmount: shared.MonenyToProto(transfer.NetAmount),
Fees: fees,
Status: shared.TransferStatusToProto(transfer.Status),
TransactionHash: strings.TrimSpace(transfer.TxHash),
FailureReason: strings.TrimSpace(transfer.FailureReason),
PaymentRef: strings.TrimSpace(transfer.PaymentRef),
}
if !transfer.CreatedAt.IsZero() {
protoTransfer.CreatedAt = timestamppb.New(transfer.CreatedAt.UTC())
}
if !transfer.UpdatedAt.IsZero() {
protoTransfer.UpdatedAt = timestamppb.New(transfer.UpdatedAt.UTC())
}
return protoTransfer
}
func gasTopUpResult(amount *moneyv1.Money, capHit bool, transferRef string) *structpb.Struct {
payload := map[string]interface{}{
"cap_hit": capHit,
@@ -518,18 +585,33 @@ func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation
return nil
}
op := &connectorv1.Operation{
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Type: connectorv1.OperationType_TRANSFER,
Status: chainTransferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(),
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(),
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Type: connectorv1.OperationType_TRANSFER,
Status: chainTransferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(),
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
IntentRef: strings.TrimSpace(transfer.GetIntentRef()),
OperationRef: strings.TrimSpace(transfer.GetOperationRef()),
CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chainConnectorID,
AccountId: strings.TrimSpace(transfer.GetSourceWalletRef()),
}}},
}
params := map[string]interface{}{}
if paymentRef := strings.TrimSpace(transfer.GetPaymentRef()); paymentRef != "" {
params["payment_ref"] = paymentRef
}
if organizationRef := strings.TrimSpace(transfer.GetOrganizationRef()); organizationRef != "" {
params["organization_ref"] = organizationRef
}
if failureReason := strings.TrimSpace(transfer.GetFailureReason()); failureReason != "" {
params["failure_reason"] = failureReason
}
if len(params) > 0 {
op.Params = structFromMap(params)
}
if dest := transfer.GetDestination(); dest != nil {
switch d := dest.GetDestination().(type) {
case *chainv1.TransferDestination_ManagedWalletRef:
@@ -629,6 +711,17 @@ func operationAccountID(party *connectorv1.OperationParty) string {
return ""
}
func structFromMap(values map[string]interface{}) *structpb.Struct {
if len(values) == 0 {
return nil
}
result, err := structpb.NewStruct(values)
if err != nil {
return nil
}
return result
}
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
err := &connectorv1.ConnectorError{
Code: code,

View File

@@ -500,6 +500,32 @@ func (t *inMemoryTransfers) Get(ctx context.Context, transferRef string) (*model
return transfer, nil
}
func (t *inMemoryTransfers) FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) {
t.mu.Lock()
defer t.mu.Unlock()
org := strings.TrimSpace(organizationRef)
opRef := strings.TrimSpace(operationRef)
if opRef == "" {
return nil, merrors.InvalidArgument("transfersStore: empty operationRef")
}
for _, transfer := range t.items {
if transfer == nil {
continue
}
if !strings.EqualFold(strings.TrimSpace(transfer.OperationRef), opRef) {
continue
}
if org != "" && !strings.EqualFold(strings.TrimSpace(transfer.OrganizationRef), org) {
continue
}
return transfer, nil
}
return nil, merrors.NoData("transfer not found")
}
func (t *inMemoryTransfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
t.mu.Lock()
defer t.mu.Unlock()

View File

@@ -40,6 +40,9 @@ func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error)
Keys: []ri.Key{{Field: "transferRef", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}, {Field: "operationRef", Sort: ri.Asc}},
},
{
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}},
Unique: true,
@@ -110,6 +113,25 @@ func (t *Transfers) Get(ctx context.Context, transferRef string) (*model.Transfe
return transfer, nil
}
func (t *Transfers) FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) {
operationRef = strings.TrimSpace(operationRef)
if operationRef == "" {
return nil, merrors.InvalidArgument("transfersStore: empty operationRef")
}
query := repository.Query().Filter(repository.Field("operationRef"), operationRef)
if org := strings.TrimSpace(organizationRef); org != "" {
query = query.Filter(repository.Field("organizationRef"), org)
}
transfer := &model.Transfer{}
if err := t.repo.FindOneByFilter(ctx, query, transfer); err != nil {
return nil, err
}
return transfer, nil
}
func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
query := repository.Query()
if src := strings.TrimSpace(filter.SourceWalletRef); src != "" {

View File

@@ -42,6 +42,7 @@ type WalletsStore interface {
type TransfersStore interface {
Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error)
Get(ctx context.Context, transferRef string) (*model.Transfer, error)
FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error)
List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error)
UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error)
}

View File

@@ -44,7 +44,7 @@ mcards:
request_timeout_seconds: 15
status_success: "success"
status_processing: "processing"
strict_operation_mode: true
strict_operation_mode: false
gateway:
id: "mcards"

View File

@@ -12,6 +12,7 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/protobuf/types/known/structpb"
)
const mntxConnectorID = "mntx"
@@ -92,11 +93,21 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
}
resp, err := s.GetCardPayoutStatus(ctx, &mntxv1.GetCardPayoutStatusRequest{PayoutId: strings.TrimSpace(req.GetOperationId())})
operationRef := strings.TrimSpace(req.GetOperationId())
if s.storage == nil || s.storage.Payouts() == nil {
return nil, merrors.Internal("get_operation: storage is not configured")
}
payout, err := s.storage.Payouts().FindByOperationRef(ctx, operationRef)
if err != nil {
return nil, err
}
return &connectorv1.GetOperationResponse{Operation: payoutToOperation(resp.GetPayout())}, nil
if payout == nil {
return nil, merrors.NoData("payout not found")
}
return &connectorv1.GetOperationResponse{Operation: payoutToOperation(StateToProto(payout))}, nil
}
func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
@@ -274,7 +285,7 @@ func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation {
if state == nil {
return nil
}
return &connectorv1.Operation{
op := &connectorv1.Operation{
OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())),
Type: connectorv1.OperationType_PAYOUT,
Status: payoutStatusToOperation(state.GetStatus()),
@@ -282,10 +293,30 @@ func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation {
Amount: minorToDecimal(state.GetAmountMinor()),
Currency: strings.ToUpper(strings.TrimSpace(state.GetCurrency())),
},
ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()),
CreatedAt: state.GetCreatedAt(),
UpdatedAt: state.GetUpdatedAt(),
ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()),
IntentRef: strings.TrimSpace(state.GetIntentRef()),
OperationRef: strings.TrimSpace(state.GetOperationRef()),
CreatedAt: state.GetCreatedAt(),
UpdatedAt: state.GetUpdatedAt(),
}
params := map[string]interface{}{}
if paymentRef := strings.TrimSpace(state.GetParentPaymentRef()); paymentRef != "" {
params["payment_ref"] = paymentRef
params["parent_payment_ref"] = paymentRef
}
if providerCode := strings.TrimSpace(state.GetProviderCode()); providerCode != "" {
params["provider_code"] = providerCode
}
if providerMessage := strings.TrimSpace(state.GetProviderMessage()); providerMessage != "" {
params["provider_message"] = providerMessage
params["failure_reason"] = providerMessage
}
if len(params) > 0 {
op.Params = structFromMap(params)
}
return op
}
func minorToDecimal(amount int64) string {
@@ -316,6 +347,17 @@ func payoutStatusToOperation(status mntxv1.PayoutStatus) connectorv1.OperationSt
}
}
func structFromMap(values map[string]interface{}) *structpb.Struct {
if len(values) == 0 {
return nil
}
result, err := structpb.NewStruct(values)
if err != nil {
return nil
}
return result
}
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
err := &connectorv1.ConnectorError{
Code: code,

View File

@@ -41,3 +41,15 @@ gateway:
timeout_seconds: 345600
accepted_user_ids: []
success_reaction: "\U0001FAE1"
treasury:
execution_delay: 60s
poll_interval: 60s
telegram:
allowed_chats: []
users: []
ledger:
timeout: 5s
limits:
max_amount_per_operation: "1000000"
max_daily_amount: "5000000"

View File

@@ -41,3 +41,17 @@ gateway:
timeout_seconds: 345600
accepted_user_ids: []
success_reaction: "\U0001FAE1"
treasury:
execution_delay: 60s
poll_interval: 60s
ledger:
timeout: 5s
limits:
max_amount_per_operation: ""
max_daily_amount: ""
telegram:
allowed_chats: []
users:
- telegram_user_id: "8273799472"
- ledger_account: "6972c738949b91ea0395e5fb"

View File

@@ -3,6 +3,7 @@ package serverimp
import (
"context"
"os"
"strings"
"time"
"github.com/tech/sendico/gateway/tgsettle/internal/service/gateway"
@@ -28,11 +29,17 @@ type Imp struct {
config *config
app *grpcapp.App[storage.Repository]
service *gateway.Service
discoveryWatcher *discovery.RegistryWatcher
discoveryReg *discovery.Registry
}
type config struct {
*grpcapp.Config `yaml:",inline"`
Gateway gatewayConfig `yaml:"gateway"`
Gateway gatewayConfig `yaml:"gateway"`
Treasury treasuryConfig `yaml:"treasury"`
Ledger ledgerConfig `yaml:"ledger"` // deprecated: use treasury.ledger
Telegram telegramConfig `yaml:"telegram"` // deprecated: use treasury.telegram
}
type gatewayConfig struct {
@@ -43,6 +50,33 @@ type gatewayConfig struct {
SuccessReaction string `yaml:"success_reaction"`
}
type telegramConfig struct {
AllowedChats []string `yaml:"allowed_chats"`
Users []telegramUserConfig `yaml:"users"`
}
type telegramUserConfig struct {
TelegramUserID string `yaml:"telegram_user_id"`
LedgerAccount string `yaml:"ledger_account"`
}
type treasuryConfig struct {
ExecutionDelay time.Duration `yaml:"execution_delay"`
PollInterval time.Duration `yaml:"poll_interval"`
Telegram telegramConfig `yaml:"telegram"`
Ledger ledgerConfig `yaml:"ledger"`
Limits treasuryLimitsConfig `yaml:"limits"`
}
type treasuryLimitsConfig struct {
MaxAmountPerOperation string `yaml:"max_amount_per_operation"`
MaxDailyAmount string `yaml:"max_daily_amount"`
}
type ledgerConfig struct {
Timeout time.Duration `yaml:"timeout"`
}
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{
logger: logger.Named("server"),
@@ -62,6 +96,9 @@ func (i *Imp) Shutdown() {
if i.service != nil {
i.service.Shutdown()
}
if i.discoveryWatcher != nil {
i.discoveryWatcher.Stop()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
i.app.Shutdown(ctx)
@@ -81,6 +118,19 @@ func (i *Imp) Start() error {
i.logger.Warn("Failed to create messaging broker", zap.Error(err))
}
}
if broker != nil {
registry := discovery.NewRegistry()
watcher, watcherErr := discovery.NewRegistryWatcher(i.logger, broker, registry)
if watcherErr != nil {
i.logger.Warn("Failed to initialise discovery registry watcher", zap.Error(watcherErr))
} else if startErr := watcher.Start(); startErr != nil {
i.logger.Warn("Failed to start discovery registry watcher", zap.Error(startErr))
} else {
i.discoveryWatcher = watcher
i.discoveryReg = registry
i.logger.Info("Discovery registry watcher started")
}
}
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
return gatewaymongo.New(logger, conn)
@@ -95,6 +145,8 @@ func (i *Imp) Start() error {
if cfg.Messaging != nil {
msgSettings = cfg.Messaging.Settings
}
treasuryTelegram := treasuryTelegramConfig(cfg, i.logger)
treasuryLedger := treasuryLedgerConfig(cfg, i.logger)
gwCfg := gateway.Config{
Rail: cfg.Gateway.Rail,
TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv,
@@ -103,6 +155,22 @@ func (i *Imp) Start() error {
SuccessReaction: cfg.Gateway.SuccessReaction,
InvokeURI: invokeURI,
MessagingSettings: msgSettings,
DiscoveryRegistry: i.discoveryReg,
Treasury: gateway.TreasuryConfig{
ExecutionDelay: cfg.Treasury.ExecutionDelay,
PollInterval: cfg.Treasury.PollInterval,
Telegram: gateway.TelegramConfig{
AllowedChats: treasuryTelegram.AllowedChats,
Users: telegramUsers(treasuryTelegram.Users),
},
Ledger: gateway.LedgerConfig{
Timeout: treasuryLedger.Timeout,
},
Limits: gateway.TreasuryLimitsConfig{
MaxAmountPerOperation: cfg.Treasury.Limits.MaxAmountPerOperation,
MaxDailyAmount: cfg.Treasury.Limits.MaxDailyAmount,
},
},
}
svc := gateway.NewService(logger, repo, producer, broker, gwCfg)
i.service = svc
@@ -142,6 +210,15 @@ func (i *Imp) loadConfig() (*config, error) {
if cfg.Metrics == nil {
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9406"}
}
if cfg.Treasury.ExecutionDelay <= 0 {
cfg.Treasury.ExecutionDelay = 30 * time.Second
}
if cfg.Treasury.PollInterval <= 0 {
cfg.Treasury.PollInterval = 30 * time.Second
}
if cfg.Treasury.Ledger.Timeout <= 0 {
cfg.Treasury.Ledger.Timeout = 5 * time.Second
}
cfg.Gateway.Rail = discovery.NormalizeRail(cfg.Gateway.Rail)
if cfg.Gateway.Rail == "" {
return nil, merrors.InvalidArgument("gateway rail is required", "gateway.rail")
@@ -151,3 +228,46 @@ func (i *Imp) loadConfig() (*config, error) {
}
return cfg, nil
}
func telegramUsers(input []telegramUserConfig) []gateway.TelegramUserBinding {
result := make([]gateway.TelegramUserBinding, 0, len(input))
for _, next := range input {
result = append(result, gateway.TelegramUserBinding{
TelegramUserID: strings.TrimSpace(next.TelegramUserID),
LedgerAccount: strings.TrimSpace(next.LedgerAccount),
})
}
return result
}
func treasuryTelegramConfig(cfg *config, logger mlogger.Logger) telegramConfig {
if cfg == nil {
return telegramConfig{}
}
if len(cfg.Treasury.Telegram.Users) > 0 || len(cfg.Treasury.Telegram.AllowedChats) > 0 {
return cfg.Treasury.Telegram
}
if len(cfg.Telegram.Users) > 0 || len(cfg.Telegram.AllowedChats) > 0 {
if logger != nil {
logger.Warn("Deprecated config path used: telegram.*; move these settings to treasury.telegram.*")
}
return cfg.Telegram
}
return cfg.Treasury.Telegram
}
func treasuryLedgerConfig(cfg *config, logger mlogger.Logger) ledgerConfig {
if cfg == nil {
return ledgerConfig{}
}
if cfg.Treasury.Ledger.Timeout > 0 {
return cfg.Treasury.Ledger
}
if cfg.Ledger.Timeout > 0 {
if logger != nil {
logger.Warn("Deprecated config path used: ledger.*; move these settings to treasury.ledger.*")
}
return cfg.Ledger
}
return cfg.Treasury.Ledger
}

View File

@@ -146,6 +146,7 @@ func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWe
message := update.Message
replyToID := strings.TrimSpace(message.ReplyToMessageID)
if replyToID == "" {
s.handleTreasuryTelegramUpdate(ctx, update)
return nil
}
replyFields := telegramReplyLogFields(update)
@@ -154,6 +155,9 @@ func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWe
return err
}
if pending == nil {
if s.handleTreasuryTelegramUpdate(ctx, update) {
return nil
}
s.logger.Warn("Telegram confirmation reply dropped",
append(replyFields,
zap.String("outcome", "dropped"),
@@ -272,6 +276,13 @@ func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWe
return nil
}
func (s *Service) handleTreasuryTelegramUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
if s == nil || s.treasury == nil || update == nil || update.Message == nil {
return false
}
return s.treasury.HandleUpdate(ctx, update)
}
func telegramReplyLogFields(update *model.TelegramWebhookUpdate) []zap.Field {
if update == nil || update.Message == nil {
return nil

View File

@@ -11,6 +11,9 @@ import (
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
)
const tgsettleConnectorID = "tgsettle"
@@ -152,12 +155,22 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
}
operationID := strings.TrimSpace(req.GetOperationId())
resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: operationID})
if s.repo == nil || s.repo.Payments() == nil {
s.logger.Warn("Get operation storage unavailable", zap.String("operation_id", operationID))
return nil, merrors.Internal("get_operation: storage is not configured")
}
record, err := s.repo.Payments().FindByOperationRef(ctx, operationID)
if err != nil {
s.logger.Warn("Get operation failed", zap.String("operation_id", operationID), zap.Error(err))
s.logger.Warn("Get operation lookup by operation_ref failed", zap.String("operation_id", operationID), zap.Error(err))
return nil, err
}
return &connectorv1.GetOperationResponse{Operation: transferToOperation(resp.GetTransfer())}, nil
if record == nil {
return nil, status.Error(codes.NotFound, "operation not found")
}
return &connectorv1.GetOperationResponse{Operation: transferToOperation(transferFromPayment(record, nil))}, nil
}
func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
@@ -221,6 +234,19 @@ func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(),
}
params := map[string]interface{}{}
if paymentRef := strings.TrimSpace(transfer.GetPaymentRef()); paymentRef != "" {
params["payment_ref"] = paymentRef
}
if organizationRef := strings.TrimSpace(transfer.GetOrganizationRef()); organizationRef != "" {
params["organization_ref"] = organizationRef
}
if failureReason := strings.TrimSpace(transfer.GetFailureReason()); failureReason != "" {
params["failure_reason"] = failureReason
}
if len(params) > 0 {
op.Params = structFromMap(params)
}
if source := strings.TrimSpace(transfer.GetSourceWalletRef()); source != "" {
op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: tgsettleConnectorID,
@@ -281,6 +307,17 @@ func operationAccountID(party *connectorv1.OperationParty) string {
return ""
}
func structFromMap(values map[string]interface{}) *structpb.Struct {
if len(values) == 0 {
return nil
}
result, err := structpb.NewStruct(values)
if err != nil {
return nil
}
return result
}
func operationLogFields(op *connectorv1.Operation) []zap.Field {
if op == nil {
return nil

View File

@@ -9,6 +9,8 @@ import (
"time"
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
treasurysvc "github.com/tech/sendico/gateway/tgsettle/internal/service/treasury"
treasuryledger "github.com/tech/sendico/gateway/tgsettle/internal/service/treasury/ledger"
"github.com/tech/sendico/gateway/tgsettle/storage"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/api/routers"
@@ -40,6 +42,9 @@ const (
defaultConfirmationTimeoutSeconds = 345600
defaultTelegramSuccessReaction = "\U0001FAE1"
defaultConfirmationSweepInterval = 5 * time.Second
defaultTreasuryExecutionDelay = 30 * time.Second
defaultTreasuryPollInterval = 30 * time.Second
defaultTreasuryLedgerTimeout = 5 * time.Second
)
const (
@@ -59,6 +64,35 @@ type Config struct {
SuccessReaction string
InvokeURI string
MessagingSettings pmodel.SettingsT
DiscoveryRegistry *discovery.Registry
Treasury TreasuryConfig
}
type TelegramConfig struct {
AllowedChats []string
Users []TelegramUserBinding
}
type TelegramUserBinding struct {
TelegramUserID string
LedgerAccount string
}
type TreasuryConfig struct {
ExecutionDelay time.Duration
PollInterval time.Duration
Telegram TelegramConfig
Ledger LedgerConfig
Limits TreasuryLimitsConfig
}
type TreasuryLimitsConfig struct {
MaxAmountPerOperation string
MaxDailyAmount string
}
type LedgerConfig struct {
Timeout time.Duration
}
type Service struct {
@@ -80,6 +114,8 @@ type Service struct {
timeoutCancel context.CancelFunc
timeoutWG sync.WaitGroup
treasury *treasurysvc.Module
connectorv1.UnimplementedConnectorServiceServer
}
@@ -112,6 +148,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
svc.startConsumers()
svc.startAnnouncer()
svc.startConfirmationTimeoutWatcher()
svc.startTreasuryModule()
return svc
}
@@ -134,12 +171,91 @@ func (s *Service) Shutdown() {
consumer.Close()
}
}
if s.treasury != nil {
s.treasury.Shutdown()
}
if s.timeoutCancel != nil {
s.timeoutCancel()
}
s.timeoutWG.Wait()
}
func (s *Service) startTreasuryModule() {
if s == nil || s.repo == nil || s.repo.TreasuryRequests() == nil {
return
}
if s.cfg.DiscoveryRegistry == nil {
s.logger.Warn("Treasury module disabled: discovery registry is unavailable")
return
}
if len(s.cfg.Treasury.Telegram.Users) == 0 {
return
}
ledgerTimeout := s.cfg.Treasury.Ledger.Timeout
if ledgerTimeout <= 0 {
ledgerTimeout = defaultTreasuryLedgerTimeout
}
ledgerClient, err := treasuryledger.NewDiscoveryClient(treasuryledger.DiscoveryConfig{
Logger: s.logger,
Registry: s.cfg.DiscoveryRegistry,
Timeout: ledgerTimeout,
})
if err != nil {
s.logger.Warn("Failed to initialise treasury ledger client", zap.Error(err))
return
}
executionDelay := s.cfg.Treasury.ExecutionDelay
if executionDelay <= 0 {
executionDelay = defaultTreasuryExecutionDelay
}
pollInterval := s.cfg.Treasury.PollInterval
if pollInterval <= 0 {
pollInterval = defaultTreasuryPollInterval
}
users := make([]treasurysvc.UserBinding, 0, len(s.cfg.Treasury.Telegram.Users))
for _, binding := range s.cfg.Treasury.Telegram.Users {
users = append(users, treasurysvc.UserBinding{
TelegramUserID: binding.TelegramUserID,
LedgerAccount: binding.LedgerAccount,
})
}
module, err := treasurysvc.NewModule(
s.logger,
s.repo.TreasuryRequests(),
ledgerClient,
treasurysvc.Config{
AllowedChats: s.cfg.Treasury.Telegram.AllowedChats,
Users: users,
ExecutionDelay: executionDelay,
PollInterval: pollInterval,
MaxAmountPerOperation: s.cfg.Treasury.Limits.MaxAmountPerOperation,
MaxDailyAmount: s.cfg.Treasury.Limits.MaxDailyAmount,
},
func(ctx context.Context, chatID string, text string) error {
return s.sendTelegramText(ctx, &model.TelegramTextRequest{
ChatID: chatID,
Text: text,
})
},
)
if err != nil {
s.logger.Warn("Failed to initialise treasury module", zap.Error(err))
_ = ledgerClient.Close()
return
}
if !module.Enabled() {
_ = ledgerClient.Close()
return
}
module.Start()
s.treasury = module
s.logger.Info("Treasury module started", zap.Duration("execution_delay", executionDelay), zap.Duration("poll_interval", pollInterval))
}
func (s *Service) startConsumers() {
if s == nil || s.broker == nil {
if s != nil && s.logger != nil {
@@ -675,6 +791,9 @@ func transferFromRequest(req *chainv1.SubmitTransferRequest) *chainv1.Transfer {
SourceWalletRef: strings.TrimSpace(req.GetSourceWalletRef()),
Destination: req.GetDestination(),
RequestedAmount: req.GetAmount(),
IntentRef: strings.TrimSpace(req.GetIntentRef()),
OperationRef: strings.TrimSpace(req.GetOperationRef()),
PaymentRef: strings.TrimSpace(req.GetPaymentRef()),
Status: chainv1.TransferStatus_TRANSFER_CREATED,
}
}
@@ -714,6 +833,10 @@ func transferFromPayment(record *storagemodel.PaymentRecord, req *chainv1.Submit
IdempotencyKey: strings.TrimSpace(record.IdempotencyKey),
RequestedAmount: requested,
NetAmount: net,
IntentRef: strings.TrimSpace(record.IntentRef),
OperationRef: strings.TrimSpace(record.OperationRef),
PaymentRef: strings.TrimSpace(record.PaymentRef),
FailureReason: strings.TrimSpace(record.FailureReason),
Status: status,
}

View File

@@ -37,6 +37,20 @@ func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string)
return f.records[key], nil
}
func (f *fakePaymentsStore) FindByOperationRef(_ context.Context, key string) (*storagemodel.PaymentRecord, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.records == nil {
return nil, nil
}
for _, record := range f.records {
if record != nil && record.OperationRef == key {
return record, nil
}
}
return nil, nil
}
func (f *fakePaymentsStore) Upsert(_ context.Context, record *storagemodel.PaymentRecord) error {
f.mu.Lock()
defer f.mu.Unlock()
@@ -66,6 +80,7 @@ type fakeRepo struct {
payments *fakePaymentsStore
tg *fakeTelegramStore
pending *fakePendingStore
treasury storage.TreasuryRequestsStore
}
func (f *fakeRepo) Payments() storage.PaymentsStore {
@@ -80,6 +95,10 @@ func (f *fakeRepo) PendingConfirmations() storage.PendingConfirmationsStore {
return f.pending
}
func (f *fakeRepo) TreasuryRequests() storage.TreasuryRequestsStore {
return f.treasury
}
type fakePendingStore struct {
mu sync.Mutex
records map[string]*storagemodel.PendingConfirmation

View File

@@ -0,0 +1,73 @@
package bot
import (
"strings"
"sync"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
)
type DialogState string
const (
DialogStateWaitingAmount DialogState = "waiting_amount"
DialogStateWaitingConfirmation DialogState = "waiting_confirmation"
)
type DialogSession struct {
State DialogState
OperationType storagemodel.TreasuryOperationType
LedgerAccountID string
RequestID string
}
type Dialogs struct {
mu sync.Mutex
sessions map[string]DialogSession
}
func NewDialogs() *Dialogs {
return &Dialogs{
sessions: map[string]DialogSession{},
}
}
func (d *Dialogs) Get(telegramUserID string) (DialogSession, bool) {
if d == nil {
return DialogSession{}, false
}
telegramUserID = strings.TrimSpace(telegramUserID)
if telegramUserID == "" {
return DialogSession{}, false
}
d.mu.Lock()
defer d.mu.Unlock()
session, ok := d.sessions[telegramUserID]
return session, ok
}
func (d *Dialogs) Set(telegramUserID string, session DialogSession) {
if d == nil {
return
}
telegramUserID = strings.TrimSpace(telegramUserID)
if telegramUserID == "" {
return
}
d.mu.Lock()
defer d.mu.Unlock()
d.sessions[telegramUserID] = session
}
func (d *Dialogs) Clear(telegramUserID string) {
if d == nil {
return
}
telegramUserID = strings.TrimSpace(telegramUserID)
if telegramUserID == "" {
return
}
d.mu.Lock()
defer d.mu.Unlock()
delete(d.sessions, telegramUserID)
}

View File

@@ -0,0 +1,370 @@
package bot
import (
"context"
"errors"
"strconv"
"strings"
"time"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
const unauthorizedMessage = "Sorry, your Telegram account is not authorized to perform treasury operations."
const welcomeMessage = "Welcome to tgsettle treasury bot.\n\nUse /fund to credit your account and /withdraw to debit it.\nAfter entering an amount, use /confirm or /cancel."
type SendTextFunc func(ctx context.Context, chatID string, text string) error
type ScheduleTracker interface {
TrackScheduled(record *storagemodel.TreasuryRequest)
Untrack(requestID string)
}
type CreateRequestInput struct {
OperationType storagemodel.TreasuryOperationType
TelegramUserID string
LedgerAccountID string
ChatID string
Amount string
}
type TreasuryService interface {
ExecutionDelay() time.Duration
MaxPerOperationLimit() string
GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error)
CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error)
ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error)
CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error)
}
type limitError interface {
error
LimitKind() string
LimitMax() string
}
type Router struct {
logger mlogger.Logger
service TreasuryService
dialogs *Dialogs
send SendTextFunc
tracker ScheduleTracker
allowedChats map[string]struct{}
userAccounts map[string]string
allowAnyChat bool
}
func NewRouter(
logger mlogger.Logger,
service TreasuryService,
send SendTextFunc,
tracker ScheduleTracker,
allowedChats []string,
userAccounts map[string]string,
) *Router {
if logger != nil {
logger = logger.Named("treasury_router")
}
allowed := map[string]struct{}{}
for _, chatID := range allowedChats {
chatID = strings.TrimSpace(chatID)
if chatID == "" {
continue
}
allowed[chatID] = struct{}{}
}
users := map[string]string{}
for userID, accountID := range userAccounts {
userID = strings.TrimSpace(userID)
accountID = strings.TrimSpace(accountID)
if userID == "" || accountID == "" {
continue
}
users[userID] = accountID
}
return &Router{
logger: logger,
service: service,
dialogs: NewDialogs(),
send: send,
tracker: tracker,
allowedChats: allowed,
userAccounts: users,
allowAnyChat: len(allowed) == 0,
}
}
func (r *Router) Enabled() bool {
return r != nil && r.service != nil && len(r.userAccounts) > 0
}
func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
if !r.Enabled() || update == nil || update.Message == nil {
return false
}
message := update.Message
chatID := strings.TrimSpace(message.ChatID)
userID := strings.TrimSpace(message.FromUserID)
text := strings.TrimSpace(message.Text)
if chatID == "" || userID == "" {
return false
}
if !r.allowAnyChat {
if _, ok := r.allowedChats[chatID]; !ok {
return true
}
}
accountID, ok := r.userAccounts[userID]
if !ok || strings.TrimSpace(accountID) == "" {
r.logUnauthorized(update)
_ = r.sendText(ctx, chatID, unauthorizedMessage)
return true
}
command := parseCommand(text)
switch command {
case "start":
_ = r.sendText(ctx, chatID, welcomeMessage)
return true
case "fund":
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationFund)
return true
case "withdraw":
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationWithdraw)
return true
case "confirm":
r.confirm(ctx, userID, accountID, chatID)
return true
case "cancel":
r.cancel(ctx, userID, accountID, chatID)
return true
}
session, hasSession := r.dialogs.Get(userID)
if hasSession {
switch session.State {
case DialogStateWaitingAmount:
r.captureAmount(ctx, userID, accountID, chatID, session.OperationType, text)
return true
case DialogStateWaitingConfirmation:
_ = r.sendText(ctx, chatID, "Confirm operation?\n\n/confirm\n/cancel")
return true
}
}
if strings.HasPrefix(text, "/") {
_ = r.sendText(ctx, chatID, "Supported commands:\n/start\n/fund\n/withdraw\n/confirm\n/cancel")
return true
}
return false
}
func (r *Router) startAmountDialog(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType) {
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
if err != nil {
r.logger.Warn("Failed to check active treasury request", zap.Error(err), zap.String("telegram_user_id", userID), zap.String("ledger_account_id", accountID))
return
}
if active != nil {
_ = r.sendText(ctx, chatID, pendingRequestMessage(active))
r.dialogs.Set(userID, DialogSession{
State: DialogStateWaitingConfirmation,
LedgerAccountID: accountID,
RequestID: active.RequestID,
})
return
}
r.dialogs.Set(userID, DialogSession{
State: DialogStateWaitingAmount,
OperationType: operation,
LedgerAccountID: accountID,
})
_ = r.sendText(ctx, chatID, "Enter amount:")
}
func (r *Router) captureAmount(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType, amount string) {
record, err := r.service.CreateRequest(ctx, CreateRequestInput{
OperationType: operation,
TelegramUserID: userID,
LedgerAccountID: accountID,
ChatID: chatID,
Amount: amount,
})
if err != nil {
if record != nil {
_ = r.sendText(ctx, chatID, pendingRequestMessage(record))
r.dialogs.Set(userID, DialogSession{
State: DialogStateWaitingConfirmation,
LedgerAccountID: accountID,
RequestID: record.RequestID,
})
return
}
if typed, ok := err.(limitError); ok {
switch typed.LimitKind() {
case "per_operation":
_ = r.sendText(ctx, chatID, "Amount exceeds allowed limit.\n\nMax per operation: "+typed.LimitMax()+"\n\nEnter another amount or /cancel")
return
case "daily":
_ = r.sendText(ctx, chatID, "Daily amount limit exceeded.\n\nMax per day: "+typed.LimitMax()+"\n\nEnter another amount or /cancel")
return
}
}
if errors.Is(err, merrors.ErrInvalidArg) {
_ = r.sendText(ctx, chatID, "Invalid amount.\n\nEnter another amount or /cancel")
return
}
_ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or /cancel")
return
}
if record == nil {
_ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or /cancel")
return
}
r.dialogs.Set(userID, DialogSession{
State: DialogStateWaitingConfirmation,
LedgerAccountID: accountID,
RequestID: record.RequestID,
})
_ = r.sendText(ctx, chatID, confirmationPrompt(record))
}
func (r *Router) confirm(ctx context.Context, userID string, accountID string, chatID string) {
requestID := ""
if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" {
requestID = strings.TrimSpace(session.RequestID)
} else {
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
if err == nil && active != nil {
requestID = strings.TrimSpace(active.RequestID)
}
}
if requestID == "" {
_ = r.sendText(ctx, chatID, "No pending treasury operation.")
return
}
record, err := r.service.ConfirmRequest(ctx, requestID, userID)
if err != nil {
_ = r.sendText(ctx, chatID, "Unable to confirm treasury request.\n\nUse /cancel or create a new request with /fund or /withdraw.")
return
}
if r.tracker != nil {
r.tracker.TrackScheduled(record)
}
r.dialogs.Clear(userID)
delay := int64(r.service.ExecutionDelay().Seconds())
if delay < 0 {
delay = 0
}
_ = r.sendText(ctx, chatID, "Operation confirmed.\n\nExecution scheduled in "+formatSeconds(delay)+".\n\nRequest ID: "+strings.TrimSpace(record.RequestID))
}
func (r *Router) cancel(ctx context.Context, userID string, accountID string, chatID string) {
requestID := ""
if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" {
requestID = strings.TrimSpace(session.RequestID)
} else {
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
if err == nil && active != nil {
requestID = strings.TrimSpace(active.RequestID)
}
}
if requestID == "" {
r.dialogs.Clear(userID)
_ = r.sendText(ctx, chatID, "No pending treasury operation.")
return
}
record, err := r.service.CancelRequest(ctx, requestID, userID)
if err != nil {
_ = r.sendText(ctx, chatID, "Unable to cancel treasury request.")
return
}
if r.tracker != nil {
r.tracker.Untrack(record.RequestID)
}
r.dialogs.Clear(userID)
_ = r.sendText(ctx, chatID, "Operation cancelled.\n\nRequest ID: "+strings.TrimSpace(record.RequestID))
}
func (r *Router) sendText(ctx context.Context, chatID string, text string) error {
if r == nil || r.send == nil {
return nil
}
chatID = strings.TrimSpace(chatID)
text = strings.TrimSpace(text)
if chatID == "" || text == "" {
return nil
}
return r.send(ctx, chatID, text)
}
func (r *Router) logUnauthorized(update *model.TelegramWebhookUpdate) {
if r == nil || r.logger == nil || update == nil || update.Message == nil {
return
}
message := update.Message
r.logger.Warn("unauthorized_access",
zap.String("event", "unauthorized_access"),
zap.String("telegram_user_id", strings.TrimSpace(message.FromUserID)),
zap.String("chat_id", strings.TrimSpace(message.ChatID)),
zap.String("message_text", strings.TrimSpace(message.Text)),
zap.Time("timestamp", time.Now()),
)
}
func pendingRequestMessage(record *storagemodel.TreasuryRequest) string {
if record == nil {
return "You already have a pending treasury operation.\n\n/cancel"
}
return "You already have a pending treasury operation.\n\n" +
"Request ID: " + strings.TrimSpace(record.RequestID) + "\n" +
"Status: " + strings.TrimSpace(string(record.Status)) + "\n" +
"Amount: " + strings.TrimSpace(record.Amount) + " " + strings.TrimSpace(record.Currency) + "\n\n" +
"Wait for execution or cancel it.\n\n/cancel"
}
func confirmationPrompt(record *storagemodel.TreasuryRequest) string {
if record == nil {
return "Request created.\n\n/confirm\n/cancel"
}
title := "Funding request created."
if record.OperationType == storagemodel.TreasuryOperationWithdraw {
title = "Withdrawal request created."
}
return title + "\n\n" +
"Account: " + strings.TrimSpace(record.LedgerAccountID) + "\n" +
"Amount: " + strings.TrimSpace(record.Amount) + " " + strings.TrimSpace(record.Currency) + "\n\n" +
"Confirm operation?\n\n/confirm\n/cancel"
}
func parseCommand(text string) string {
text = strings.TrimSpace(text)
if !strings.HasPrefix(text, "/") {
return ""
}
token := text
if idx := strings.IndexAny(token, " \t\n\r"); idx >= 0 {
token = token[:idx]
}
token = strings.TrimPrefix(token, "/")
if idx := strings.Index(token, "@"); idx >= 0 {
token = token[:idx]
}
return strings.ToLower(strings.TrimSpace(token))
}
func formatSeconds(value int64) string {
if value == 1 {
return "1 second"
}
return strconv.FormatInt(value, 10) + " seconds"
}

View File

@@ -0,0 +1,220 @@
package bot
import (
"context"
"testing"
"time"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
"github.com/tech/sendico/pkg/model"
)
type fakeService struct{}
func (fakeService) ExecutionDelay() time.Duration {
return 30 * time.Second
}
func (fakeService) MaxPerOperationLimit() string {
return "1000000"
}
func (fakeService) GetActiveRequestForAccount(context.Context, string) (*storagemodel.TreasuryRequest, error) {
return nil, nil
}
func (fakeService) CreateRequest(context.Context, CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
return nil, nil
}
func (fakeService) ConfirmRequest(context.Context, string, string) (*storagemodel.TreasuryRequest, error) {
return nil, nil
}
func (fakeService) CancelRequest(context.Context, string, string) (*storagemodel.TreasuryRequest, error) {
return nil, nil
}
func TestRouterUnauthorizedInAllowedChatSendsAccessDenied(t *testing.T) {
var sent []string
router := NewRouter(
mloggerfactory.NewLogger(false),
fakeService{},
func(_ context.Context, _ string, text string) error {
sent = append(sent, text)
return nil
},
nil,
[]string{"100"},
map[string]string{"123": "acct-1"},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
ChatID: "100",
FromUserID: "999",
Text: "/fund",
},
})
if !handled {
t.Fatalf("expected update to be handled")
}
if len(sent) != 1 {
t.Fatalf("expected one message, got %d", len(sent))
}
if sent[0] != unauthorizedMessage {
t.Fatalf("unexpected message: %q", sent[0])
}
}
func TestRouterUnknownChatIsIgnored(t *testing.T) {
var sent []string
router := NewRouter(
mloggerfactory.NewLogger(false),
fakeService{},
func(_ context.Context, _ string, text string) error {
sent = append(sent, text)
return nil
},
nil,
[]string{"100"},
map[string]string{"123": "acct-1"},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
ChatID: "999",
FromUserID: "123",
Text: "/fund",
},
})
if !handled {
t.Fatalf("expected update to be handled")
}
if len(sent) != 0 {
t.Fatalf("expected no messages, got %d", len(sent))
}
}
func TestRouterEmptyAllowedChats_AllowsAnyChatForAuthorizedUser(t *testing.T) {
var sent []string
router := NewRouter(
mloggerfactory.NewLogger(false),
fakeService{},
func(_ context.Context, _ string, text string) error {
sent = append(sent, text)
return nil
},
nil,
nil,
map[string]string{"123": "acct-1"},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
ChatID: "999",
FromUserID: "123",
Text: "/fund",
},
})
if !handled {
t.Fatalf("expected update to be handled")
}
if len(sent) != 1 {
t.Fatalf("expected one message, got %d", len(sent))
}
if sent[0] != "Enter amount:" {
t.Fatalf("unexpected message: %q", sent[0])
}
}
func TestRouterEmptyAllowedChats_UnauthorizedUserGetsDenied(t *testing.T) {
var sent []string
router := NewRouter(
mloggerfactory.NewLogger(false),
fakeService{},
func(_ context.Context, _ string, text string) error {
sent = append(sent, text)
return nil
},
nil,
nil,
map[string]string{"123": "acct-1"},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
ChatID: "777",
FromUserID: "999",
Text: "/fund",
},
})
if !handled {
t.Fatalf("expected update to be handled")
}
if len(sent) != 1 {
t.Fatalf("expected one message, got %d", len(sent))
}
if sent[0] != unauthorizedMessage {
t.Fatalf("unexpected message: %q", sent[0])
}
}
func TestRouterStartAuthorizedShowsWelcome(t *testing.T) {
var sent []string
router := NewRouter(
mloggerfactory.NewLogger(false),
fakeService{},
func(_ context.Context, _ string, text string) error {
sent = append(sent, text)
return nil
},
nil,
nil,
map[string]string{"123": "acct-1"},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
ChatID: "777",
FromUserID: "123",
Text: "/start",
},
})
if !handled {
t.Fatalf("expected update to be handled")
}
if len(sent) != 1 {
t.Fatalf("expected one message, got %d", len(sent))
}
if sent[0] != welcomeMessage {
t.Fatalf("unexpected message: %q", sent[0])
}
}
func TestRouterStartUnauthorizedGetsDenied(t *testing.T) {
var sent []string
router := NewRouter(
mloggerfactory.NewLogger(false),
fakeService{},
func(_ context.Context, _ string, text string) error {
sent = append(sent, text)
return nil
},
nil,
nil,
map[string]string{"123": "acct-1"},
)
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
Message: &model.TelegramMessage{
ChatID: "777",
FromUserID: "999",
Text: "/start",
},
})
if !handled {
t.Fatalf("expected update to be handled")
}
if len(sent) != 1 {
t.Fatalf("expected one message, got %d", len(sent))
}
if sent[0] != unauthorizedMessage {
t.Fatalf("unexpected message: %q", sent[0])
}
}

View File

@@ -0,0 +1,19 @@
package treasury
import "time"
type UserBinding struct {
TelegramUserID string
LedgerAccount string
}
type Config struct {
AllowedChats []string
Users []UserBinding
ExecutionDelay time.Duration
PollInterval time.Duration
MaxAmountPerOperation string
MaxDailyAmount string
}

View File

@@ -0,0 +1,287 @@
package ledger
import (
"context"
"crypto/tls"
"fmt"
"net/url"
"strings"
"time"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/structpb"
)
const ledgerConnectorID = "ledger"
type Config struct {
Endpoint string
Timeout time.Duration
Insecure bool
}
type Account struct {
AccountID string
Currency string
OrganizationRef string
}
type Balance struct {
AccountID string
Amount string
Currency string
}
type PostRequest struct {
AccountID string
OrganizationRef string
Amount string
Currency string
Reference string
IdempotencyKey string
}
type OperationResult struct {
Reference string
}
type Client interface {
GetAccount(ctx context.Context, accountID string) (*Account, error)
GetBalance(ctx context.Context, accountID string) (*Balance, error)
ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error)
ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error)
Close() error
}
type grpcConnectorClient interface {
GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error)
GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error)
SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
}
type connectorClient struct {
cfg Config
conn *grpc.ClientConn
client grpcConnectorClient
}
func New(cfg Config) (Client, error) {
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
if cfg.Endpoint == "" {
return nil, merrors.InvalidArgument("ledger endpoint is required", "ledger.endpoint")
}
if normalized, insecure := normalizeEndpoint(cfg.Endpoint); normalized != "" {
cfg.Endpoint = normalized
if insecure {
cfg.Insecure = true
}
}
if cfg.Timeout <= 0 {
cfg.Timeout = 5 * time.Second
}
dialOpts := []grpc.DialOption{}
if cfg.Insecure {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
} else {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
}
conn, err := grpc.NewClient(cfg.Endpoint, dialOpts...)
if err != nil {
return nil, merrors.InternalWrap(err, fmt.Sprintf("ledger: dial %s", cfg.Endpoint))
}
return &connectorClient{
cfg: cfg,
conn: conn,
client: connectorv1.NewConnectorServiceClient(conn),
}, nil
}
func (c *connectorClient) Close() error {
if c == nil || c.conn == nil {
return nil
}
return c.conn.Close()
}
func (c *connectorClient) GetAccount(ctx context.Context, accountID string) (*Account, error) {
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
}
ctx, cancel := c.callContext(ctx)
defer cancel()
resp, err := c.client.GetAccount(ctx, &connectorv1.GetAccountRequest{
AccountRef: &connectorv1.AccountRef{
ConnectorId: ledgerConnectorID,
AccountId: accountID,
},
})
if err != nil {
return nil, err
}
account := resp.GetAccount()
if account == nil {
return nil, merrors.NoData("ledger account not found")
}
organizationRef := strings.TrimSpace(account.GetOwnerRef())
if organizationRef == "" && account.GetProviderDetails() != nil {
if value, ok := account.GetProviderDetails().AsMap()["organization_ref"]; ok {
organizationRef = strings.TrimSpace(fmt.Sprint(value))
}
}
return &Account{
AccountID: accountID,
Currency: strings.ToUpper(strings.TrimSpace(account.GetAsset())),
OrganizationRef: organizationRef,
}, nil
}
func (c *connectorClient) GetBalance(ctx context.Context, accountID string) (*Balance, error) {
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
}
ctx, cancel := c.callContext(ctx)
defer cancel()
resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{
AccountRef: &connectorv1.AccountRef{
ConnectorId: ledgerConnectorID,
AccountId: accountID,
},
})
if err != nil {
return nil, err
}
balance := resp.GetBalance()
if balance == nil || balance.GetAvailable() == nil {
return nil, merrors.Internal("ledger balance is unavailable")
}
return &Balance{
AccountID: accountID,
Amount: strings.TrimSpace(balance.GetAvailable().GetAmount()),
Currency: strings.ToUpper(strings.TrimSpace(balance.GetAvailable().GetCurrency())),
}, nil
}
func (c *connectorClient) ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) {
return c.submitExternalOperation(ctx, connectorv1.OperationType_CREDIT, discovery.OperationExternalCredit, req)
}
func (c *connectorClient) ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) {
return c.submitExternalOperation(ctx, connectorv1.OperationType_DEBIT, discovery.OperationExternalDebit, req)
}
func (c *connectorClient) submitExternalOperation(ctx context.Context, opType connectorv1.OperationType, operation string, req PostRequest) (*OperationResult, error) {
req.AccountID = strings.TrimSpace(req.AccountID)
req.OrganizationRef = strings.TrimSpace(req.OrganizationRef)
req.Amount = strings.TrimSpace(req.Amount)
req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
req.Reference = strings.TrimSpace(req.Reference)
req.IdempotencyKey = strings.TrimSpace(req.IdempotencyKey)
if req.AccountID == "" {
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
}
if req.OrganizationRef == "" {
return nil, merrors.InvalidArgument("ledger organization_ref is required", "organization_ref")
}
if req.Amount == "" || req.Currency == "" {
return nil, merrors.InvalidArgument("ledger amount is required", "amount")
}
if req.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("ledger idempotency_key is required", "idempotency_key")
}
params := map[string]any{
"organization_ref": req.OrganizationRef,
"operation": operation,
"description": "tgsettle treasury operation",
"metadata": map[string]any{
"reference": req.Reference,
},
}
operationReq := &connectorv1.Operation{
Type: opType,
IdempotencyKey: req.IdempotencyKey,
Money: &moneyv1.Money{
Amount: req.Amount,
Currency: req.Currency,
},
Params: structFromMap(params),
}
account := &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: req.AccountID}
switch opType {
case connectorv1.OperationType_CREDIT:
operationReq.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}}
case connectorv1.OperationType_DEBIT:
operationReq.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}}
}
ctx, cancel := c.callContext(ctx)
defer cancel()
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operationReq})
if err != nil {
return nil, err
}
if resp.GetReceipt() == nil {
return nil, merrors.Internal("ledger receipt is unavailable")
}
if receiptErr := resp.GetReceipt().GetError(); receiptErr != nil {
message := strings.TrimSpace(receiptErr.GetMessage())
if message == "" {
message = "ledger operation failed"
}
return nil, merrors.InvalidArgument(message)
}
reference := strings.TrimSpace(resp.GetReceipt().GetOperationId())
if reference == "" {
reference = req.Reference
}
return &OperationResult{Reference: reference}, nil
}
func (c *connectorClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
}
return context.WithTimeout(ctx, c.cfg.Timeout)
}
func structFromMap(values map[string]any) *structpb.Struct {
if len(values) == 0 {
return nil
}
result, err := structpb.NewStruct(values)
if err != nil {
return nil
}
return result
}
func normalizeEndpoint(raw string) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return raw, false
}
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
case "http", "grpc":
return parsed.Host, true
case "https", "grpcs":
return parsed.Host, false
default:
return raw, false
}
}

View File

@@ -0,0 +1,235 @@
package ledger
import (
"context"
"fmt"
"net"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
type DiscoveryConfig struct {
Logger mlogger.Logger
Registry *discovery.Registry
Timeout time.Duration
}
type discoveryEndpoint struct {
address string
insecure bool
raw string
}
func (e discoveryEndpoint) key() string {
return fmt.Sprintf("%s|%t", e.address, e.insecure)
}
type discoveryClient struct {
logger mlogger.Logger
registry *discovery.Registry
timeout time.Duration
mu sync.Mutex
client Client
endpointKey string
}
func NewDiscoveryClient(cfg DiscoveryConfig) (Client, error) {
if cfg.Registry == nil {
return nil, merrors.InvalidArgument("treasury ledger discovery registry is required", "registry")
}
if cfg.Timeout <= 0 {
cfg.Timeout = 5 * time.Second
}
logger := cfg.Logger
if logger != nil {
logger = logger.Named("treasury_ledger_discovery")
}
return &discoveryClient{
logger: logger,
registry: cfg.Registry,
timeout: cfg.Timeout,
}, nil
}
func (c *discoveryClient) Close() error {
if c == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if c.client != nil {
err := c.client.Close()
c.client = nil
c.endpointKey = ""
return err
}
return nil
}
func (c *discoveryClient) GetAccount(ctx context.Context, accountID string) (*Account, error) {
client, err := c.resolveClient(ctx)
if err != nil {
return nil, err
}
return client.GetAccount(ctx, accountID)
}
func (c *discoveryClient) GetBalance(ctx context.Context, accountID string) (*Balance, error) {
client, err := c.resolveClient(ctx)
if err != nil {
return nil, err
}
return client.GetBalance(ctx, accountID)
}
func (c *discoveryClient) ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) {
client, err := c.resolveClient(ctx)
if err != nil {
return nil, err
}
return client.ExternalCredit(ctx, req)
}
func (c *discoveryClient) ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) {
client, err := c.resolveClient(ctx)
if err != nil {
return nil, err
}
return client.ExternalDebit(ctx, req)
}
func (c *discoveryClient) resolveClient(_ context.Context) (Client, error) {
if c == nil || c.registry == nil {
return nil, merrors.Internal("treasury ledger discovery is unavailable")
}
endpoint, err := c.resolveEndpoint()
if err != nil {
return nil, err
}
key := endpoint.key()
c.mu.Lock()
defer c.mu.Unlock()
if c.client != nil && c.endpointKey == key {
return c.client, nil
}
if c.client != nil {
_ = c.client.Close()
c.client = nil
c.endpointKey = ""
}
next, err := New(Config{
Endpoint: endpoint.address,
Timeout: c.timeout,
Insecure: endpoint.insecure,
})
if err != nil {
return nil, err
}
c.client = next
c.endpointKey = key
if c.logger != nil {
c.logger.Info("Discovered ledger endpoint selected",
zap.String("service", string(mservice.Ledger)),
zap.String("invoke_uri", endpoint.raw),
zap.String("address", endpoint.address),
zap.Bool("insecure", endpoint.insecure))
}
return c.client, nil
}
func (c *discoveryClient) resolveEndpoint() (discoveryEndpoint, error) {
entries := c.registry.List(time.Now(), true)
type match struct {
entry discovery.RegistryEntry
opMatch bool
}
matches := make([]match, 0, len(entries))
requiredOps := discovery.LedgerServiceOperations()
for _, entry := range entries {
if !matchesService(entry.Service, mservice.Ledger) {
continue
}
matches = append(matches, match{
entry: entry,
opMatch: discovery.HasAnyOperation(entry.Operations, requiredOps),
})
}
if len(matches) == 0 {
return discoveryEndpoint{}, merrors.NoData("discovery: ledger service unavailable")
}
sort.Slice(matches, func(i, j int) bool {
if matches[i].opMatch != matches[j].opMatch {
return matches[i].opMatch
}
if matches[i].entry.RoutingPriority != matches[j].entry.RoutingPriority {
return matches[i].entry.RoutingPriority > matches[j].entry.RoutingPriority
}
if matches[i].entry.ID != matches[j].entry.ID {
return matches[i].entry.ID < matches[j].entry.ID
}
return matches[i].entry.InstanceID < matches[j].entry.InstanceID
})
return parseDiscoveryEndpoint(matches[0].entry.InvokeURI)
}
func matchesService(service string, candidate mservice.Type) bool {
service = strings.TrimSpace(service)
if service == "" || strings.TrimSpace(string(candidate)) == "" {
return false
}
return strings.EqualFold(service, strings.TrimSpace(string(candidate)))
}
func parseDiscoveryEndpoint(raw string) (discoveryEndpoint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri is required")
}
if !strings.Contains(raw, "://") {
if _, _, splitErr := net.SplitHostPort(raw); splitErr != nil {
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
}
return discoveryEndpoint{address: raw, insecure: true, raw: raw}, nil
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" {
if err != nil {
return discoveryEndpoint{}, err
}
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
}
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
switch scheme {
case "grpc":
address := strings.TrimSpace(parsed.Host)
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
}
return discoveryEndpoint{address: address, insecure: true, raw: raw}, nil
case "grpcs":
address := strings.TrimSpace(parsed.Host)
if _, _, splitErr := net.SplitHostPort(address); splitErr != nil {
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: invoke uri must include host:port")
}
return discoveryEndpoint{address: address, insecure: false, raw: raw}, nil
case "dns", "passthrough":
return discoveryEndpoint{address: raw, insecure: true, raw: raw}, nil
default:
return discoveryEndpoint{}, merrors.InvalidArgument("discovery: unsupported invoke uri scheme")
}
}

View File

@@ -0,0 +1,148 @@
package treasury
import (
"context"
"strings"
"time"
"github.com/tech/sendico/gateway/tgsettle/internal/service/treasury/bot"
"github.com/tech/sendico/gateway/tgsettle/internal/service/treasury/ledger"
"github.com/tech/sendico/gateway/tgsettle/storage"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type Module struct {
logger mlogger.Logger
service *Service
router *bot.Router
scheduler *Scheduler
ledger ledger.Client
}
func NewModule(
logger mlogger.Logger,
repo storage.TreasuryRequestsStore,
ledgerClient ledger.Client,
cfg Config,
send bot.SendTextFunc,
) (*Module, error) {
if logger != nil {
logger = logger.Named("treasury")
}
service, err := NewService(
logger,
repo,
ledgerClient,
cfg.ExecutionDelay,
cfg.MaxAmountPerOperation,
cfg.MaxDailyAmount,
)
if err != nil {
return nil, err
}
users := map[string]string{}
for _, binding := range cfg.Users {
userID := strings.TrimSpace(binding.TelegramUserID)
accountID := strings.TrimSpace(binding.LedgerAccount)
if userID == "" || accountID == "" {
continue
}
users[userID] = accountID
}
module := &Module{
logger: logger,
service: service,
ledger: ledgerClient,
}
module.scheduler = NewScheduler(logger, service, NotifyFunc(send), cfg.PollInterval)
module.router = bot.NewRouter(logger, &botServiceAdapter{svc: service}, send, module.scheduler, cfg.AllowedChats, users)
return module, nil
}
func (m *Module) Enabled() bool {
return m != nil && m.router != nil && m.router.Enabled() && m.scheduler != nil
}
func (m *Module) Start() {
if m == nil || m.scheduler == nil {
return
}
m.scheduler.Start()
}
func (m *Module) Shutdown() {
if m == nil {
return
}
if m.scheduler != nil {
m.scheduler.Shutdown()
}
if m.ledger != nil {
_ = m.ledger.Close()
}
}
func (m *Module) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
if m == nil || m.router == nil {
return false
}
return m.router.HandleUpdate(ctx, update)
}
type botServiceAdapter struct {
svc *Service
}
func (a *botServiceAdapter) ExecutionDelay() (delay time.Duration) {
if a == nil || a.svc == nil {
return 0
}
return a.svc.ExecutionDelay()
}
func (a *botServiceAdapter) MaxPerOperationLimit() string {
if a == nil || a.svc == nil {
return ""
}
return a.svc.MaxPerOperationLimit()
}
func (a *botServiceAdapter) GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) {
if a == nil || a.svc == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return a.svc.GetActiveRequestForAccount(ctx, ledgerAccountID)
}
func (a *botServiceAdapter) CreateRequest(ctx context.Context, input bot.CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
if a == nil || a.svc == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return a.svc.CreateRequest(ctx, CreateRequestInput{
OperationType: input.OperationType,
TelegramUserID: input.TelegramUserID,
LedgerAccountID: input.LedgerAccountID,
ChatID: input.ChatID,
Amount: input.Amount,
})
}
func (a *botServiceAdapter) ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
if a == nil || a.svc == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return a.svc.ConfirmRequest(ctx, requestID, telegramUserID)
}
func (a *botServiceAdapter) CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
if a == nil || a.svc == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return a.svc.CancelRequest(ctx, requestID, telegramUserID)
}

View File

@@ -0,0 +1,261 @@
package treasury
import (
"context"
"strings"
"sync"
"time"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type NotifyFunc func(ctx context.Context, chatID string, text string) error
type Scheduler struct {
logger mlogger.Logger
service *Service
notify NotifyFunc
safetySweepInterval time.Duration
cancel context.CancelFunc
wg sync.WaitGroup
timersMu sync.Mutex
timers map[string]*time.Timer
}
func NewScheduler(logger mlogger.Logger, service *Service, notify NotifyFunc, safetySweepInterval time.Duration) *Scheduler {
if logger != nil {
logger = logger.Named("treasury_scheduler")
}
if safetySweepInterval <= 0 {
safetySweepInterval = 30 * time.Second
}
return &Scheduler{
logger: logger,
service: service,
notify: notify,
safetySweepInterval: safetySweepInterval,
timers: map[string]*time.Timer{},
}
}
func (s *Scheduler) Start() {
if s == nil || s.service == nil || s.cancel != nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
// Rebuild in-memory timers from DB on startup.
s.hydrateTimers(ctx)
// Safety pass for overdue items at startup.
s.sweep(ctx)
s.wg.Add(1)
go func() {
defer s.wg.Done()
ticker := time.NewTicker(s.safetySweepInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sweep(ctx)
}
}
}()
}
func (s *Scheduler) Shutdown() {
if s == nil || s.cancel == nil {
return
}
s.cancel()
s.wg.Wait()
s.timersMu.Lock()
for requestID, timer := range s.timers {
if timer != nil {
timer.Stop()
}
delete(s.timers, requestID)
}
s.timersMu.Unlock()
}
func (s *Scheduler) TrackScheduled(record *storagemodel.TreasuryRequest) {
if s == nil || s.service == nil || record == nil {
return
}
if strings.TrimSpace(record.RequestID) == "" {
return
}
if record.Status != storagemodel.TreasuryRequestStatusScheduled {
return
}
requestID := strings.TrimSpace(record.RequestID)
when := record.ScheduledAt
if when.IsZero() {
when = time.Now()
}
delay := time.Until(when)
if delay <= 0 {
s.Untrack(requestID)
go s.executeAndNotifyByID(context.Background(), requestID)
return
}
s.timersMu.Lock()
if existing := s.timers[requestID]; existing != nil {
existing.Stop()
}
s.timers[requestID] = time.AfterFunc(delay, func() {
s.Untrack(requestID)
s.executeAndNotifyByID(context.Background(), requestID)
})
s.timersMu.Unlock()
}
func (s *Scheduler) Untrack(requestID string) {
if s == nil {
return
}
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return
}
s.timersMu.Lock()
if timer := s.timers[requestID]; timer != nil {
timer.Stop()
}
delete(s.timers, requestID)
s.timersMu.Unlock()
}
func (s *Scheduler) hydrateTimers(ctx context.Context) {
if s == nil || s.service == nil {
return
}
scheduled, err := s.service.ScheduledRequests(ctx, 1000)
if err != nil {
s.logger.Warn("Failed to hydrate scheduled treasury requests", zap.Error(err))
return
}
for _, record := range scheduled {
s.TrackScheduled(record)
}
}
func (s *Scheduler) sweep(ctx context.Context) {
if s == nil || s.service == nil {
return
}
now := time.Now()
confirmed, err := s.service.DueRequests(ctx, []storagemodel.TreasuryRequestStatus{
storagemodel.TreasuryRequestStatusConfirmed,
}, now, 100)
if err != nil {
s.logger.Warn("Failed to list confirmed treasury requests", zap.Error(err))
return
}
for _, request := range confirmed {
s.executeAndNotifyByID(ctx, strings.TrimSpace(request.RequestID))
}
scheduled, err := s.service.DueRequests(ctx, []storagemodel.TreasuryRequestStatus{
storagemodel.TreasuryRequestStatusScheduled,
}, now, 100)
if err != nil {
s.logger.Warn("Failed to list scheduled treasury requests", zap.Error(err))
return
}
for _, request := range scheduled {
s.Untrack(strings.TrimSpace(request.RequestID))
s.executeAndNotifyByID(ctx, strings.TrimSpace(request.RequestID))
}
}
func (s *Scheduler) executeAndNotifyByID(ctx context.Context, requestID string) {
if s == nil || s.service == nil {
return
}
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return
}
runCtx := ctx
if runCtx == nil {
runCtx = context.Background()
}
withTimeout, cancel := context.WithTimeout(runCtx, 30*time.Second)
defer cancel()
result, err := s.service.ExecuteRequest(withTimeout, requestID)
if err != nil {
s.logger.Warn("Failed to execute treasury request", zap.Error(err), zap.String("request_id", requestID))
return
}
if result == nil || result.Request == nil || s.notify == nil {
return
}
text := executionMessage(result)
if strings.TrimSpace(text) == "" {
return
}
if err := s.notify(ctx, strings.TrimSpace(result.Request.ChatID), text); err != nil {
s.logger.Warn("Failed to notify treasury execution result", zap.Error(err), zap.String("request_id", strings.TrimSpace(result.Request.RequestID)))
}
}
func executionMessage(result *ExecutionResult) string {
if result == nil || result.Request == nil {
return ""
}
request := result.Request
switch request.Status {
case storagemodel.TreasuryRequestStatusExecuted:
op := "Funding"
sign := "+"
if request.OperationType == storagemodel.TreasuryOperationWithdraw {
op = "Withdrawal"
sign = "-"
}
balanceAmount := "unavailable"
balanceCurrency := strings.TrimSpace(request.Currency)
if result.NewBalance != nil {
if strings.TrimSpace(result.NewBalance.Amount) != "" {
balanceAmount = strings.TrimSpace(result.NewBalance.Amount)
}
if strings.TrimSpace(result.NewBalance.Currency) != "" {
balanceCurrency = strings.TrimSpace(result.NewBalance.Currency)
}
}
return op + " completed.\n\n" +
"Account: " + strings.TrimSpace(request.LedgerAccountID) + "\n" +
"Amount: " + sign + strings.TrimSpace(request.Amount) + " " + strings.TrimSpace(request.Currency) + "\n" +
"New balance: " + balanceAmount + " " + balanceCurrency + "\n\n" +
"Reference: " + strings.TrimSpace(request.RequestID)
case storagemodel.TreasuryRequestStatusFailed:
reason := strings.TrimSpace(request.ErrorMessage)
if reason == "" && result.ExecutionError != nil {
reason = strings.TrimSpace(result.ExecutionError.Error())
}
if reason == "" {
reason = "Unknown error."
}
return "Execution failed.\n\n" +
"Account: " + strings.TrimSpace(request.LedgerAccountID) + "\n" +
"Amount: " + strings.TrimSpace(request.Amount) + " " + strings.TrimSpace(request.Currency) + "\n" +
"Status: FAILED\n\n" +
"Reason:\n" + reason + "\n\n" +
"Request ID: " + strings.TrimSpace(request.RequestID)
default:
return ""
}
}

View File

@@ -0,0 +1,411 @@
package treasury
import (
"context"
"errors"
"fmt"
"math/big"
"strings"
"time"
"github.com/tech/sendico/gateway/tgsettle/internal/service/treasury/ledger"
"github.com/tech/sendico/gateway/tgsettle/storage"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
var ErrActiveTreasuryRequest = errors.New("active treasury request exists")
type CreateRequestInput struct {
OperationType storagemodel.TreasuryOperationType
TelegramUserID string
LedgerAccountID string
ChatID string
Amount string
}
type ExecutionResult struct {
Request *storagemodel.TreasuryRequest
NewBalance *ledger.Balance
ExecutionError error
}
type Service struct {
logger mlogger.Logger
repo storage.TreasuryRequestsStore
ledger ledger.Client
validator *Validator
executionDelay time.Duration
}
func NewService(
logger mlogger.Logger,
repo storage.TreasuryRequestsStore,
ledgerClient ledger.Client,
executionDelay time.Duration,
maxPerOperation string,
maxDaily string,
) (*Service, error) {
if logger == nil {
return nil, merrors.InvalidArgument("logger is required", "logger")
}
if repo == nil {
return nil, merrors.InvalidArgument("treasury repository is required", "repo")
}
if ledgerClient == nil {
return nil, merrors.InvalidArgument("ledger client is required", "ledger_client")
}
if executionDelay <= 0 {
executionDelay = 30 * time.Second
}
validator, err := NewValidator(repo, maxPerOperation, maxDaily)
if err != nil {
return nil, err
}
return &Service{
logger: logger.Named("treasury_service"),
repo: repo,
ledger: ledgerClient,
validator: validator,
executionDelay: executionDelay,
}, nil
}
func (s *Service) ExecutionDelay() time.Duration {
if s == nil {
return 0
}
return s.executionDelay
}
func (s *Service) MaxPerOperationLimit() string {
if s == nil || s.validator == nil {
return ""
}
return s.validator.MaxPerOperation()
}
func (s *Service) GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) {
if s == nil || s.repo == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return s.repo.FindActiveByLedgerAccountID(ctx, ledgerAccountID)
}
func (s *Service) GetRequest(ctx context.Context, requestID string) (*storagemodel.TreasuryRequest, error) {
if s == nil || s.repo == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return s.repo.FindByRequestID(ctx, requestID)
}
func (s *Service) CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
if s == nil || s.repo == nil || s.ledger == nil || s.validator == nil {
return nil, merrors.Internal("treasury service unavailable")
}
input.TelegramUserID = strings.TrimSpace(input.TelegramUserID)
input.LedgerAccountID = strings.TrimSpace(input.LedgerAccountID)
input.ChatID = strings.TrimSpace(input.ChatID)
input.Amount = strings.TrimSpace(input.Amount)
switch input.OperationType {
case storagemodel.TreasuryOperationFund, storagemodel.TreasuryOperationWithdraw:
default:
return nil, merrors.InvalidArgument("treasury operation is invalid", "operation_type")
}
if input.TelegramUserID == "" {
return nil, merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id")
}
if input.LedgerAccountID == "" {
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
}
if input.ChatID == "" {
return nil, merrors.InvalidArgument("chat_id is required", "chat_id")
}
active, err := s.repo.FindActiveByLedgerAccountID(ctx, input.LedgerAccountID)
if err != nil {
return nil, err
}
if active != nil {
return active, ErrActiveTreasuryRequest
}
amountRat, normalizedAmount, err := s.validator.ValidateAmount(input.Amount)
if err != nil {
return nil, err
}
if err := s.validator.ValidateDailyLimit(ctx, input.LedgerAccountID, amountRat, time.Now()); err != nil {
return nil, err
}
account, err := s.ledger.GetAccount(ctx, input.LedgerAccountID)
if err != nil {
return nil, err
}
if account == nil || strings.TrimSpace(account.Currency) == "" {
return nil, merrors.Internal("ledger account currency is unavailable")
}
if strings.TrimSpace(account.OrganizationRef) == "" {
return nil, merrors.Internal("ledger account organization is unavailable")
}
requestID := newRequestID()
record := &storagemodel.TreasuryRequest{
RequestID: requestID,
OperationType: input.OperationType,
TelegramUserID: input.TelegramUserID,
LedgerAccountID: input.LedgerAccountID,
OrganizationRef: account.OrganizationRef,
ChatID: input.ChatID,
Amount: normalizedAmount,
Currency: strings.ToUpper(strings.TrimSpace(account.Currency)),
Status: storagemodel.TreasuryRequestStatusCreated,
IdempotencyKey: fmt.Sprintf("tgsettle:%s", requestID),
Active: true,
}
if err := s.repo.Create(ctx, record); err != nil {
if errors.Is(err, storage.ErrDuplicate) {
active, fetchErr := s.repo.FindActiveByLedgerAccountID(ctx, input.LedgerAccountID)
if fetchErr != nil {
return nil, fetchErr
}
if active != nil {
return active, ErrActiveTreasuryRequest
}
return nil, err
}
return nil, err
}
s.logRequest(record, "created", nil)
return record, nil
}
func (s *Service) ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
requestID = strings.TrimSpace(requestID)
telegramUserID = strings.TrimSpace(telegramUserID)
if requestID == "" {
return nil, merrors.InvalidArgument("request_id is required", "request_id")
}
record, err := s.repo.FindByRequestID(ctx, requestID)
if err != nil {
return nil, err
}
if record == nil {
return nil, merrors.NoData("treasury request not found")
}
if telegramUserID != "" && record.TelegramUserID != telegramUserID {
return nil, merrors.Unauthorized("treasury request ownership mismatch")
}
switch record.Status {
case storagemodel.TreasuryRequestStatusScheduled:
return record, nil
case storagemodel.TreasuryRequestStatusCreated, storagemodel.TreasuryRequestStatusConfirmed:
now := time.Now()
record.ConfirmedAt = now
record.ScheduledAt = now.Add(s.executionDelay)
record.Status = storagemodel.TreasuryRequestStatusScheduled
record.Active = true
record.ErrorMessage = ""
default:
return nil, merrors.InvalidArgument("treasury request cannot be confirmed in current status", "status")
}
if err := s.repo.Update(ctx, record); err != nil {
return nil, err
}
s.logRequest(record, "scheduled", nil)
return record, nil
}
func (s *Service) CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
requestID = strings.TrimSpace(requestID)
telegramUserID = strings.TrimSpace(telegramUserID)
if requestID == "" {
return nil, merrors.InvalidArgument("request_id is required", "request_id")
}
record, err := s.repo.FindByRequestID(ctx, requestID)
if err != nil {
return nil, err
}
if record == nil {
return nil, merrors.NoData("treasury request not found")
}
if telegramUserID != "" && record.TelegramUserID != telegramUserID {
return nil, merrors.Unauthorized("treasury request ownership mismatch")
}
switch record.Status {
case storagemodel.TreasuryRequestStatusCancelled:
return record, nil
case storagemodel.TreasuryRequestStatusCreated, storagemodel.TreasuryRequestStatusConfirmed, storagemodel.TreasuryRequestStatusScheduled:
record.Status = storagemodel.TreasuryRequestStatusCancelled
record.CancelledAt = time.Now()
record.Active = false
default:
return nil, merrors.InvalidArgument("treasury request cannot be cancelled in current status", "status")
}
if err := s.repo.Update(ctx, record); err != nil {
return nil, err
}
s.logRequest(record, "cancelled", nil)
return record, nil
}
func (s *Service) ExecuteRequest(ctx context.Context, requestID string) (*ExecutionResult, error) {
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return nil, merrors.InvalidArgument("request_id is required", "request_id")
}
record, err := s.repo.FindByRequestID(ctx, requestID)
if err != nil {
return nil, err
}
if record == nil {
return nil, nil
}
switch record.Status {
case storagemodel.TreasuryRequestStatusExecuted,
storagemodel.TreasuryRequestStatusCancelled,
storagemodel.TreasuryRequestStatusFailed:
return nil, nil
case storagemodel.TreasuryRequestStatusScheduled:
claimed, err := s.repo.ClaimScheduled(ctx, requestID)
if err != nil {
return nil, err
}
if !claimed {
return nil, nil
}
record, err = s.repo.FindByRequestID(ctx, requestID)
if err != nil {
return nil, err
}
if record == nil {
return nil, nil
}
}
if record.Status != storagemodel.TreasuryRequestStatusConfirmed {
return nil, nil
}
return s.executeClaimed(ctx, record)
}
func (s *Service) executeClaimed(ctx context.Context, record *storagemodel.TreasuryRequest) (*ExecutionResult, error) {
if record == nil {
return nil, merrors.InvalidArgument("treasury request is required", "request")
}
postReq := ledger.PostRequest{
AccountID: record.LedgerAccountID,
OrganizationRef: record.OrganizationRef,
Amount: record.Amount,
Currency: record.Currency,
Reference: record.RequestID,
IdempotencyKey: record.IdempotencyKey,
}
var (
opResult *ledger.OperationResult
err error
)
switch record.OperationType {
case storagemodel.TreasuryOperationFund:
opResult, err = s.ledger.ExternalCredit(ctx, postReq)
case storagemodel.TreasuryOperationWithdraw:
opResult, err = s.ledger.ExternalDebit(ctx, postReq)
default:
err = merrors.InvalidArgument("treasury operation is invalid", "operation_type")
}
now := time.Now()
if err != nil {
record.Status = storagemodel.TreasuryRequestStatusFailed
record.Active = false
record.ExecutedAt = now
record.ErrorMessage = strings.TrimSpace(err.Error())
if saveErr := s.repo.Update(ctx, record); saveErr != nil {
return nil, saveErr
}
s.logRequest(record, "failed", err)
return &ExecutionResult{
Request: record,
ExecutionError: err,
}, nil
}
if opResult != nil {
record.LedgerReference = strings.TrimSpace(opResult.Reference)
}
record.Status = storagemodel.TreasuryRequestStatusExecuted
record.Active = false
record.ExecutedAt = now
record.ErrorMessage = ""
balance, balanceErr := s.ledger.GetBalance(ctx, record.LedgerAccountID)
if balanceErr != nil {
record.ErrorMessage = strings.TrimSpace(balanceErr.Error())
}
if saveErr := s.repo.Update(ctx, record); saveErr != nil {
return nil, saveErr
}
s.logRequest(record, "executed", nil)
return &ExecutionResult{
Request: record,
NewBalance: balance,
ExecutionError: balanceErr,
}, nil
}
func (s *Service) DueRequests(ctx context.Context, statuses []storagemodel.TreasuryRequestStatus, now time.Time, limit int64) ([]*storagemodel.TreasuryRequest, error) {
if s == nil || s.repo == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return s.repo.FindDueByStatus(ctx, statuses, now, limit)
}
func (s *Service) ScheduledRequests(ctx context.Context, limit int64) ([]*storagemodel.TreasuryRequest, error) {
if s == nil || s.repo == nil {
return nil, merrors.Internal("treasury service unavailable")
}
return s.repo.FindDueByStatus(
ctx,
[]storagemodel.TreasuryRequestStatus{storagemodel.TreasuryRequestStatusScheduled},
time.Now().Add(10*365*24*time.Hour),
limit,
)
}
func (s *Service) ParseAmount(value string) (*big.Rat, error) {
return parseAmountRat(value)
}
func (s *Service) logRequest(record *storagemodel.TreasuryRequest, status string, err error) {
if s == nil || s.logger == nil || record == nil {
return
}
fields := []zap.Field{
zap.String("request_id", strings.TrimSpace(record.RequestID)),
zap.String("telegram_user_id", strings.TrimSpace(record.TelegramUserID)),
zap.String("ledger_account_id", strings.TrimSpace(record.LedgerAccountID)),
zap.String("operation_type", strings.TrimSpace(string(record.OperationType))),
zap.String("amount", strings.TrimSpace(record.Amount)),
zap.String("currency", strings.TrimSpace(record.Currency)),
zap.String("status", status),
}
if err != nil {
fields = append(fields, zap.Error(err))
}
s.logger.Info("treasury_request", fields...)
}
func newRequestID() string {
return "TGSETTLE-" + strings.ToUpper(bson.NewObjectID().Hex()[:8])
}

View File

@@ -0,0 +1,181 @@
package treasury
import (
"context"
"math/big"
"regexp"
"strings"
"time"
"github.com/tech/sendico/gateway/tgsettle/storage"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
var treasuryAmountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`)
type LimitKind string
const (
LimitKindPerOperation LimitKind = "per_operation"
LimitKindDaily LimitKind = "daily"
)
type LimitError struct {
Kind LimitKind
Max string
}
func (e *LimitError) Error() string {
if e == nil {
return "limit exceeded"
}
switch e.Kind {
case LimitKindPerOperation:
return "max amount per operation exceeded"
case LimitKindDaily:
return "max daily amount exceeded"
default:
return "limit exceeded"
}
}
func (e *LimitError) LimitKind() string {
if e == nil {
return ""
}
return string(e.Kind)
}
func (e *LimitError) LimitMax() string {
if e == nil {
return ""
}
return e.Max
}
type Validator struct {
repo storage.TreasuryRequestsStore
maxPerOperation *big.Rat
maxDaily *big.Rat
maxPerOperationRaw string
maxDailyRaw string
}
func NewValidator(repo storage.TreasuryRequestsStore, maxPerOperation string, maxDaily string) (*Validator, error) {
validator := &Validator{
repo: repo,
maxPerOperationRaw: strings.TrimSpace(maxPerOperation),
maxDailyRaw: strings.TrimSpace(maxDaily),
}
if validator.maxPerOperationRaw != "" {
value, err := parseAmountRat(validator.maxPerOperationRaw)
if err != nil {
return nil, merrors.InvalidArgument("treasury max_amount_per_operation is invalid", "treasury.limits.max_amount_per_operation")
}
validator.maxPerOperation = value
}
if validator.maxDailyRaw != "" {
value, err := parseAmountRat(validator.maxDailyRaw)
if err != nil {
return nil, merrors.InvalidArgument("treasury max_daily_amount is invalid", "treasury.limits.max_daily_amount")
}
validator.maxDaily = value
}
return validator, nil
}
func (v *Validator) MaxPerOperation() string {
if v == nil {
return ""
}
return v.maxPerOperationRaw
}
func (v *Validator) MaxDaily() string {
if v == nil {
return ""
}
return v.maxDailyRaw
}
func (v *Validator) ValidateAmount(amount string) (*big.Rat, string, error) {
amount = strings.TrimSpace(amount)
value, err := parseAmountRat(amount)
if err != nil {
return nil, "", err
}
if v != nil && v.maxPerOperation != nil && value.Cmp(v.maxPerOperation) > 0 {
return nil, "", &LimitError{
Kind: LimitKindPerOperation,
Max: v.maxPerOperationRaw,
}
}
return value, amount, nil
}
func (v *Validator) ValidateDailyLimit(ctx context.Context, ledgerAccountID string, amount *big.Rat, now time.Time) error {
if v == nil || v.maxDaily == nil || v.repo == nil {
return nil
}
if amount == nil {
return merrors.InvalidArgument("amount is required", "amount")
}
dayStart := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC)
dayEnd := dayStart.Add(24 * time.Hour)
records, err := v.repo.ListByAccountAndStatuses(
ctx,
ledgerAccountID,
[]storagemodel.TreasuryRequestStatus{
storagemodel.TreasuryRequestStatusCreated,
storagemodel.TreasuryRequestStatusConfirmed,
storagemodel.TreasuryRequestStatusScheduled,
storagemodel.TreasuryRequestStatusExecuted,
},
dayStart,
dayEnd,
)
if err != nil {
return err
}
total := new(big.Rat)
for _, record := range records {
if record == nil {
continue
}
next, err := parseAmountRat(record.Amount)
if err != nil {
return merrors.Internal("treasury request amount is invalid")
}
total.Add(total, next)
}
total.Add(total, amount)
if total.Cmp(v.maxDaily) > 0 {
return &LimitError{
Kind: LimitKindDaily,
Max: v.maxDailyRaw,
}
}
return nil
}
func parseAmountRat(value string) (*big.Rat, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, merrors.InvalidArgument("amount is required", "amount")
}
if !treasuryAmountPattern.MatchString(value) {
return nil, merrors.InvalidArgument("amount format is invalid", "amount")
}
amount := new(big.Rat)
if _, ok := amount.SetString(value); !ok {
return nil, merrors.InvalidArgument("amount format is invalid", "amount")
}
if amount.Sign() <= 0 {
return nil, merrors.InvalidArgument("amount must be positive", "amount")
}
return amount, nil
}

View File

@@ -4,6 +4,7 @@ const (
paymentsCollection = "payments"
telegramConfirmationsCollection = "telegram_confirmations"
pendingConfirmationsCollection = "pending_confirmations"
treasuryRequestsCollection = "treasury_requests"
)
func (*PaymentRecord) Collection() string {
@@ -17,3 +18,7 @@ func (*TelegramConfirmation) Collection() string {
func (*PendingConfirmation) Collection() string {
return pendingConfirmationsCollection
}
func (*TreasuryRequest) Collection() string {
return treasuryRequestsCollection
}

View File

@@ -0,0 +1,50 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
)
type TreasuryOperationType string
const (
TreasuryOperationFund TreasuryOperationType = "fund"
TreasuryOperationWithdraw TreasuryOperationType = "withdraw"
)
type TreasuryRequestStatus string
const (
TreasuryRequestStatusCreated TreasuryRequestStatus = "created"
TreasuryRequestStatusConfirmed TreasuryRequestStatus = "confirmed"
TreasuryRequestStatusScheduled TreasuryRequestStatus = "scheduled"
TreasuryRequestStatusExecuted TreasuryRequestStatus = "executed"
TreasuryRequestStatusCancelled TreasuryRequestStatus = "cancelled"
TreasuryRequestStatusFailed TreasuryRequestStatus = "failed"
)
type TreasuryRequest struct {
storable.Base `bson:",inline" json:",inline"`
RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"`
OperationType TreasuryOperationType `bson:"operationType,omitempty" json:"operation_type,omitempty"`
TelegramUserID string `bson:"telegramUserId,omitempty" json:"telegram_user_id,omitempty"`
LedgerAccountID string `bson:"ledgerAccountId,omitempty" json:"ledger_account_id,omitempty"`
OrganizationRef string `bson:"organizationRef,omitempty" json:"organization_ref,omitempty"`
ChatID string `bson:"chatId,omitempty" json:"chat_id,omitempty"`
Amount string `bson:"amount,omitempty" json:"amount,omitempty"`
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
Status TreasuryRequestStatus `bson:"status,omitempty" json:"status,omitempty"`
ConfirmedAt time.Time `bson:"confirmedAt,omitempty" json:"confirmed_at,omitempty"`
ScheduledAt time.Time `bson:"scheduledAt,omitempty" json:"scheduled_at,omitempty"`
ExecutedAt time.Time `bson:"executedAt,omitempty" json:"executed_at,omitempty"`
CancelledAt time.Time `bson:"cancelledAt,omitempty" json:"cancelled_at,omitempty"`
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"`
LedgerReference string `bson:"ledgerReference,omitempty" json:"ledger_reference,omitempty"`
ErrorMessage string `bson:"errorMessage,omitempty" json:"error_message,omitempty"`
Active bool `bson:"active,omitempty" json:"active,omitempty"`
}

View File

@@ -24,6 +24,7 @@ type Repository struct {
payments storage.PaymentsStore
tg storage.TelegramConfirmationsStore
pending storage.PendingConfirmationsStore
treasury storage.TreasuryRequestsStore
outbox gatewayoutbox.Store
}
@@ -74,6 +75,11 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
result.logger.Error("Failed to initialise pending confirmations store", zap.Error(err), zap.String("store", "pending_confirmations"))
return nil, err
}
treasuryStore, err := store.NewTreasuryRequests(result.logger, result.db)
if err != nil {
result.logger.Error("Failed to initialise treasury requests store", zap.Error(err), zap.String("store", "treasury_requests"))
return nil, err
}
outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db)
if err != nil {
result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox"))
@@ -82,6 +88,7 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
result.payments = paymentsStore
result.tg = tgStore
result.pending = pendingStore
result.treasury = treasuryStore
result.outbox = outboxStore
result.logger.Info("Payment gateway MongoDB storage initialised")
return result, nil
@@ -99,6 +106,10 @@ func (r *Repository) PendingConfirmations() storage.PendingConfirmationsStore {
return r.pending
}
func (r *Repository) TreasuryRequests() storage.TreasuryRequestsStore {
return r.treasury
}
func (r *Repository) Outbox() gatewayoutbox.Store {
return r.outbox
}

View File

@@ -20,6 +20,7 @@ import (
const (
paymentsCollection = "payments"
fieldIdempotencyKey = "idempotencyKey"
fieldOperationRef = "operationRef"
)
type Payments struct {
@@ -44,6 +45,14 @@ func NewPayments(logger mlogger.Logger, db *mongo.Database) (*Payments, error) {
logger.Error("Failed to create payments idempotency index", zap.Error(err), zap.String("index_field", fieldIdempotencyKey))
return nil, err
}
if err := repo.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: fieldOperationRef, Sort: ri.Asc}},
Unique: true,
Sparse: true,
}); err != nil {
logger.Error("Failed to create payments operation index", zap.Error(err), zap.String("index_field", fieldOperationRef))
return nil, err
}
p := &Payments{
logger: logger,
@@ -72,6 +81,25 @@ func (p *Payments) FindByIdempotencyKey(ctx context.Context, key string) (*model
return &result, nil
}
func (p *Payments) FindByOperationRef(ctx context.Context, key string) (*model.PaymentRecord, error) {
key = strings.TrimSpace(key)
if key == "" {
return nil, merrors.InvalidArgument("operation reference is required", "operation_ref")
}
var result model.PaymentRecord
err := p.repo.FindOneByFilter(ctx, repository.Filter(fieldOperationRef, key), &result)
if errors.Is(err, merrors.ErrNoData) {
return nil, nil
}
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
p.logger.Warn("Payment record lookup by operation ref failed", zap.String("operation_ref", key), zap.Error(err))
}
return nil, err
}
return &result, nil
}
func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) error {
if record == nil {
return merrors.InvalidArgument("payment record is nil", "record")
@@ -82,6 +110,7 @@ func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) erro
record.OutgoingLeg = strings.TrimSpace(record.OutgoingLeg)
record.TargetChatID = strings.TrimSpace(record.TargetChatID)
record.IntentRef = strings.TrimSpace(record.IntentRef)
record.OperationRef = strings.TrimSpace(record.OperationRef)
if record.PaymentIntentID == "" {
return merrors.InvalidArgument("intention reference is required", "payment_intent_ref")
}

View File

@@ -0,0 +1,311 @@
package store
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/gateway/tgsettle/storage"
"github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap"
)
const (
treasuryRequestsCollection = "treasury_requests"
fieldTreasuryRequestID = "requestId"
fieldTreasuryLedgerAccount = "ledgerAccountId"
fieldTreasuryIdempotencyKey = "idempotencyKey"
fieldTreasuryStatus = "status"
fieldTreasuryScheduledAt = "scheduledAt"
fieldTreasuryCreatedAt = "createdAt"
fieldTreasuryActive = "active"
)
type TreasuryRequests struct {
logger mlogger.Logger
repo repository.Repository
}
func NewTreasuryRequests(logger mlogger.Logger, db *mongo.Database) (*TreasuryRequests, error) {
if db == nil {
return nil, merrors.InvalidArgument("mongo database is nil")
}
if logger == nil {
logger = zap.NewNop()
}
logger = logger.Named("treasury_requests").With(zap.String("collection", treasuryRequestsCollection))
repo := repository.CreateMongoRepository(db, treasuryRequestsCollection)
if err := repo.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: fieldTreasuryRequestID, Sort: ri.Asc}},
Unique: true,
}); err != nil {
logger.Error("Failed to create treasury requests request_id index", zap.Error(err), zap.String("index_field", fieldTreasuryRequestID))
return nil, err
}
if err := repo.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: fieldTreasuryIdempotencyKey, Sort: ri.Asc}},
Unique: true,
}); err != nil {
logger.Error("Failed to create treasury requests idempotency index", zap.Error(err), zap.String("index_field", fieldTreasuryIdempotencyKey))
return nil, err
}
if err := repo.CreateIndex(&ri.Definition{
Keys: []ri.Key{
{Field: fieldTreasuryLedgerAccount, Sort: ri.Asc},
{Field: fieldTreasuryActive, Sort: ri.Asc},
},
Unique: true,
PartialFilter: repository.Filter(fieldTreasuryActive, true),
}); err != nil {
logger.Error("Failed to create treasury requests active-account index", zap.Error(err))
return nil, err
}
if err := repo.CreateIndex(&ri.Definition{
Keys: []ri.Key{
{Field: fieldTreasuryStatus, Sort: ri.Asc},
{Field: fieldTreasuryScheduledAt, Sort: ri.Asc},
},
}); err != nil {
logger.Error("Failed to create treasury requests execution index", zap.Error(err))
return nil, err
}
if err := repo.CreateIndex(&ri.Definition{
Keys: []ri.Key{
{Field: fieldTreasuryLedgerAccount, Sort: ri.Asc},
{Field: fieldTreasuryCreatedAt, Sort: ri.Asc},
},
}); err != nil {
logger.Error("Failed to create treasury requests daily-amount index", zap.Error(err))
return nil, err
}
t := &TreasuryRequests{
logger: logger,
repo: repo,
}
t.logger.Debug("Treasury requests store initialised")
return t, nil
}
func (t *TreasuryRequests) Create(ctx context.Context, record *model.TreasuryRequest) error {
if record == nil {
return merrors.InvalidArgument("treasury request is nil", "record")
}
record.RequestID = strings.TrimSpace(record.RequestID)
record.TelegramUserID = strings.TrimSpace(record.TelegramUserID)
record.LedgerAccountID = strings.TrimSpace(record.LedgerAccountID)
record.OrganizationRef = strings.TrimSpace(record.OrganizationRef)
record.ChatID = strings.TrimSpace(record.ChatID)
record.Amount = strings.TrimSpace(record.Amount)
record.Currency = strings.ToUpper(strings.TrimSpace(record.Currency))
record.IdempotencyKey = strings.TrimSpace(record.IdempotencyKey)
record.LedgerReference = strings.TrimSpace(record.LedgerReference)
record.ErrorMessage = strings.TrimSpace(record.ErrorMessage)
if record.RequestID == "" {
return merrors.InvalidArgument("request_id is required", "request_id")
}
if record.TelegramUserID == "" {
return merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id")
}
if record.LedgerAccountID == "" {
return merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
}
if record.Amount == "" {
return merrors.InvalidArgument("amount is required", "amount")
}
if record.Currency == "" {
return merrors.InvalidArgument("currency is required", "currency")
}
if record.IdempotencyKey == "" {
return merrors.InvalidArgument("idempotency_key is required", "idempotency_key")
}
if record.Status == "" {
return merrors.InvalidArgument("status is required", "status")
}
now := time.Now()
if record.CreatedAt.IsZero() {
record.CreatedAt = now
}
record.UpdatedAt = now
record.ID = bson.NilObjectID
err := t.repo.Insert(ctx, record, repository.Filter(fieldTreasuryRequestID, record.RequestID))
if errors.Is(err, merrors.ErrDataConflict) {
return storage.ErrDuplicate
}
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
t.logger.Warn("Failed to create treasury request", zap.Error(err), zap.String("request_id", record.RequestID))
}
return err
}
func (t *TreasuryRequests) FindByRequestID(ctx context.Context, requestID string) (*model.TreasuryRequest, error) {
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return nil, merrors.InvalidArgument("request_id is required", "request_id")
}
var result model.TreasuryRequest
err := t.repo.FindOneByFilter(ctx, repository.Filter(fieldTreasuryRequestID, requestID), &result)
if errors.Is(err, merrors.ErrNoData) {
return nil, nil
}
if err != nil {
return nil, err
}
return &result, nil
}
func (t *TreasuryRequests) FindActiveByLedgerAccountID(ctx context.Context, ledgerAccountID string) (*model.TreasuryRequest, error) {
ledgerAccountID = strings.TrimSpace(ledgerAccountID)
if ledgerAccountID == "" {
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
}
var result model.TreasuryRequest
query := repository.Query().
Filter(repository.Field(fieldTreasuryLedgerAccount), ledgerAccountID).
Filter(repository.Field(fieldTreasuryActive), true)
err := t.repo.FindOneByFilter(ctx, query, &result)
if errors.Is(err, merrors.ErrNoData) {
return nil, nil
}
if err != nil {
return nil, err
}
return &result, nil
}
func (t *TreasuryRequests) FindDueByStatus(ctx context.Context, statuses []model.TreasuryRequestStatus, now time.Time, limit int64) ([]*model.TreasuryRequest, error) {
if len(statuses) == 0 {
return nil, nil
}
if limit <= 0 {
limit = 100
}
statusValues := make([]any, 0, len(statuses))
for _, status := range statuses {
next := strings.TrimSpace(string(status))
if next == "" {
continue
}
statusValues = append(statusValues, next)
}
if len(statusValues) == 0 {
return nil, nil
}
query := repository.Query().
In(repository.Field(fieldTreasuryStatus), statusValues...).
Comparison(repository.Field(fieldTreasuryScheduledAt), builder.Lte, now).
Sort(repository.Field(fieldTreasuryScheduledAt), true).
Limit(&limit)
result := make([]*model.TreasuryRequest, 0)
err := t.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
next := &model.TreasuryRequest{}
if err := cur.Decode(next); err != nil {
return err
}
result = append(result, next)
return nil
})
if err != nil && !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
return result, nil
}
func (t *TreasuryRequests) ClaimScheduled(ctx context.Context, requestID string) (bool, error) {
requestID = strings.TrimSpace(requestID)
if requestID == "" {
return false, merrors.InvalidArgument("request_id is required", "request_id")
}
patch := repository.Patch().
Set(repository.Field(fieldTreasuryStatus), string(model.TreasuryRequestStatusConfirmed)).
Set(repository.Field("updatedAt"), time.Now())
updated, err := t.repo.PatchMany(ctx, repository.Filter(fieldTreasuryRequestID, requestID).And(
repository.Filter(fieldTreasuryStatus, string(model.TreasuryRequestStatusScheduled)),
), patch)
if err != nil {
return false, err
}
return updated > 0, nil
}
func (t *TreasuryRequests) Update(ctx context.Context, record *model.TreasuryRequest) error {
if record == nil {
return merrors.InvalidArgument("treasury request is nil", "record")
}
record.RequestID = strings.TrimSpace(record.RequestID)
if record.RequestID == "" {
return merrors.InvalidArgument("request_id is required", "request_id")
}
existing, err := t.FindByRequestID(ctx, record.RequestID)
if err != nil {
return err
}
if existing == nil {
return merrors.NoData("treasury request not found")
}
record.ID = existing.ID
if record.CreatedAt.IsZero() {
record.CreatedAt = existing.CreatedAt
}
record.UpdatedAt = time.Now()
if err := t.repo.Update(ctx, record); err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
t.logger.Warn("Failed to update treasury request", zap.Error(err), zap.String("request_id", record.RequestID))
}
return err
}
return nil
}
func (t *TreasuryRequests) ListByAccountAndStatuses(ctx context.Context, ledgerAccountID string, statuses []model.TreasuryRequestStatus, dayStart, dayEnd time.Time) ([]*model.TreasuryRequest, error) {
ledgerAccountID = strings.TrimSpace(ledgerAccountID)
if ledgerAccountID == "" {
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
}
statusValues := make([]any, 0, len(statuses))
for _, status := range statuses {
next := strings.TrimSpace(string(status))
if next == "" {
continue
}
statusValues = append(statusValues, next)
}
if len(statusValues) == 0 {
return nil, nil
}
query := repository.Query().
Filter(repository.Field(fieldTreasuryLedgerAccount), ledgerAccountID).
In(repository.Field(fieldTreasuryStatus), statusValues...).
Comparison(repository.Field(fieldTreasuryCreatedAt), builder.Gte, dayStart).
Comparison(repository.Field(fieldTreasuryCreatedAt), builder.Lt, dayEnd)
result := make([]*model.TreasuryRequest, 0)
err := t.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
next := &model.TreasuryRequest{}
if err := cur.Decode(next); err != nil {
return err
}
result = append(result, next)
return nil
})
if err != nil && !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
return result, nil
}
var _ storage.TreasuryRequestsStore = (*TreasuryRequests)(nil)

View File

@@ -14,10 +14,12 @@ type Repository interface {
Payments() PaymentsStore
TelegramConfirmations() TelegramConfirmationsStore
PendingConfirmations() PendingConfirmationsStore
TreasuryRequests() TreasuryRequestsStore
}
type PaymentsStore interface {
FindByIdempotencyKey(ctx context.Context, key string) (*model.PaymentRecord, error)
FindByOperationRef(ctx context.Context, key string) (*model.PaymentRecord, error)
Upsert(ctx context.Context, record *model.PaymentRecord) error
}
@@ -34,3 +36,13 @@ type PendingConfirmationsStore interface {
DeleteByRequestID(ctx context.Context, requestID string) error
ListExpired(ctx context.Context, now time.Time, limit int64) ([]*model.PendingConfirmation, error)
}
type TreasuryRequestsStore interface {
Create(ctx context.Context, record *model.TreasuryRequest) error
FindByRequestID(ctx context.Context, requestID string) (*model.TreasuryRequest, error)
FindActiveByLedgerAccountID(ctx context.Context, ledgerAccountID string) (*model.TreasuryRequest, error)
FindDueByStatus(ctx context.Context, statuses []model.TreasuryRequestStatus, now time.Time, limit int64) ([]*model.TreasuryRequest, error)
ClaimScheduled(ctx context.Context, requestID string) (bool, error)
Update(ctx context.Context, record *model.TreasuryRequest) error
ListByAccountAndStatuses(ctx context.Context, ledgerAccountID string, statuses []model.TreasuryRequestStatus, dayStart, dayEnd time.Time) ([]*model.TreasuryRequest, error)
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/gateway/tron/internal/appversion"
"github.com/tech/sendico/gateway/tron/shared"
tronstoragemodel "github.com/tech/sendico/gateway/tron/storage/model"
chainasset "github.com/tech/sendico/pkg/chain"
"github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/merrors"
@@ -17,6 +18,7 @@ import (
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
)
const chainConnectorID = "chain"
@@ -293,11 +295,21 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
}
resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())})
operationRef := strings.TrimSpace(req.GetOperationId())
if s.storage == nil || s.storage.Transfers() == nil {
return nil, merrors.Internal("get_operation: storage is not configured")
}
transfer, err := s.storage.Transfers().FindByOperationRef(ctx, "", operationRef)
if err != nil {
return nil, err
}
return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(resp.GetTransfer())}, nil
if transfer == nil {
return nil, merrors.NoData("transfer not found")
}
return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(storageTransferToProto(transfer))}, nil
}
func (s *Service) ListOperations(ctx context.Context, req *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
@@ -493,6 +505,61 @@ func feeEstimateResult(resp *chainv1.EstimateTransferFeeResponse) *structpb.Stru
return result
}
func storageTransferToProto(transfer *tronstoragemodel.Transfer) *chainv1.Transfer {
if transfer == nil {
return nil
}
destination := &chainv1.TransferDestination{Memo: strings.TrimSpace(transfer.Destination.Memo)}
if managedWalletRef := strings.TrimSpace(transfer.Destination.ManagedWalletRef); managedWalletRef != "" {
destination.Destination = &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: managedWalletRef}
} else if externalAddress := strings.TrimSpace(transfer.Destination.ExternalAddress); externalAddress != "" {
destination.Destination = &chainv1.TransferDestination_ExternalAddress{ExternalAddress: externalAddress}
}
fees := make([]*chainv1.ServiceFeeBreakdown, 0, len(transfer.Fees))
for _, fee := range transfer.Fees {
fees = append(fees, &chainv1.ServiceFeeBreakdown{
FeeCode: strings.TrimSpace(fee.FeeCode),
Amount: fee.Amount,
Description: strings.TrimSpace(fee.Description),
})
}
asset := &chainv1.Asset{
Chain: shared.ChainEnumFromName(transfer.Network),
TokenSymbol: strings.TrimSpace(transfer.TokenSymbol),
ContractAddress: strings.TrimSpace(transfer.ContractAddress),
}
protoTransfer := &chainv1.Transfer{
TransferRef: strings.TrimSpace(transfer.TransferRef),
IdempotencyKey: strings.TrimSpace(transfer.IdempotencyKey),
IntentRef: strings.TrimSpace(transfer.IntentRef),
OperationRef: strings.TrimSpace(transfer.OperationRef),
OrganizationRef: strings.TrimSpace(transfer.OrganizationRef),
SourceWalletRef: strings.TrimSpace(transfer.SourceWalletRef),
Destination: destination,
Asset: asset,
RequestedAmount: shared.MonenyToProto(transfer.RequestedAmount),
NetAmount: shared.MonenyToProto(transfer.NetAmount),
Fees: fees,
Status: shared.TransferStatusToProto(transfer.Status),
TransactionHash: strings.TrimSpace(transfer.TxHash),
FailureReason: strings.TrimSpace(transfer.FailureReason),
PaymentRef: strings.TrimSpace(transfer.PaymentRef),
}
if !transfer.CreatedAt.IsZero() {
protoTransfer.CreatedAt = timestamppb.New(transfer.CreatedAt.UTC())
}
if !transfer.UpdatedAt.IsZero() {
protoTransfer.UpdatedAt = timestamppb.New(transfer.UpdatedAt.UTC())
}
return protoTransfer
}
func gasTopUpResult(amount *moneyv1.Money, capHit bool, transferRef string) *structpb.Struct {
payload := map[string]interface{}{
"cap_hit": capHit,
@@ -518,18 +585,33 @@ func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation
return nil
}
op := &connectorv1.Operation{
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Type: connectorv1.OperationType_TRANSFER,
Status: chainTransferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(),
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(),
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Type: connectorv1.OperationType_TRANSFER,
Status: chainTransferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(),
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
IntentRef: strings.TrimSpace(transfer.GetIntentRef()),
OperationRef: strings.TrimSpace(transfer.GetOperationRef()),
CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chainConnectorID,
AccountId: strings.TrimSpace(transfer.GetSourceWalletRef()),
}}},
}
params := map[string]interface{}{}
if paymentRef := strings.TrimSpace(transfer.GetPaymentRef()); paymentRef != "" {
params["payment_ref"] = paymentRef
}
if organizationRef := strings.TrimSpace(transfer.GetOrganizationRef()); organizationRef != "" {
params["organization_ref"] = organizationRef
}
if failureReason := strings.TrimSpace(transfer.GetFailureReason()); failureReason != "" {
params["failure_reason"] = failureReason
}
if len(params) > 0 {
op.Params = structFromMap(params)
}
if dest := transfer.GetDestination(); dest != nil {
switch d := dest.GetDestination().(type) {
case *chainv1.TransferDestination_ManagedWalletRef:
@@ -629,6 +711,17 @@ func operationAccountID(party *connectorv1.OperationParty) string {
return ""
}
func structFromMap(values map[string]interface{}) *structpb.Struct {
if len(values) == 0 {
return nil
}
result, err := structpb.NewStruct(values)
if err != nil {
return nil
}
return result
}
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
err := &connectorv1.ConnectorError{
Code: code,

View File

@@ -554,6 +554,32 @@ func (t *inMemoryTransfers) Get(ctx context.Context, transferRef string) (*model
return transfer, nil
}
func (t *inMemoryTransfers) FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) {
t.mu.Lock()
defer t.mu.Unlock()
org := strings.TrimSpace(organizationRef)
opRef := strings.TrimSpace(operationRef)
if opRef == "" {
return nil, merrors.InvalidArgument("transfersStore: empty operationRef")
}
for _, transfer := range t.items {
if transfer == nil {
continue
}
if !strings.EqualFold(strings.TrimSpace(transfer.OperationRef), opRef) {
continue
}
if org != "" && !strings.EqualFold(strings.TrimSpace(transfer.OrganizationRef), org) {
continue
}
return transfer, nil
}
return nil, merrors.NoData("transfer not found")
}
func (t *inMemoryTransfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
t.mu.Lock()
defer t.mu.Unlock()

View File

@@ -40,6 +40,9 @@ func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error)
Keys: []ri.Key{{Field: "transferRef", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}, {Field: "operationRef", Sort: ri.Asc}},
},
{
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}},
Unique: true,
@@ -110,6 +113,25 @@ func (t *Transfers) Get(ctx context.Context, transferRef string) (*model.Transfe
return transfer, nil
}
func (t *Transfers) FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) {
operationRef = strings.TrimSpace(operationRef)
if operationRef == "" {
return nil, merrors.InvalidArgument("transfersStore: empty operationRef")
}
query := repository.Query().Filter(repository.Field("operationRef"), operationRef)
if org := strings.TrimSpace(organizationRef); org != "" {
query = query.Filter(repository.Field("organizationRef"), org)
}
transfer := &model.Transfer{}
if err := t.repo.FindOneByFilter(ctx, query, transfer); err != nil {
return nil, err
}
return transfer, nil
}
func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
query := repository.Query()
if src := strings.TrimSpace(filter.SourceWalletRef); src != "" {

View File

@@ -42,6 +42,7 @@ type WalletsStore interface {
type TransfersStore interface {
Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error)
Get(ctx context.Context, transferRef string) (*model.Transfer, error)
FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error)
List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error)
UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error)
}

View File

@@ -29,6 +29,8 @@ type createAccountParams struct {
modelRole account_role.AccountRole
}
const defaultLedgerAccountName = "Ledger account"
// validateCreateAccountInput validates and normalizes all fields from the request.
func validateCreateAccountInput(req *ledgerv1.CreateAccountRequest) (createAccountParams, error) {
if req == nil {
@@ -88,7 +90,17 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
return nil, err
}
// Topology roles resolve to existing system accounts.
// Operating accounts are user-facing and can coexist with topology accounts.
// Ensure topology exists first, then create a dedicated account.
if p.modelRole == account_role.AccountRoleOperating {
if err := s.ensureLedgerTopology(ctx, p.orgRef, p.currency); err != nil {
recordAccountOperation("create", "error")
return nil, err
}
return s.persistNewAccount(ctx, p, req)
}
// Other topology roles resolve to existing system accounts.
if isRequiredTopologyRole(p.modelRole) {
return s.resolveTopologyAccount(ctx, p.orgRef, p.currency, p.modelRole)
}
@@ -139,7 +151,7 @@ func (s *Service) persistNewAccount(ctx context.Context, p createAccountParams,
if len(metadata) == 0 {
metadata = nil
}
describable := describableFromProto(req.GetDescribable())
describable := ensureDefaultLedgerAccountName(describableFromProto(req.GetDescribable()))
const maxCreateAttempts = 3
for attempt := 0; attempt < maxCreateAttempts; attempt++ {
@@ -157,15 +169,8 @@ func (s *Service) persistNewAccount(ctx context.Context, p createAccountParams,
return &ledgerv1.CreateAccountResponse{Account: toProtoAccount(account)}, nil
}
if errors.Is(err, merrors.ErrDataConflict) {
existing, lookupErr := s.storage.Accounts().GetByRole(ctx, p.orgRef, p.currency, p.modelRole)
if lookupErr == nil && existing != nil {
recordAccountOperation("create", "success")
return &ledgerv1.CreateAccountResponse{Account: toProtoAccount(existing)}, nil
}
if attempt < maxCreateAttempts-1 {
continue
}
if errors.Is(err, merrors.ErrDataConflict) && attempt < maxCreateAttempts-1 {
continue
}
recordAccountOperation("create", "error")
@@ -396,6 +401,18 @@ func describableFromProto(desc *describablev1.Describable) *pmodel.Describable {
}
}
func ensureDefaultLedgerAccountName(desc *pmodel.Describable) *pmodel.Describable {
if desc == nil {
return &pmodel.Describable{Name: defaultLedgerAccountName}
}
if strings.TrimSpace(desc.Name) != "" {
return desc
}
copy := *desc
copy.Name = defaultLedgerAccountName
return &copy
}
func describableToProto(desc pmodel.Describable) *describablev1.Describable {
name := strings.TrimSpace(desc.Name)
var description *string

View File

@@ -13,6 +13,7 @@ import (
"github.com/tech/sendico/pkg/merrors"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/model/account_role"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
)
@@ -184,12 +185,15 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
// default role
require.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, resp.Account.Role)
require.Equal(t, "USD", resp.Account.Currency)
require.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, resp.Account.AccountType)
require.Equal(t, defaultLedgerAccountName, resp.Account.GetDescribable().GetName())
// Expect: required roles + settlement
require.Len(t, accountStore.created, 5)
// Expect: required topology roles + dedicated operating account
require.Len(t, accountStore.created, 6)
var settlement *pmodel.LedgerAccount
var operating *pmodel.LedgerAccount
var operatingCount int
roles := make(map[account_role.AccountRole]bool)
for _, acc := range accountStore.created {
@@ -199,6 +203,7 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
settlement = acc
}
if acc.Role == account_role.AccountRoleOperating {
operatingCount++
operating = acc
}
@@ -212,12 +217,13 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
require.NotNil(t, settlement)
require.NotNil(t, operating)
require.Equal(t, 2, operatingCount)
for _, role := range RequiredRolesV1 {
require.True(t, roles[role])
}
// Responder must return the operating account it created/resolved.
// Responder returns the dedicated operating account created for this request.
require.Equal(t, operating.AccountCode, resp.Account.AccountCode)
require.Equal(t, operating.GetID().Hex(), resp.Account.LedgerAccountRef)
@@ -235,6 +241,38 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
require.Equal(t, "true", settlement.Metadata["system"])
}
func TestCreateAccountResponder_OperatingPreservesProvidedNameAndType(t *testing.T) {
t.Parallel()
orgRef := bson.NewObjectID()
accountStore := &accountStoreStub{}
svc := &Service{
logger: zap.NewNop(),
storage: &repositoryStub{accounts: accountStore},
}
req := &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(),
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE,
Currency: "usd",
Role: ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING,
Describable: &describablev1.Describable{
Name: "Incoming revenue",
},
}
resp, err := svc.createAccountResponder(context.Background(), req)(context.Background())
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, resp.Account)
require.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE, resp.Account.AccountType)
require.Equal(t, "Incoming revenue", resp.Account.GetDescribable().GetName())
// Topology accounts + dedicated operating account.
require.Len(t, accountStore.created, 6)
}
func TestCreateAccountResponder_RetriesOnConflict(t *testing.T) {
t.Parallel()

View File

@@ -70,9 +70,15 @@ func (s *Service) ensureRoleAccount(ctx context.Context, orgRef bson.ObjectID, c
account, err := s.storage.Accounts().GetByRole(ctx, orgRef, normalizedCurrency, role)
if err == nil {
return account, nil
if isSystemTaggedAccount(account) {
return account, nil
}
s.logger.Info("Found non-system account for topology role; creating missing system account",
mzap.ObjRef("organization_ref", orgRef),
zap.String("currency", normalizedCurrency),
zap.String("role", string(role)))
}
if !errors.Is(err, storage.ErrAccountNotFound) {
if err != nil && !errors.Is(err, storage.ErrAccountNotFound) {
s.logger.Warn("Failed to resolve ledger account by role", zap.Error(err),
mzap.ObjRef("organization_ref", orgRef), zap.String("currency", normalizedCurrency),
zap.String("role", string(role)))
@@ -105,6 +111,13 @@ func (s *Service) ensureRoleAccount(ctx context.Context, orgRef bson.ObjectID, c
return account, nil
}
func isSystemTaggedAccount(account *pmodel.LedgerAccount) bool {
if account == nil || account.Metadata == nil {
return false
}
return strings.EqualFold(strings.TrimSpace(account.Metadata["system"]), "true")
}
func newSystemAccount(orgRef bson.ObjectID, currency string, role account_role.AccountRole) *pmodel.LedgerAccount {
ref := bson.NewObjectID()
account := &pmodel.LedgerAccount{

View File

@@ -3,6 +3,7 @@ package store
import (
"context"
"errors"
"fmt"
"strings"
"github.com/tech/sendico/ledger/storage"
@@ -24,6 +25,28 @@ type accountsStore struct {
repo repository.Repository
}
const (
orgCurrencyRoleNonOperatingPrefix = "org_currency_role_non_operating_unique"
orgCurrencyRoleSystemOperatingName = "org_currency_role_system_operating_unique"
)
var nonOperatingUniqueRoles = []account_role.AccountRole{
account_role.AccountRoleHold,
account_role.AccountRoleTransit,
account_role.AccountRoleSettlement,
account_role.AccountRoleClearing,
account_role.AccountRolePending,
account_role.AccountRoleReserve,
account_role.AccountRoleLiquidity,
account_role.AccountRoleFee,
account_role.AccountRoleChargeback,
account_role.AccountRoleAdjustment,
}
func nonOperatingRoleIndexName(role account_role.AccountRole) string {
return fmt.Sprintf("%s_%s", orgCurrencyRoleNonOperatingPrefix, role)
}
func NewAccounts(logger mlogger.Logger, db *mongo.Database) (storage.AccountsStore, error) {
repo := repository.CreateMongoRepository(db, mservice.LedgerAccounts)
@@ -41,21 +64,45 @@ func NewAccounts(logger mlogger.Logger, db *mongo.Database) (storage.AccountsSto
return nil, err
}
// Create compound index on organizationRef + currency + role (unique)
roleIndex := &ri.Definition{
// Keep role uniqueness for non-operating organization accounts.
// Some Mongo-compatible backends reject partial filters that use negation ($ne/$not).
// Build one equality-based partial index per non-operating role for compatibility.
for _, role := range nonOperatingUniqueRoles {
roleIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "organizationRef", Sort: ri.Asc},
{Field: "currency", Sort: ri.Asc},
{Field: "role", Sort: ri.Asc},
},
Unique: true,
Name: nonOperatingRoleIndexName(role),
PartialFilter: repository.Query().
Filter(repository.Field("scope"), pkm.LedgerAccountScopeOrganization).
Filter(repository.Field("role"), role),
}
if err := repo.CreateIndex(roleIndex); err != nil {
logger.Error("Failed to ensure accounts role index", zap.String("role", string(role)), zap.Error(err))
return nil, err
}
}
// Ensure only one system-tagged operating role per organization/currency.
systemOperatingRoleIndex := &ri.Definition{
Keys: []ri.Key{
{Field: "organizationRef", Sort: ri.Asc},
{Field: "currency", Sort: ri.Asc},
{Field: "role", Sort: ri.Asc},
{Field: "metadata.system", Sort: ri.Asc},
},
Unique: true,
PartialFilter: repository.Filter(
"scope",
pkm.LedgerAccountScopeOrganization,
),
Name: orgCurrencyRoleSystemOperatingName,
PartialFilter: repository.Query().
Filter(repository.Field("scope"), pkm.LedgerAccountScopeOrganization).
Filter(repository.Field("role"), account_role.AccountRoleOperating).
Filter(repository.Field("metadata.system"), "true"),
}
if err := repo.CreateIndex(roleIndex); err != nil {
logger.Error("Failed to ensure accounts role index", zap.Error(err))
if err := repo.CreateIndex(systemOperatingRoleIndex); err != nil {
logger.Error("Failed to ensure system operating role index", zap.Error(err))
return nil, err
}
@@ -182,14 +229,34 @@ func (a *accountsStore) GetByRole(ctx context.Context, orgRef bson.ObjectID, cur
return nil, merrors.InvalidArgument("accountsStore: empty role")
}
result := &pkm.LedgerAccount{}
limit := int64(1)
// Prefer topology/system-tagged account when present.
systemQuery := repository.Query().
Filter(repository.Field("organizationRef"), orgRef).
Filter(repository.Field("currency"), currency).
Filter(repository.Field("role"), role).
Filter(repository.Field("scope"), pkm.LedgerAccountScopeOrganization).
Filter(repository.Field("metadata.system"), "true").
Limit(&limit)
if err := a.repo.FindOneByFilter(ctx, systemQuery, result); err == nil {
a.logger.Debug("System account loaded by role", mzap.ObjRef("accountRef", *result.GetID()),
zap.String("currency", currency), zap.String("role", string(role)))
return result, nil
} else if !errors.Is(err, merrors.ErrNoData) {
a.logger.Warn("Failed to get account by role", zap.Error(err), mzap.ObjRef("organization_ref", orgRef),
zap.String("currency", currency), zap.String("role", string(role)))
return nil, err
}
// Fallback to any organization account with the role.
query := repository.Query().
Filter(repository.Field("organizationRef"), orgRef).
Filter(repository.Field("currency"), currency).
Filter(repository.Field("role"), role).
Filter(repository.Field("scope"), pkm.LedgerAccountScopeOrganization).
Limit(&limit)
result := &pkm.LedgerAccount{}
if err := a.repo.FindOneByFilter(ctx, query, result); err != nil {
if errors.Is(err, merrors.ErrNoData) {
a.logger.Debug("Account not found by role", zap.String("currency", currency),

View File

@@ -2,6 +2,9 @@ package orchestrator
import (
"context"
"fmt"
"github.com/shopspring/decimal"
chainclient "github.com/tech/sendico/gateway/chain/client"
"github.com/tech/sendico/pkg/discovery"
"strings"
@@ -48,7 +51,7 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
if err != nil {
return nil, err
}
amount, err := sourceAmount(req.Payment)
amount, err := sourceAmount(req.Payment, action)
if err != nil {
return nil, err
}
@@ -90,6 +93,12 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
return nil, refsErr
}
step.ExternalRefs = refs
if action == discovery.RailOperationSend {
if err := e.submitWalletFeeTransfer(ctx, req, client, gateway, sourceWalletRef, operationRef, idempotencyKey); err != nil {
return nil, err
}
}
step.State = agg.StepStateCompleted
step.FailureCode = ""
step.FailureMsg = ""
@@ -161,11 +170,24 @@ func sourceManagedWalletRef(payment *agg.Payment) (string, error) {
return ref, nil
}
func sourceAmount(payment *agg.Payment) (*moneyv1.Money, error) {
func sourceAmount(payment *agg.Payment, action model.RailOperation) (*moneyv1.Money, error) {
if payment == nil {
return nil, merrors.InvalidArgument("crypto send: payment is required")
}
money := effectiveSourceAmount(payment)
var money *paymenttypes.Money
switch action {
case discovery.RailOperationFee:
resolved, ok, err := walletFeeAmount(payment)
if err != nil {
return nil, err
}
if !ok {
return nil, merrors.InvalidArgument("crypto send: wallet fee amount is required")
}
money = resolved
default:
money = effectiveSourceAmount(payment)
}
if money == nil {
return nil, merrors.InvalidArgument("crypto send: source amount is required")
}
@@ -180,6 +202,64 @@ func sourceAmount(payment *agg.Payment) (*moneyv1.Money, error) {
}, nil
}
func (e *gatewayCryptoExecutor) submitWalletFeeTransfer(
ctx context.Context,
req sexec.StepRequest,
client chainclient.Client,
gateway *model.GatewayInstanceDescriptor,
sourceWalletRef string,
operationRef string,
idempotencyKey string,
) error {
if req.Payment == nil {
return merrors.InvalidArgument("crypto send: payment is required")
}
feeAmount, ok, err := walletFeeAmount(req.Payment)
if err != nil {
return err
}
if !ok {
return nil
}
destination, err := e.resolveDestination(req.Payment, discovery.RailOperationFee)
if err != nil {
return err
}
feeMoney := &moneyv1.Money{
Amount: strings.TrimSpace(feeAmount.GetAmount()),
Currency: strings.TrimSpace(feeAmount.GetCurrency()),
}
resp, err := client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
IdempotencyKey: strings.TrimSpace(idempotencyKey) + ":fee",
OrganizationRef: req.Payment.OrganizationRef.Hex(),
SourceWalletRef: sourceWalletRef,
Destination: destination,
Amount: feeMoney,
OperationRef: strings.TrimSpace(operationRef) + ":fee",
IntentRef: strings.TrimSpace(req.Payment.IntentSnapshot.Ref),
PaymentRef: strings.TrimSpace(req.Payment.PaymentRef),
Metadata: transferMetadata(req.Step),
})
if err != nil {
return err
}
if resp == nil || resp.GetTransfer() == nil {
return merrors.Internal("crypto send: fee transfer response is missing")
}
if _, err := transferExternalRefs(resp.GetTransfer(), firstNonEmpty(
strings.TrimSpace(req.Step.InstanceID),
strings.TrimSpace(gateway.InstanceID),
strings.TrimSpace(req.Step.Gateway),
strings.TrimSpace(gateway.ID),
)); err != nil {
return err
}
return nil
}
func effectiveSourceAmount(payment *agg.Payment) *paymenttypes.Money {
if payment == nil {
return nil
@@ -190,6 +270,77 @@ func effectiveSourceAmount(payment *agg.Payment) *paymenttypes.Money {
return payment.IntentSnapshot.Amount
}
func walletFeeAmount(payment *agg.Payment) (*paymenttypes.Money, bool, error) {
if payment == nil || payment.QuoteSnapshot == nil || len(payment.QuoteSnapshot.FeeLines) == 0 {
return nil, false, nil
}
sourceCurrency := ""
if source := effectiveSourceAmount(payment); source != nil {
sourceCurrency = strings.TrimSpace(source.Currency)
}
total := decimal.Zero
currency := ""
for i, line := range payment.QuoteSnapshot.FeeLines {
if !isWalletDebitFeeLine(line) {
continue
}
money := line.GetMoney()
if money == nil {
continue
}
lineCurrency := strings.TrimSpace(money.GetCurrency())
if lineCurrency == "" {
return nil, false, merrors.InvalidArgument(fmt.Sprintf("crypto send: fee_lines[%d].money.currency is required", i))
}
if sourceCurrency != "" && !strings.EqualFold(sourceCurrency, lineCurrency) {
continue
}
if currency == "" {
currency = lineCurrency
} else if !strings.EqualFold(currency, lineCurrency) {
return nil, false, merrors.InvalidArgument("crypto send: wallet fee currency mismatch")
}
amountRaw := strings.TrimSpace(money.GetAmount())
amount, err := decimal.NewFromString(amountRaw)
if err != nil {
return nil, false, merrors.InvalidArgument(fmt.Sprintf("crypto send: fee_lines[%d].money.amount is invalid", i))
}
if amount.Sign() < 0 {
amount = amount.Neg()
}
if amount.Sign() == 0 {
continue
}
total = total.Add(amount)
}
if total.Sign() <= 0 {
return nil, false, nil
}
return &paymenttypes.Money{
Amount: total.String(),
Currency: strings.ToUpper(strings.TrimSpace(currency)),
}, true, nil
}
func isWalletDebitFeeLine(line *paymenttypes.FeeLine) bool {
if line == nil {
return false
}
if line.GetSide() != paymenttypes.EntrySideDebit {
return false
}
meta := line.Meta
if len(meta) == 0 {
return false
}
return strings.EqualFold(strings.TrimSpace(meta["fee_target"]), "wallet")
}
func (e *gatewayCryptoExecutor) resolveDestination(payment *agg.Payment, action model.RailOperation) (*chainv1.TransferDestination, error) {
if payment == nil {
return nil, merrors.InvalidArgument("crypto send: payment is required")

View File

@@ -195,6 +195,245 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_MissingCardRoute(t *testing.T) {
}
}
func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsWalletFeeTransferOnSend(t *testing.T) {
orgID := bson.NewObjectID()
submitRequests := make([]*chainv1.SubmitTransferRequest, 0, 2)
client := &chainclient.Fake{
SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
submitRequests = append(submitRequests, req)
switch len(submitRequests) {
case 1:
return &chainv1.SubmitTransferResponse{
Transfer: &chainv1.Transfer{
TransferRef: "trf-principal",
OperationRef: "op-principal",
},
}, nil
case 2:
return &chainv1.SubmitTransferResponse{
Transfer: &chainv1.Transfer{
TransferRef: "trf-fee",
OperationRef: "op-fee",
},
}, nil
default:
t.Fatalf("unexpected transfer submission call %d", len(submitRequests))
return nil, nil
}
},
}
resolver := &fakeGatewayInvokeResolver{client: client}
registry := &fakeGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto_rail_gateway_arbitrum_sepolia",
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
Rail: discovery.RailCrypto,
InvokeURI: "grpc://crypto-gateway",
IsEnabled: true,
},
},
}
executor := &gatewayCryptoExecutor{
gatewayInvokeResolver: resolver,
gatewayRegistry: registry,
cardGatewayRoutes: map[string]CardGatewayRoute{
paymenttypes.DefaultCardsGatewayID: {FundingAddress: "TUA_DEST", FeeAddress: "TUA_FEE"},
},
}
req := sexec.StepRequest{
Payment: &agg.Payment{
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
PaymentRef: "payment-1",
IdempotencyKey: "idem-1",
IntentSnapshot: model.PaymentIntent{
Ref: "intent-1",
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-src",
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{Pan: "4111111111111111"},
},
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
},
QuoteSnapshot: &model.PaymentQuoteSnapshot{
DebitAmount: &paymenttypes.Money{Amount: "10.000000", Currency: "USDT"},
FeeLines: []*paymenttypes.FeeLine{
{
Money: &paymenttypes.Money{Amount: "0.70", Currency: "USDT"},
LineType: paymenttypes.PostingLineTypeFee,
Side: paymenttypes.EntrySideDebit,
Meta: map[string]string{"fee_target": "wallet"},
},
},
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 1, Rail: "CRYPTO", Gateway: "crypto_rail_gateway_arbitrum_sepolia", InstanceID: "crypto_rail_gateway_arbitrum_sepolia", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 4, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
},
Step: xplan.Step{
StepRef: "hop_1_crypto_send",
StepCode: "hop.1.crypto.send",
Action: discovery.RailOperationSend,
Rail: discovery.RailCrypto,
Gateway: "crypto_rail_gateway_arbitrum_sepolia",
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
},
StepExecution: agg.StepExecution{
StepRef: "hop_1_crypto_send",
StepCode: "hop.1.crypto.send",
Attempt: 1,
},
}
out, err := executor.ExecuteCrypto(context.Background(), req)
if err != nil {
t.Fatalf("ExecuteCrypto returned error: %v", err)
}
if out == nil {
t.Fatal("expected output")
}
if got, want := len(submitRequests), 2; got != want {
t.Fatalf("submit transfer calls mismatch: got=%d want=%d", got, want)
}
principalReq := submitRequests[0]
if got, want := principalReq.GetAmount().GetAmount(), "10.000000"; got != want {
t.Fatalf("principal amount mismatch: got=%q want=%q", got, want)
}
if got, want := principalReq.GetDestination().GetExternalAddress(), "TUA_DEST"; got != want {
t.Fatalf("principal destination mismatch: got=%q want=%q", got, want)
}
feeReq := submitRequests[1]
if got, want := feeReq.GetAmount().GetAmount(), "0.7"; got != want {
t.Fatalf("fee amount mismatch: got=%q want=%q", got, want)
}
if got, want := feeReq.GetAmount().GetCurrency(), "USDT"; got != want {
t.Fatalf("fee currency mismatch: got=%q want=%q", got, want)
}
if got, want := feeReq.GetDestination().GetExternalAddress(), "TUA_FEE"; got != want {
t.Fatalf("fee destination mismatch: got=%q want=%q", got, want)
}
if got, want := feeReq.GetOperationRef(), "payment-1:hop_1_crypto_send:fee"; got != want {
t.Fatalf("fee operation_ref mismatch: got=%q want=%q", got, want)
}
if got, want := feeReq.GetIdempotencyKey(), "idem-1:hop_1_crypto_send:fee"; got != want {
t.Fatalf("fee idempotency_key mismatch: got=%q want=%q", got, want)
}
}
func TestGatewayCryptoExecutor_ExecuteCrypto_FeeActionUsesWalletFeeAmount(t *testing.T) {
orgID := bson.NewObjectID()
var submitReq *chainv1.SubmitTransferRequest
client := &chainclient.Fake{
SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
submitReq = req
return &chainv1.SubmitTransferResponse{
Transfer: &chainv1.Transfer{
TransferRef: "trf-fee",
OperationRef: "op-fee",
},
}, nil
},
}
resolver := &fakeGatewayInvokeResolver{client: client}
registry := &fakeGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto_rail_gateway_arbitrum_sepolia",
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
Rail: discovery.RailCrypto,
InvokeURI: "grpc://crypto-gateway",
IsEnabled: true,
},
},
}
executor := &gatewayCryptoExecutor{
gatewayInvokeResolver: resolver,
gatewayRegistry: registry,
cardGatewayRoutes: map[string]CardGatewayRoute{
paymenttypes.DefaultCardsGatewayID: {FundingAddress: "TUA_DEST", FeeAddress: "TUA_FEE"},
},
}
req := sexec.StepRequest{
Payment: &agg.Payment{
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
PaymentRef: "payment-1",
IdempotencyKey: "idem-1",
IntentSnapshot: model.PaymentIntent{
Ref: "intent-1",
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-src",
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{Pan: "4111111111111111"},
},
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
},
QuoteSnapshot: &model.PaymentQuoteSnapshot{
DebitAmount: &paymenttypes.Money{Amount: "10.000000", Currency: "USDT"},
FeeLines: []*paymenttypes.FeeLine{
{
Money: &paymenttypes.Money{Amount: "0.70", Currency: "USDT"},
LineType: paymenttypes.PostingLineTypeFee,
Side: paymenttypes.EntrySideDebit,
Meta: map[string]string{"fee_target": "wallet"},
},
},
Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{
{Index: 1, Rail: "CRYPTO", Gateway: "crypto_rail_gateway_arbitrum_sepolia", InstanceID: "crypto_rail_gateway_arbitrum_sepolia", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 4, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination},
},
},
},
},
Step: xplan.Step{
StepRef: "hop_1_crypto_fee",
StepCode: "hop.1.crypto.fee",
Action: discovery.RailOperationFee,
Rail: discovery.RailCrypto,
Gateway: "crypto_rail_gateway_arbitrum_sepolia",
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
},
StepExecution: agg.StepExecution{
StepRef: "hop_1_crypto_fee",
StepCode: "hop.1.crypto.fee",
Attempt: 1,
},
}
_, err := executor.ExecuteCrypto(context.Background(), req)
if err != nil {
t.Fatalf("ExecuteCrypto returned error: %v", err)
}
if submitReq == nil {
t.Fatal("expected transfer submission")
}
if got, want := submitReq.GetAmount().GetAmount(), "0.7"; got != want {
t.Fatalf("fee amount mismatch: got=%q want=%q", got, want)
}
if got, want := submitReq.GetDestination().GetExternalAddress(), "TUA_FEE"; got != want {
t.Fatalf("fee destination mismatch: got=%q want=%q", got, want)
}
}
type fakeGatewayInvokeResolver struct {
lastInvokeURI string
client chainclient.Client

View File

@@ -43,6 +43,11 @@ service DocumentService {
// generates it lazily, stores it, and returns it.
rpc GetDocument(GetDocumentRequest)
returns (GetDocumentResponse);
// GetOperationDocument returns a generated PDF file for
// a gateway operation snapshot provided by the caller.
rpc GetOperationDocument(GetOperationDocumentRequest)
returns (GetDocumentResponse);
}
@@ -99,3 +104,24 @@ message GetDocumentResponse {
// MIME type, typically "application/pdf"
string mime_type = 3;
}
// GetOperationDocumentRequest requests a document for a
// single gateway operation.
message GetOperationDocumentRequest {
string organization_ref = 1;
string gateway_service = 2;
string operation_ref = 3;
string payment_ref = 4;
string operation_code = 5;
string operation_label = 6;
string operation_state = 7;
string failure_code = 8;
string failure_reason = 9;
string amount = 10;
string currency = 11;
int64 started_at_unix_ms = 12;
int64 completed_at_unix_ms = 13;
}

View File

@@ -95,6 +95,8 @@ paths:
$ref: ./api/payments/by_multiquote.yaml
/payments/{organizations_ref}:
$ref: ./api/payments/list.yaml
/payments/documents/operation/{organizations_ref}:
$ref: ./api/payments/documents_operation.yaml
components:
securitySchemes:

View File

@@ -1,27 +1,29 @@
get:
tags: [Payments]
summary: Download act document by payment reference
description: Returns the billing act document as binary content.
operationId: paymentsGetActDocument
summary: Download billing document by operation reference
description: |
Returns operation-level billing document as binary content.
The request is resolved by gateway service and operation reference.
operationId: paymentsGetOperationDocument
security:
- bearerAuth: []
parameters:
- $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef
- name: payment_ref
- name: gateway_service
in: query
required: false
description: Payment reference for which to fetch the act document.
required: true
description: Gateway service identifier (`chain_gateway`, `tron_gateway`, `mntx_gateway`, `payment_gateway`, `tgsettle_gateway`).
schema:
type: string
- name: paymentRef
- name: operation_ref
in: query
required: false
description: Alias of `payment_ref`.
required: true
description: Operation reference for which to fetch billing document.
schema:
type: string
responses:
'200':
description: Act document file
description: Operation billing document file
content:
application/pdf:
schema:

View File

@@ -397,6 +397,18 @@ components:
label:
description: Human-readable operation label.
type: string
amount:
description: Primary money amount associated with the operation.
$ref: ../common/money.yaml#/components/schemas/Money
convertedAmount:
description: Secondary amount for conversion operations (for example FX convert output amount).
$ref: ../common/money.yaml#/components/schemas/Money
operationRef:
description: External operation reference identifier reported by the gateway.
type: string
gateway:
description: Gateway microservice type handling the operation.
type: string
failureCode:
description: Machine-readable failure code when operation fails.
type: string

View File