32 Commits

Author SHA1 Message Date
d64ad89072 Merge pull request 'missing asset in billing' (#663) from SEND065 into main
All checks were successful
ci/woodpecker/push/billing_documents Pipeline was successful
Reviewed-on: #663
2026-03-05 12:25:53 +00:00
Arseni
d61eee99bc missing asset in billing 2026-03-05 15:02:52 +03:00
1e376da719 Merge pull request 'fixed icon path in billing' (#659) from SEND064 into main
Some checks failed
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/gateway_tron Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_methods Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/payments_quotation Pipeline is pending
ci/woodpecker/push/billing_documents Pipeline failed
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/fx_oracle Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #659
2026-03-05 11:35:37 +00:00
a8b0c70b65 Merge pull request 'fixed succcess operation matching' (#661) from po-660 into main
All checks were successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #661
2026-03-05 11:24:54 +00:00
Stephan D
8981d296c8 fixed succcess operation matching 2026-03-05 12:23:58 +01:00
Arseni
7e5a98acd7 fixed icon path in billing 2026-03-05 13:59:17 +03:00
8577239dd6 Merge pull request 'improved tgsettle messages + storage fixes' (#658) from tg-657 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #658
2026-03-05 10:54:28 +00:00
Stephan D
5e59fea7e5 improved tgsettle messages + storage fixes 2026-03-05 11:54:07 +01:00
801f349aa8 Merge pull request 'Fixed bot verbosity' (#656) from tg-655 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
Reviewed-on: #656
2026-03-05 10:02:54 +00:00
Stephan D
d1e47841cc Fixed bot verbosity 2026-03-05 11:02:30 +01:00
364731a8c7 Merge pull request 'added download for operation and included fixes for source of payments' (#639) from SEND063 into main
All checks were successful
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #639
Reviewed-by: tech <tech.sendico@proton.me>
2026-03-05 08:29:45 +00:00
Arseni
519a2b1304 few fixes and made sure ledger widget displays the name of ledger wallet 2026-03-05 01:48:53 +03:00
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
Arseni
c59538869b updated document upload according to fresh api 2026-03-04 18:07:08 +03:00
Arseni
aff804ec58 SEND063 2026-03-04 17:43:18 +03: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
118 changed files with 6887 additions and 939 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -26,7 +26,7 @@ documents:
issuer: issuer:
legal_name: "Sendico Ltd" legal_name: "Sendico Ltd"
legal_address: "12 Market Street, London, UK" legal_address: "12 Market Street, London, UK"
logo_path: "/assets/logo.png" logo_path: "assets/logo.png"
templates: templates:
acceptance_path: "templates/acceptance.tpl" acceptance_path: "templates/acceptance.tpl"
protection: protection:

View File

@@ -26,9 +26,9 @@ documents:
issuer: issuer:
legal_name: "Sendico Ltd" legal_name: "Sendico Ltd"
legal_address: "12 Market Street, London, UK" legal_address: "12 Market Street, London, UK"
logo_path: "/assets/logo.png" logo_path: "/app/assets/logo.png"
templates: templates:
acceptance_path: "templates/acceptance.tpl" acceptance_path: "/app/templates/acceptance.tpl"
protection: protection:
owner_password: "sendico-documents" owner_password: "sendico-documents"
storage: storage:

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -148,18 +147,17 @@ func (s *Service) Shutdown() {
func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) { func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) {
start := time.Now() start := time.Now()
paymentRefs := 0
var paymentRefs []string
if req != nil { 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() { defer func() {
statusLabel := statusFromError(err) statusLabel := statusFromError(err)
observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start)) observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start))
observeBatchSize(len(paymentRefs)) observeBatchSize(paymentRefs)
itemsCount := 0 itemsCount := 0
if resp != nil { if resp != nil {
@@ -181,80 +179,16 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba
logger.Info("BatchResolveDocuments finished", fields...) logger.Info("BatchResolveDocuments finished", fields...)
}() }()
if len(paymentRefs) == 0 { _ = ctx
resp = &documentsv1.BatchResolveDocumentsResponse{} err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
return resp, nil return nil, err
}
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
} }
func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) { func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
start := time.Now() start := time.Now()
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
paymentRef := "" paymentRef := ""
if req != nil { if req != nil {
docType = req.GetType() docType = req.GetType()
paymentRef = strings.TrimSpace(req.GetPaymentRef()) paymentRef = strings.TrimSpace(req.GetPaymentRef())
@@ -293,92 +227,94 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
logger.Info("GetDocument finished", fields...) logger.Info("GetDocument finished", fields...)
}() }()
if paymentRef == "" { _ = ctx
err = status.Error(codes.InvalidArgument, "payment_ref is required") 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 { logger := s.logger.With(
err = status.Error(codes.InvalidArgument, "document type is required") 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 { if resp != nil {
err = status.Error(codes.Unavailable, errStorageUnavailable.Error()) observeDocumentBytes(docType, len(resp.GetContent()))
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")
} }
return nil, status.Error(codes.Internal, err.Error()) contentBytes := 0
} if resp != nil {
contentBytes = len(resp.GetContent())
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())
} }
return &documentsv1.GetDocumentResponse{ fields := []zap.Field{
Content: content, zap.String("status", statusLabel),
Filename: documentFilename(docType, paymentRef), zap.Duration("duration", time.Since(start)),
MimeType: "application/pdf", zap.Int("content_bytes", contentBytes),
}, nil }
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 { 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()) return nil, err
}
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())
} }
resp = &documentsv1.GetDocumentResponse{ resp = &documentsv1.GetDocumentResponse{
Content: content, Content: content,
Filename: documentFilename(docType, paymentRef), Filename: operationDocumentFilename(operationRef),
MimeType: "application/pdf", MimeType: "application/pdf",
} }
@@ -392,7 +328,7 @@ func (s *Service) startDiscoveryAnnouncer() {
announce := discovery.Announcement{ announce := discovery.Announcement{
Service: mservice.BillingDocuments, Service: mservice.BillingDocuments,
Operations: []string{discovery.OperationDocumentsBatchResolve, discovery.OperationDocumentsGet}, Operations: []string{discovery.OperationDocumentsGet},
InvokeURI: s.invokeURI, InvokeURI: s.invokeURI,
Version: appversion.Create().Short(), Version: appversion.Create().Short(),
} }
@@ -418,10 +354,19 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
return nil, "", err 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{ generated := renderer.Renderer{
Issuer: s.config.Issuer, Issuer: s.config.Issuer,
OwnerPassword: s.config.Protection.OwnerPassword, OwnerPassword: s.config.Protection.OwnerPassword,
} }
placeholder := strings.Repeat("0", 64) placeholder := strings.Repeat("0", 64)
firstPass, err := generated.Render(blocks, placeholder) firstPass, err := generated.Render(blocks, placeholder)
@@ -440,6 +385,157 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
return finalBytes, footerHex, nil 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 { func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType {
if len(types) == 0 { if len(types) == 0 {
return nil return nil

View File

@@ -12,6 +12,8 @@ import (
"github.com/tech/sendico/billing/documents/storage/model" "github.com/tech/sendico/billing/documents/storage/model"
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1" documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
) )
type stubRepo struct { type stubRepo struct {
@@ -94,9 +96,7 @@ func (s *stubTemplate) Render(_ model.ActSnapshot) ([]renderer.Block, error) {
return s.blocks, nil return s.blocks, nil
} }
func TestGetDocument_IdempotentAndHashed(t *testing.T) { func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) {
ctx := context.Background()
snapshot := model.ActSnapshot{ snapshot := model.ActSnapshot{
PaymentID: "PAY-123", PaymentID: "PAY-123",
Date: time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC), Date: time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC),
@@ -105,14 +105,6 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
Currency: "USD", Currency: "USD",
} }
record := &model.DocumentRecord{
PaymentRef: "PAY-123",
Snapshot: snapshot,
}
documentsStore := &stubDocumentsStore{record: record}
repo := &stubRepo{store: documentsStore}
store := newMemDocStore()
tmpl := &stubTemplate{ tmpl := &stubTemplate{
blocks: []renderer.Block{ blocks: []renderer.Block{
{Tag: renderer.TagTitle, Lines: []string{"ACT"}}, {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), WithConfig(cfg),
WithDocumentStore(store),
WithTemplateRenderer(tmpl), WithTemplateRenderer(tmpl),
) )
resp1, err := svc.GetDocument(ctx, &documentsv1.GetDocumentRequest{ pdf1, hash1, err := svc.generateActPDF(snapshot)
PaymentRef: "PAY-123",
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
})
if err != nil { 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") t.Fatalf("expected content on first call")
} }
stored := record.Hashes[model.DocumentTypeAct] if hash1 == "" {
t.Fatalf("expected non-empty hash on first call")
if stored == "" {
t.Fatalf("expected stored hash")
} }
footerHash := extractFooterHash(resp1.GetContent()) footerHash := extractFooterHash(pdf1)
if footerHash == "" { if footerHash == "" {
t.Fatalf("expected footer hash in PDF") t.Fatalf("expected footer hash in PDF")
} }
if stored != footerHash { if hash1 != footerHash {
t.Fatalf("stored hash mismatch: got %s", stored) t.Fatalf("stored hash mismatch: got %s", hash1)
} }
resp2, err := svc.GetDocument(ctx, &documentsv1.GetDocumentRequest{ pdf2, hash2, err := svc.generateActPDF(snapshot)
PaymentRef: "PAY-123",
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
})
if err != nil { if err != nil {
t.Fatalf("GetDocument second call: %v", err) t.Fatalf("generateActPDF second call: %v", err)
} }
if hash2 == "" {
if !bytes.Equal(resp1.GetContent(), resp2.GetContent()) { t.Fatalf("expected non-empty hash on second call")
t.Fatalf("expected identical PDF bytes on second call")
} }
footerHash2 := extractFooterHash(pdf2)
if tmpl.calls != 1 { if footerHash2 == "" {
t.Fatalf("expected template to be rendered once, got %d", tmpl.calls) t.Fatalf("expected footer hash in second PDF")
} }
if footerHash2 != hash2 {
if store.saveCount != 1 { t.Fatalf("second hash mismatch: got=%s want=%s", footerHash2, hash2)
t.Fatalf("expected document save once, got %d", store.saveCount)
}
if store.loadCount == 0 {
t.Fatalf("expected document load on second call")
} }
} }
@@ -212,3 +189,48 @@ func extractFooterHash(pdf []byte) string {
func isHexDigit(b byte) bool { func isHexDigit(b byte) bool {
return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F') 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/api/http/response"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
paymenttypes "github.com/tech/sendico/pkg/payments/types" paymenttypes "github.com/tech/sendico/pkg/payments/types"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" 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" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
@@ -77,14 +79,18 @@ type Payment struct {
} }
type PaymentOperation struct { type PaymentOperation struct {
StepRef string `json:"stepRef,omitempty"` StepRef string `json:"stepRef,omitempty"`
Code string `json:"code,omitempty"` Code string `json:"code,omitempty"`
State string `json:"state,omitempty"` State string `json:"state,omitempty"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
FailureCode string `json:"failureCode,omitempty"` Amount *paymenttypes.Money `json:"amount,omitempty"`
FailureReason string `json:"failureReason,omitempty"` ConvertedAmount *paymenttypes.Money `json:"convertedAmount,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"` OperationRef string `json:"operationRef,omitempty"`
CompletedAt time.Time `json:"completedAt,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 { type paymentQuoteResponse struct {
@@ -283,7 +289,7 @@ func toPayment(p *orchestrationv2.Payment) *Payment {
if p == nil { if p == nil {
return nil return nil
} }
operations := toUserVisibleOperations(p.GetStepExecutions()) operations := toUserVisibleOperations(p.GetStepExecutions(), p.GetQuoteSnapshot())
failureCode, failureReason := firstFailure(operations) failureCode, failureReason := firstFailure(operations)
return &Payment{ return &Payment{
PaymentRef: p.GetPaymentRef(), PaymentRef: p.GetPaymentRef(),
@@ -308,7 +314,7 @@ func firstFailure(operations []PaymentOperation) (string, string) {
return "", "" return "", ""
} }
func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOperation { func toUserVisibleOperations(steps []*orchestrationv2.StepExecution, quote *quotationv2.PaymentQuote) []PaymentOperation {
if len(steps) == 0 { if len(steps) == 0 {
return nil return nil
} }
@@ -317,7 +323,7 @@ func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOp
if step == nil || !isUserVisibleStep(step.GetReportVisibility()) { if step == nil || !isUserVisibleStep(step.GetReportVisibility()) {
continue continue
} }
ops = append(ops, toPaymentOperation(step)) ops = append(ops, toPaymentOperation(step, quote))
} }
if len(ops) == 0 { if len(ops) == 0 {
return nil return nil
@@ -325,14 +331,20 @@ func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOp
return ops 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{ op := PaymentOperation{
StepRef: step.GetStepRef(), StepRef: step.GetStepRef(),
Code: step.GetStepCode(), Code: step.GetStepCode(),
State: enumJSONName(step.GetState().String()), State: enumJSONName(step.GetState().String()),
Label: strings.TrimSpace(step.GetUserLabel()), Label: strings.TrimSpace(step.GetUserLabel()),
StartedAt: timestampAsTime(step.GetStartedAt()), Amount: amount,
CompletedAt: timestampAsTime(step.GetCompletedAt()), ConvertedAmount: convertedAmount,
OperationRef: operationRef,
Gateway: string(gateway),
StartedAt: timestampAsTime(step.GetStartedAt()),
CompletedAt: timestampAsTime(step.GetCompletedAt()),
} }
failure := step.GetFailure() failure := step.GetFailure()
if failure == nil { if failure == nil {
@@ -346,6 +358,165 @@ func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation {
return op 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 { func isUserVisibleStep(visibility orchestrationv2.ReportVisibility) bool {
switch visibility { switch visibility {
case orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN, case orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,

View File

@@ -3,6 +3,8 @@ package sresponse
import ( import (
"testing" "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" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" 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 { if len(ops) != 2 {
t.Fatalf("operations count mismatch: got=%d want=2", len(ops)) 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) 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/mservice"
"github.com/tech/sendico/pkg/mutil/mzap" "github.com/tech/sendico/pkg/mutil/mzap"
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1" 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" "github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param" mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
@@ -23,43 +24,90 @@ import (
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
) )
const ( const (
documentsServiceName = "BILLING_DOCUMENTS" documentsServiceName = "BILLING_DOCUMENTS"
documentsOperationGet = discovery.OperationDocumentsGet documentsOperationGet = discovery.OperationDocumentsGet
documentsDialTimeout = 5 * time.Second
documentsCallTimeout = 10 * 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) orgRef, err := a.oph.GetRef(r)
if err != nil { if err != nil {
a.logger.Warn("Failed to parse organization reference for document request", zap.Error(err), mutil.PLog(a.oph, r)) 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() ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead) allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
if err != nil { if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r)) 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 { if !allowed {
a.logger.Debug("Access denied when downloading act", mutil.PLog(a.oph, r)) a.logger.Debug("Access denied when downloading document", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied") return bson.NilObjectID, response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
} }
paymentRef := strings.TrimSpace(r.URL.Query().Get("payment_ref")) return orgRef, nil
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")
}
func (a *PaymentAPI) resolveOperationDocumentDeps(ctx context.Context, gatewayService mservice.Type) (*discovery.ServiceSummary, *discovery.GatewaySummary, http.HandlerFunc) {
if a.discovery == nil { 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) 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) lookupResp, err := a.discovery.Lookup(lookupCtx)
if err != nil { if err != nil {
a.logger.Warn("Failed to lookup discovery registry", zap.Error(err)) 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) service := findDocumentsService(lookupResp.Services)
if service == nil { 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) gateway := findGatewayForService(lookupResp.Gateways, gatewayService)
if err != nil { if gateway == nil {
a.logger.Warn("Failed to fetch act document", zap.Error(err), mzap.ObjRef("organization_ref", orgRef)) return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "gateway service unavailable")
return documentErrorResponse(a.logger, a.Name(), err)
} }
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()) filename := strings.TrimSpace(docResp.GetFilename())
if filename == "" { if filename == "" {
filename = fmt.Sprintf("act_%s.pdf", paymentRef) filename = strings.TrimSpace(fallbackFilename)
} }
if filename == "" {
filename = "document.pdf"
}
mimeType := strings.TrimSpace(docResp.GetMimeType()) mimeType := strings.TrimSpace(docResp.GetMimeType())
if mimeType == "" { if mimeType == "" {
mimeType = "application/pdf" 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-Type", mimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if _, writeErr := w.Write(docResp.GetContent()); writeErr != nil { if _, err := w.Write(docResp.GetContent()); err != nil {
a.logger.Warn("Failed to write document response", zap.Error(writeErr)) 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())) conn, err := grpc.NewClient(invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil { if err != nil {
return nil, merrors.InternalWrap(err, "dial billing documents") 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) callCtx, callCancel := context.WithTimeout(ctx, documentsCallTimeout)
defer callCancel() defer callCancel()
return client.GetDocument(callCtx, &documentsv1.GetDocumentRequest{ return client.GetOperationDocument(callCtx, req)
PaymentRef: paymentRef, }
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
}) 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 { 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-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("/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("/"), 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"), api.Get, p.listDiscoveryRegistry)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry/refresh"), api.Get, p.getDiscoveryRefresh) apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry/refresh"), api.Get, p.getDiscoveryRefresh)
@@ -207,7 +207,7 @@ type grpcQuotationClient struct {
callTimeout time.Duration 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() cfg.setDefaults()
if strings.TrimSpace(cfg.Address) == "" { if strings.TrimSpace(cfg.Address) == "" {
return nil, merrors.InvalidArgument("payment quotation: address is required") 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/appversion"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "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" chainasset "github.com/tech/sendico/pkg/chain"
"github.com/tech/sendico/pkg/connector/params" "github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
@@ -17,6 +18,7 @@ import (
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
) )
const chainConnectorID = "chain" const chainConnectorID = "chain"
@@ -293,11 +295,21 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" { if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
return nil, merrors.InvalidArgument("get_operation: operation_id is required") 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 { if err != nil {
return nil, err 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) { 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 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 { func gasTopUpResult(amount *moneyv1.Money, capHit bool, transferRef string) *structpb.Struct {
payload := map[string]interface{}{ payload := map[string]interface{}{
"cap_hit": capHit, "cap_hit": capHit,
@@ -518,18 +585,33 @@ func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation
return nil return nil
} }
op := &connectorv1.Operation{ op := &connectorv1.Operation{
OperationId: strings.TrimSpace(transfer.GetTransferRef()), OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Type: connectorv1.OperationType_TRANSFER, Type: connectorv1.OperationType_TRANSFER,
Status: chainTransferStatusToOperation(transfer.GetStatus()), Status: chainTransferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(), Money: transfer.GetRequestedAmount(),
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()), ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
CreatedAt: transfer.GetCreatedAt(), IntentRef: strings.TrimSpace(transfer.GetIntentRef()),
UpdatedAt: transfer.GetUpdatedAt(), OperationRef: strings.TrimSpace(transfer.GetOperationRef()),
CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chainConnectorID, ConnectorId: chainConnectorID,
AccountId: strings.TrimSpace(transfer.GetSourceWalletRef()), 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 { if dest := transfer.GetDestination(); dest != nil {
switch d := dest.GetDestination().(type) { switch d := dest.GetDestination().(type) {
case *chainv1.TransferDestination_ManagedWalletRef: case *chainv1.TransferDestination_ManagedWalletRef:
@@ -629,6 +711,17 @@ func operationAccountID(party *connectorv1.OperationParty) string {
return "" 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 { func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
err := &connectorv1.ConnectorError{ err := &connectorv1.ConnectorError{
Code: code, Code: code,

View File

@@ -500,6 +500,32 @@ func (t *inMemoryTransfers) Get(ctx context.Context, transferRef string) (*model
return transfer, nil 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) { func (t *inMemoryTransfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() 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}}, Keys: []ri.Key{{Field: "transferRef", Sort: ri.Asc}},
Unique: true, Unique: true,
}, },
{
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}, {Field: "operationRef", Sort: ri.Asc}},
},
{ {
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}}, Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}},
Unique: true, Unique: true,
@@ -110,6 +113,25 @@ func (t *Transfers) Get(ctx context.Context, transferRef string) (*model.Transfe
return transfer, nil 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) { func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
query := repository.Query() query := repository.Query()
if src := strings.TrimSpace(filter.SourceWalletRef); src != "" { if src := strings.TrimSpace(filter.SourceWalletRef); src != "" {

View File

@@ -42,6 +42,7 @@ type WalletsStore interface {
type TransfersStore interface { type TransfersStore interface {
Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error) Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error)
Get(ctx context.Context, transferRef string) (*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) 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) 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 request_timeout_seconds: 15
status_success: "success" status_success: "success"
status_processing: "processing" status_processing: "processing"
strict_operation_mode: true strict_operation_mode: false
gateway: gateway:
id: "mcards" id: "mcards"

View File

@@ -12,6 +12,7 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/protobuf/types/known/structpb"
) )
const mntxConnectorID = "mntx" const mntxConnectorID = "mntx"
@@ -92,11 +93,21 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" { if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
return nil, merrors.InvalidArgument("get_operation: operation_id is required") 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 { if err != nil {
return nil, err 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) { 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 { if state == nil {
return nil return nil
} }
return &connectorv1.Operation{ op := &connectorv1.Operation{
OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())), OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())),
Type: connectorv1.OperationType_PAYOUT, Type: connectorv1.OperationType_PAYOUT,
Status: payoutStatusToOperation(state.GetStatus()), Status: payoutStatusToOperation(state.GetStatus()),
@@ -282,10 +293,30 @@ func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation {
Amount: minorToDecimal(state.GetAmountMinor()), Amount: minorToDecimal(state.GetAmountMinor()),
Currency: strings.ToUpper(strings.TrimSpace(state.GetCurrency())), Currency: strings.ToUpper(strings.TrimSpace(state.GetCurrency())),
}, },
ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()), ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()),
CreatedAt: state.GetCreatedAt(), IntentRef: strings.TrimSpace(state.GetIntentRef()),
UpdatedAt: state.GetUpdatedAt(), 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 { 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 { func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
err := &connectorv1.ConnectorError{ err := &connectorv1.ConnectorError{
Code: code, Code: code,

View File

@@ -41,3 +41,15 @@ gateway:
timeout_seconds: 345600 timeout_seconds: 345600
accepted_user_ids: [] accepted_user_ids: []
success_reaction: "\U0001FAE1" 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,19 @@ gateway:
timeout_seconds: 345600 timeout_seconds: 345600
accepted_user_ids: [] accepted_user_ids: []
success_reaction: "\U0001FAE1" 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"
- telegram_user_id: "8273507566"
ledger_account: "6995d6c118bca1d8baa5f2be"

View File

@@ -3,6 +3,7 @@ package serverimp
import ( import (
"context" "context"
"os" "os"
"strings"
"time" "time"
"github.com/tech/sendico/gateway/tgsettle/internal/service/gateway" "github.com/tech/sendico/gateway/tgsettle/internal/service/gateway"
@@ -28,11 +29,17 @@ type Imp struct {
config *config config *config
app *grpcapp.App[storage.Repository] app *grpcapp.App[storage.Repository]
service *gateway.Service service *gateway.Service
discoveryWatcher *discovery.RegistryWatcher
discoveryReg *discovery.Registry
} }
type config struct { type config struct {
*grpcapp.Config `yaml:",inline"` *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 { type gatewayConfig struct {
@@ -43,6 +50,33 @@ type gatewayConfig struct {
SuccessReaction string `yaml:"success_reaction"` 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) { func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
return &Imp{ return &Imp{
logger: logger.Named("server"), logger: logger.Named("server"),
@@ -62,6 +96,9 @@ func (i *Imp) Shutdown() {
if i.service != nil { if i.service != nil {
i.service.Shutdown() i.service.Shutdown()
} }
if i.discoveryWatcher != nil {
i.discoveryWatcher.Stop()
}
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
i.app.Shutdown(ctx) i.app.Shutdown(ctx)
@@ -81,6 +118,19 @@ func (i *Imp) Start() error {
i.logger.Warn("Failed to create messaging broker", zap.Error(err)) 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) { repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
return gatewaymongo.New(logger, conn) return gatewaymongo.New(logger, conn)
@@ -95,6 +145,8 @@ func (i *Imp) Start() error {
if cfg.Messaging != nil { if cfg.Messaging != nil {
msgSettings = cfg.Messaging.Settings msgSettings = cfg.Messaging.Settings
} }
treasuryTelegram := treasuryTelegramConfig(cfg, i.logger)
treasuryLedger := treasuryLedgerConfig(cfg, i.logger)
gwCfg := gateway.Config{ gwCfg := gateway.Config{
Rail: cfg.Gateway.Rail, Rail: cfg.Gateway.Rail,
TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv, TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv,
@@ -103,6 +155,22 @@ func (i *Imp) Start() error {
SuccessReaction: cfg.Gateway.SuccessReaction, SuccessReaction: cfg.Gateway.SuccessReaction,
InvokeURI: invokeURI, InvokeURI: invokeURI,
MessagingSettings: msgSettings, 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) svc := gateway.NewService(logger, repo, producer, broker, gwCfg)
i.service = svc i.service = svc
@@ -142,6 +210,15 @@ func (i *Imp) loadConfig() (*config, error) {
if cfg.Metrics == nil { if cfg.Metrics == nil {
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9406"} 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) cfg.Gateway.Rail = discovery.NormalizeRail(cfg.Gateway.Rail)
if cfg.Gateway.Rail == "" { if cfg.Gateway.Rail == "" {
return nil, merrors.InvalidArgument("gateway rail is required", "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 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

@@ -55,8 +55,9 @@ func (s *Service) sweepExpiredConfirmations(ctx context.Context) {
s.logger.Warn("Failed to list expired pending confirmations", zap.Error(err)) s.logger.Warn("Failed to list expired pending confirmations", zap.Error(err))
return return
} }
for _, pending := range expired { for i := range expired {
if pending == nil || strings.TrimSpace(pending.RequestID) == "" { pending := &expired[i]
if strings.TrimSpace(pending.RequestID) == "" {
continue continue
} }
result := &model.ConfirmationResult{ result := &model.ConfirmationResult{
@@ -146,6 +147,7 @@ func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWe
message := update.Message message := update.Message
replyToID := strings.TrimSpace(message.ReplyToMessageID) replyToID := strings.TrimSpace(message.ReplyToMessageID)
if replyToID == "" { if replyToID == "" {
s.handleTreasuryTelegramUpdate(ctx, update)
return nil return nil
} }
replyFields := telegramReplyLogFields(update) replyFields := telegramReplyLogFields(update)
@@ -154,6 +156,9 @@ func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWe
return err return err
} }
if pending == nil { if pending == nil {
if s.handleTreasuryTelegramUpdate(ctx, update) {
return nil
}
s.logger.Warn("Telegram confirmation reply dropped", s.logger.Warn("Telegram confirmation reply dropped",
append(replyFields, append(replyFields,
zap.String("outcome", "dropped"), zap.String("outcome", "dropped"),
@@ -272,6 +277,13 @@ func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWe
return nil 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 { func telegramReplyLogFields(update *model.TelegramWebhookUpdate) []zap.Field {
if update == nil || update.Message == nil { if update == nil || update.Message == nil {
return nil return nil

View File

@@ -11,6 +11,9 @@ import (
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
) )
const tgsettleConnectorID = "tgsettle" 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") return nil, merrors.InvalidArgument("get_operation: operation_id is required")
} }
operationID := strings.TrimSpace(req.GetOperationId()) 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 { 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 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) { 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(), CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(), 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 != "" { if source := strings.TrimSpace(transfer.GetSourceWalletRef()); source != "" {
op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: tgsettleConnectorID, ConnectorId: tgsettleConnectorID,
@@ -281,6 +307,17 @@ func operationAccountID(party *connectorv1.OperationParty) string {
return "" 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 { func operationLogFields(op *connectorv1.Operation) []zap.Field {
if op == nil { if op == nil {
return nil return nil

View File

@@ -9,6 +9,8 @@ import (
"time" "time"
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" 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" "github.com/tech/sendico/gateway/tgsettle/storage"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model" storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
"github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/api/routers"
@@ -40,6 +42,9 @@ const (
defaultConfirmationTimeoutSeconds = 345600 defaultConfirmationTimeoutSeconds = 345600
defaultTelegramSuccessReaction = "\U0001FAE1" defaultTelegramSuccessReaction = "\U0001FAE1"
defaultConfirmationSweepInterval = 5 * time.Second defaultConfirmationSweepInterval = 5 * time.Second
defaultTreasuryExecutionDelay = 30 * time.Second
defaultTreasuryPollInterval = 30 * time.Second
defaultTreasuryLedgerTimeout = 5 * time.Second
) )
const ( const (
@@ -59,6 +64,35 @@ type Config struct {
SuccessReaction string SuccessReaction string
InvokeURI string InvokeURI string
MessagingSettings pmodel.SettingsT 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 { type Service struct {
@@ -80,6 +114,8 @@ type Service struct {
timeoutCancel context.CancelFunc timeoutCancel context.CancelFunc
timeoutWG sync.WaitGroup timeoutWG sync.WaitGroup
treasury *treasurysvc.Module
connectorv1.UnimplementedConnectorServiceServer connectorv1.UnimplementedConnectorServiceServer
} }
@@ -112,6 +148,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
svc.startConsumers() svc.startConsumers()
svc.startAnnouncer() svc.startAnnouncer()
svc.startConfirmationTimeoutWatcher() svc.startConfirmationTimeoutWatcher()
svc.startTreasuryModule()
return svc return svc
} }
@@ -134,12 +171,106 @@ func (s *Service) Shutdown() {
consumer.Close() consumer.Close()
} }
} }
if s.treasury != nil {
s.treasury.Shutdown()
}
if s.timeoutCancel != nil { if s.timeoutCancel != nil {
s.timeoutCancel() s.timeoutCancel()
} }
s.timeoutWG.Wait() 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
}
configuredUsers := s.cfg.Treasury.Telegram.Users
if len(configuredUsers) == 0 {
return
}
users := make([]treasurysvc.UserBinding, 0, len(configuredUsers))
configuredUserIDs := make([]string, 0, len(configuredUsers))
for _, binding := range configuredUsers {
userID := strings.TrimSpace(binding.TelegramUserID)
accountID := strings.TrimSpace(binding.LedgerAccount)
if userID != "" {
configuredUserIDs = append(configuredUserIDs, userID)
}
if userID == "" || accountID == "" {
continue
}
users = append(users, treasurysvc.UserBinding{
TelegramUserID: userID,
LedgerAccount: accountID,
})
}
if len(users) == 0 {
s.logger.Warn("Treasury module disabled: no valid treasury.telegram.users bindings",
zap.Int("configured_bindings", len(configuredUsers)),
zap.Strings("configured_user_ids", configuredUserIDs))
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
}
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() { func (s *Service) startConsumers() {
if s == nil || s.broker == nil { if s == nil || s.broker == nil {
if s != nil && s.logger != nil { if s != nil && s.logger != nil {
@@ -675,6 +806,9 @@ func transferFromRequest(req *chainv1.SubmitTransferRequest) *chainv1.Transfer {
SourceWalletRef: strings.TrimSpace(req.GetSourceWalletRef()), SourceWalletRef: strings.TrimSpace(req.GetSourceWalletRef()),
Destination: req.GetDestination(), Destination: req.GetDestination(),
RequestedAmount: req.GetAmount(), RequestedAmount: req.GetAmount(),
IntentRef: strings.TrimSpace(req.GetIntentRef()),
OperationRef: strings.TrimSpace(req.GetOperationRef()),
PaymentRef: strings.TrimSpace(req.GetPaymentRef()),
Status: chainv1.TransferStatus_TRANSFER_CREATED, Status: chainv1.TransferStatus_TRANSFER_CREATED,
} }
} }
@@ -714,6 +848,10 @@ func transferFromPayment(record *storagemodel.PaymentRecord, req *chainv1.Submit
IdempotencyKey: strings.TrimSpace(record.IdempotencyKey), IdempotencyKey: strings.TrimSpace(record.IdempotencyKey),
RequestedAmount: requested, RequestedAmount: requested,
NetAmount: net, NetAmount: net,
IntentRef: strings.TrimSpace(record.IntentRef),
OperationRef: strings.TrimSpace(record.OperationRef),
PaymentRef: strings.TrimSpace(record.PaymentRef),
FailureReason: strings.TrimSpace(record.FailureReason),
Status: status, Status: status,
} }

View File

@@ -37,6 +37,20 @@ func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string)
return f.records[key], nil 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 { func (f *fakePaymentsStore) Upsert(_ context.Context, record *storagemodel.PaymentRecord) error {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -66,6 +80,7 @@ type fakeRepo struct {
payments *fakePaymentsStore payments *fakePaymentsStore
tg *fakeTelegramStore tg *fakeTelegramStore
pending *fakePendingStore pending *fakePendingStore
treasury storage.TreasuryRequestsStore
} }
func (f *fakeRepo) Payments() storage.PaymentsStore { func (f *fakeRepo) Payments() storage.PaymentsStore {
@@ -80,6 +95,10 @@ func (f *fakeRepo) PendingConfirmations() storage.PendingConfirmationsStore {
return f.pending return f.pending
} }
func (f *fakeRepo) TreasuryRequests() storage.TreasuryRequestsStore {
return f.treasury
}
type fakePendingStore struct { type fakePendingStore struct {
mu sync.Mutex mu sync.Mutex
records map[string]*storagemodel.PendingConfirmation records map[string]*storagemodel.PendingConfirmation
@@ -143,19 +162,18 @@ func (f *fakePendingStore) DeleteByRequestID(_ context.Context, requestID string
return nil return nil
} }
func (f *fakePendingStore) ListExpired(_ context.Context, now time.Time, limit int64) ([]*storagemodel.PendingConfirmation, error) { func (f *fakePendingStore) ListExpired(_ context.Context, now time.Time, limit int64) ([]storagemodel.PendingConfirmation, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
if limit <= 0 { if limit <= 0 {
limit = 100 limit = 100
} }
result := make([]*storagemodel.PendingConfirmation, 0) result := make([]storagemodel.PendingConfirmation, 0)
for _, record := range f.records { for _, record := range f.records {
if record == nil || record.ExpiresAt.IsZero() || record.ExpiresAt.After(now) { if record == nil || record.ExpiresAt.IsZero() || record.ExpiresAt.After(now) {
continue continue
} }
cp := *record result = append(result, *record)
result = append(result, &cp)
if int64(len(result)) >= limit { if int64(len(result)) >= limit {
break break
} }

View File

@@ -0,0 +1,86 @@
package bot
import "strings"
type Command string
const (
CommandStart Command = "start"
CommandHelp Command = "help"
CommandFund Command = "fund"
CommandWithdraw Command = "withdraw"
CommandConfirm Command = "confirm"
CommandCancel Command = "cancel"
)
var supportedCommands = []Command{
CommandStart,
CommandHelp,
CommandFund,
CommandWithdraw,
CommandConfirm,
CommandCancel,
}
func (c Command) Slash() string {
name := strings.TrimSpace(string(c))
if name == "" {
return ""
}
return "/" + name
}
func parseCommand(text string) Command {
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 Command(strings.ToLower(strings.TrimSpace(token)))
}
func supportedCommandsMessage() string {
lines := make([]string, 0, len(supportedCommands)+1)
lines = append(lines, "Supported commands:")
for _, cmd := range supportedCommands {
lines = append(lines, cmd.Slash())
}
return strings.Join(lines, "\n")
}
func confirmationCommandsMessage() string {
return "Confirm operation?\n\n" + CommandConfirm.Slash() + "\n" + CommandCancel.Slash()
}
func helpMessage(accountCode string, currency string) string {
accountCode = strings.TrimSpace(accountCode)
currency = strings.ToUpper(strings.TrimSpace(currency))
if accountCode == "" {
accountCode = "N/A"
}
if currency == "" {
currency = "N/A"
}
lines := []string{
"Treasury bot help",
"",
"Attached account: " + accountCode + " (" + currency + ")",
"",
"How to use:",
"1) Start funding with " + CommandFund.Slash() + " or withdrawal with " + CommandWithdraw.Slash(),
"2) Enter amount as decimal, dot separator, no currency (example: 1250.75)",
"3) Confirm with " + CommandConfirm.Slash() + " or abort with " + CommandCancel.Slash(),
"",
"After confirmation there is a cooldown window. You can cancel during it with " + CommandCancel.Slash() + ".",
"You will receive a follow-up message with execution success or failure.",
}
return strings.Join(lines, "\n")
}

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,504 @@
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 unauthorizedChatMessage = "Sorry, this Telegram chat is not authorized to perform treasury operations."
const amountInputHint = "Enter amount as a decimal number using a dot separator and without currency.\nExample: 1250.75"
type SendTextFunc func(ctx context.Context, chatID string, text string) error
type ScheduleTracker interface {
TrackScheduled(record *storagemodel.TreasuryRequest)
Untrack(requestID string)
}
type AccountProfile struct {
AccountID string
AccountCode string
Currency 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)
GetAccountProfile(ctx context.Context, ledgerAccountID string) (*AccountProfile, 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
}
command := parseCommand(text)
if r.logger != nil {
r.logger.Debug("Telegram treasury update received",
zap.Int64("update_id", update.UpdateID),
zap.String("chat_id", chatID),
zap.String("telegram_user_id", userID),
zap.String("command", strings.TrimSpace(string(command))),
zap.String("message_text", text),
zap.String("reply_to_message_id", strings.TrimSpace(message.ReplyToMessageID)),
)
}
if !r.allowAnyChat {
if _, ok := r.allowedChats[chatID]; !ok {
r.logUnauthorized(update)
_ = r.sendText(ctx, chatID, unauthorizedChatMessage)
return true
}
}
accountID, ok := r.userAccounts[userID]
if !ok || strings.TrimSpace(accountID) == "" {
r.logUnauthorized(update)
_ = r.sendText(ctx, chatID, unauthorizedMessage)
return true
}
switch command {
case CommandStart:
profile := r.resolveAccountProfile(ctx, accountID)
_ = r.sendText(ctx, chatID, welcomeMessage(profile))
return true
case CommandHelp:
profile := r.resolveAccountProfile(ctx, accountID)
_ = r.sendText(ctx, chatID, helpMessage(displayAccountCode(profile, accountID), profile.Currency))
return true
case CommandFund:
if r.logger != nil {
r.logger.Info("Treasury funding dialog requested",
zap.String("chat_id", chatID),
zap.String("telegram_user_id", userID),
zap.String("ledger_account_id", accountID))
}
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationFund)
return true
case CommandWithdraw:
if r.logger != nil {
r.logger.Info("Treasury withdrawal dialog requested",
zap.String("chat_id", chatID),
zap.String("telegram_user_id", userID),
zap.String("ledger_account_id", accountID))
}
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationWithdraw)
return true
case CommandConfirm:
if r.logger != nil {
r.logger.Info("Treasury confirmation requested",
zap.String("chat_id", chatID),
zap.String("telegram_user_id", userID),
zap.String("ledger_account_id", accountID))
}
r.confirm(ctx, userID, accountID, chatID)
return true
case CommandCancel:
if r.logger != nil {
r.logger.Info("Treasury cancellation requested",
zap.String("chat_id", chatID),
zap.String("telegram_user_id", userID),
zap.String("ledger_account_id", accountID))
}
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, confirmationCommandsMessage())
return true
}
}
if strings.HasPrefix(text, "/") {
_ = r.sendText(ctx, chatID, supportedCommandsMessage())
return true
}
if strings.TrimSpace(message.ReplyToMessageID) != "" {
return false
}
if text != "" {
_ = r.sendText(ctx, chatID, supportedCommandsMessage())
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 {
if r.logger != 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))
}
_ = r.sendText(ctx, chatID, "Unable to check pending treasury operations right now. Please try again.")
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,
})
profile := r.resolveAccountProfile(ctx, accountID)
_ = r.sendText(ctx, chatID, amountPromptMessage(operation, profile, accountID))
}
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 "+CommandCancel.Slash())
return
case "daily":
_ = r.sendText(ctx, chatID, "Daily amount limit exceeded.\n\nMax per day: "+typed.LimitMax()+"\n\nEnter another amount or "+CommandCancel.Slash())
return
}
}
if errors.Is(err, merrors.ErrInvalidArg) {
_ = r.sendText(ctx, chatID, "Invalid amount.\n\n"+amountInputHint+"\n\nEnter another amount or "+CommandCancel.Slash())
return
}
_ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or "+CommandCancel.Slash())
return
}
if record == nil {
_ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or "+CommandCancel.Slash())
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 "+CommandCancel.Slash()+" or create a new request with "+CommandFund.Slash()+" or "+CommandWithdraw.Slash()+".")
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)+".\nYou can cancel during this cooldown with "+CommandCancel.Slash()+".\n\nYou will receive a follow-up message with execution success or failure.\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
}
if err := r.send(ctx, chatID, text); err != nil {
if r.logger != nil {
r.logger.Warn("Failed to send treasury bot response",
zap.Error(err),
zap.String("chat_id", chatID),
zap.String("message_text", text))
}
return err
}
return nil
}
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" + CommandCancel.Slash()
}
return "You already have a pending treasury operation.\n\n" +
"Account: " + requestAccountDisplay(record) + "\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" + CommandCancel.Slash()
}
func confirmationPrompt(record *storagemodel.TreasuryRequest) string {
if record == nil {
return "Request created.\n\n" + CommandConfirm.Slash() + "\n" + CommandCancel.Slash()
}
title := "Funding request created."
if record.OperationType == storagemodel.TreasuryOperationWithdraw {
title = "Withdrawal request created."
}
return title + "\n\n" +
"Account: " + requestAccountDisplay(record) + "\n" +
"Amount: " + strings.TrimSpace(record.Amount) + " " + strings.TrimSpace(record.Currency) + "\n\n" +
confirmationCommandsMessage()
}
func welcomeMessage(profile *AccountProfile) string {
accountCode := displayAccountCode(profile, "")
currency := ""
if profile != nil {
currency = strings.ToUpper(strings.TrimSpace(profile.Currency))
}
if accountCode == "" {
accountCode = "N/A"
}
if currency == "" {
currency = "N/A"
}
return "Welcome to Sendico treasury bot.\n\nAttached account: " + accountCode + " (" + currency + ").\nUse " + CommandFund.Slash() + " to credit your account and " + CommandWithdraw.Slash() + " to debit it.\nAfter entering an amount, use " + CommandConfirm.Slash() + " or " + CommandCancel.Slash() + ".\nUse " + CommandHelp.Slash() + " for detailed usage."
}
func amountPromptMessage(operation storagemodel.TreasuryOperationType, profile *AccountProfile, fallbackAccountID string) string {
action := "fund"
if operation == storagemodel.TreasuryOperationWithdraw {
action = "withdraw"
}
accountCode := displayAccountCode(profile, fallbackAccountID)
currency := ""
if profile != nil {
currency = strings.ToUpper(strings.TrimSpace(profile.Currency))
}
if accountCode == "" {
accountCode = "N/A"
}
if currency == "" {
currency = "N/A"
}
return "Preparing to " + action + " account " + accountCode + " (" + currency + ").\n\n" + amountInputHint
}
func requestAccountDisplay(record *storagemodel.TreasuryRequest) string {
if record == nil {
return ""
}
if code := strings.TrimSpace(record.LedgerAccountCode); code != "" {
return code
}
return strings.TrimSpace(record.LedgerAccountID)
}
func displayAccountCode(profile *AccountProfile, fallbackAccountID string) string {
if profile != nil {
if code := strings.TrimSpace(profile.AccountCode); code != "" {
return code
}
if id := strings.TrimSpace(profile.AccountID); id != "" {
return id
}
}
return strings.TrimSpace(fallbackAccountID)
}
func (r *Router) resolveAccountProfile(ctx context.Context, ledgerAccountID string) *AccountProfile {
if r == nil || r.service == nil {
return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)}
}
profile, err := r.service.GetAccountProfile(ctx, ledgerAccountID)
if err != nil {
if r.logger != nil {
r.logger.Warn("Failed to resolve treasury account profile",
zap.Error(err),
zap.String("ledger_account_id", strings.TrimSpace(ledgerAccountID)))
}
return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)}
}
if profile == nil {
return &AccountProfile{AccountID: strings.TrimSpace(ledgerAccountID)}
}
if strings.TrimSpace(profile.AccountID) == "" {
profile.AccountID = strings.TrimSpace(ledgerAccountID)
}
return profile
}
func formatSeconds(value int64) string {
if value == 1 {
return "1 second"
}
return strconv.FormatInt(value, 10) + " seconds"
}

View File

@@ -0,0 +1,297 @@
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) GetAccountProfile(_ context.Context, ledgerAccountID string) (*AccountProfile, error) {
return &AccountProfile{
AccountID: ledgerAccountID,
AccountCode: ledgerAccountID,
Currency: "USD",
}, 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 TestRouterUnknownChatGetsDenied(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) != 1 {
t.Fatalf("expected one message, got %d", len(sent))
}
if sent[0] != unauthorizedChatMessage {
t.Fatalf("unexpected message: %q", sent[0])
}
}
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] != amountPromptMessage(
storagemodel.TreasuryOperationFund,
&AccountProfile{AccountID: "acct-1", AccountCode: "acct-1", Currency: "USD"},
"acct-1",
) {
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(&AccountProfile{AccountID: "acct-1", AccountCode: "acct-1", Currency: "USD"}) {
t.Fatalf("unexpected message: %q", sent[0])
}
}
func TestRouterHelpAuthorizedShowsHelp(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: "/help",
},
})
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] != helpMessage("acct-1", "USD") {
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])
}
}
func TestRouterPlainTextWithoutSession_ShowsSupportedCommands(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: "hello",
},
})
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] != supportedCommandsMessage() {
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,312 @@
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
AccountCode 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")
}
accountCode := strings.TrimSpace(account.GetLabel())
organizationRef := strings.TrimSpace(account.GetOwnerRef())
if organizationRef == "" && account.GetProviderDetails() != nil {
details := account.GetProviderDetails().AsMap()
if organizationRef == "" {
organizationRef = firstDetailValue(details, "organization_ref", "organizationRef", "org_ref")
}
if accountCode == "" {
accountCode = firstDetailValue(details, "account_code", "accountCode", "code", "ledger_account_code")
}
}
return &Account{
AccountID: accountID,
AccountCode: accountCode,
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
}
}
func firstDetailValue(values map[string]any, keys ...string) string {
if len(values) == 0 || len(keys) == 0 {
return ""
}
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if value, ok := values[key]; ok {
if text := strings.TrimSpace(fmt.Sprint(value)); text != "" {
return text
}
}
}
return ""
}

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,166 @@
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) GetAccountProfile(ctx context.Context, ledgerAccountID string) (*bot.AccountProfile, error) {
if a == nil || a.svc == nil {
return nil, merrors.Internal("treasury service unavailable")
}
profile, err := a.svc.GetAccountProfile(ctx, ledgerAccountID)
if err != nil {
return nil, err
}
if profile == nil {
return nil, nil
}
return &bot.AccountProfile{
AccountID: strings.TrimSpace(profile.AccountID),
AccountCode: strings.TrimSpace(profile.AccountCode),
Currency: strings.TrimSpace(profile.Currency),
}, nil
}
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,307 @@
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.logger.Debug("Treasury execution produced no result", zap.String("request_id", requestID))
return
}
if s.notify == nil {
s.logger.Warn("Treasury execution notifier is unavailable", zap.String("request_id", requestID))
return
}
text := executionMessage(result)
if strings.TrimSpace(text) == "" {
s.logger.Debug("Treasury execution result has no notification text",
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
return
}
chatID := strings.TrimSpace(result.Request.ChatID)
if chatID == "" {
s.logger.Warn("Treasury execution notification skipped: empty chat_id",
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)))
return
}
s.logger.Info("Sending treasury execution notification",
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
zap.String("chat_id", chatID),
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
notifyCtx := context.Background()
if ctx != nil {
notifyCtx = ctx
}
notifyCtx, notifyCancel := context.WithTimeout(notifyCtx, 15*time.Second)
defer notifyCancel()
if err := s.notify(notifyCtx, 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)),
zap.String("chat_id", chatID),
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
return
}
s.logger.Info("Treasury execution notification sent",
zap.String("request_id", strings.TrimSpace(result.Request.RequestID)),
zap.String("chat_id", chatID),
zap.String("status", strings.TrimSpace(string(result.Request.Status))))
}
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: " + requestAccountCode(request) + "\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: " + requestAccountCode(request) + "\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 ""
}
}
func requestAccountCode(request *storagemodel.TreasuryRequest) string {
if request == nil {
return ""
}
if code := strings.TrimSpace(request.LedgerAccountCode); code != "" {
return code
}
return strings.TrimSpace(request.LedgerAccountID)
}

View File

@@ -0,0 +1,457 @@
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 AccountProfile struct {
AccountID string
AccountCode string
Currency 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) GetAccountProfile(ctx context.Context, ledgerAccountID string) (*AccountProfile, error) {
if s == nil || s.ledger == nil {
return nil, merrors.Internal("treasury service unavailable")
}
ledgerAccountID = strings.TrimSpace(ledgerAccountID)
if ledgerAccountID == "" {
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
}
account, err := s.ledger.GetAccount(ctx, ledgerAccountID)
if err != nil {
return nil, err
}
if account == nil {
return nil, merrors.NoData("ledger account not found")
}
return &AccountProfile{
AccountID: ledgerAccountID,
AccountCode: resolveAccountCode(account, ledgerAccountID),
Currency: strings.ToUpper(strings.TrimSpace(account.Currency)),
}, nil
}
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,
LedgerAccountCode: resolveAccountCode(account, 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("ledger_account_code", strings.TrimSpace(record.LedgerAccountCode)),
zap.String("chat_id", strings.TrimSpace(record.ChatID)),
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),
zap.String("ledger_reference", strings.TrimSpace(record.LedgerReference)),
zap.String("error_message", strings.TrimSpace(record.ErrorMessage)),
}
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])
}
func resolveAccountCode(account *ledger.Account, fallbackAccountID string) string {
if account != nil {
if code := strings.TrimSpace(account.AccountCode); code != "" {
return code
}
if code := strings.TrimSpace(account.AccountID); code != "" {
return code
}
}
return strings.TrimSpace(fallbackAccountID)
}

View File

@@ -0,0 +1,178 @@
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 {
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" paymentsCollection = "payments"
telegramConfirmationsCollection = "telegram_confirmations" telegramConfirmationsCollection = "telegram_confirmations"
pendingConfirmationsCollection = "pending_confirmations" pendingConfirmationsCollection = "pending_confirmations"
treasuryRequestsCollection = "treasury_requests"
) )
func (*PaymentRecord) Collection() string { func (*PaymentRecord) Collection() string {
@@ -17,3 +18,7 @@ func (*TelegramConfirmation) Collection() string {
func (*PendingConfirmation) Collection() string { func (*PendingConfirmation) Collection() string {
return pendingConfirmationsCollection return pendingConfirmationsCollection
} }
func (*TreasuryRequest) Collection() string {
return treasuryRequestsCollection
}

View File

@@ -0,0 +1,51 @@
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"`
LedgerAccountCode string `bson:"ledgerAccountCode,omitempty" json:"ledger_account_code,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 payments storage.PaymentsStore
tg storage.TelegramConfirmationsStore tg storage.TelegramConfirmationsStore
pending storage.PendingConfirmationsStore pending storage.PendingConfirmationsStore
treasury storage.TreasuryRequestsStore
outbox gatewayoutbox.Store 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")) result.logger.Error("Failed to initialise pending confirmations store", zap.Error(err), zap.String("store", "pending_confirmations"))
return nil, err 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) outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db)
if err != nil { if err != nil {
result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox")) 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.payments = paymentsStore
result.tg = tgStore result.tg = tgStore
result.pending = pendingStore result.pending = pendingStore
result.treasury = treasuryStore
result.outbox = outboxStore result.outbox = outboxStore
result.logger.Info("Payment gateway MongoDB storage initialised") result.logger.Info("Payment gateway MongoDB storage initialised")
return result, nil return result, nil
@@ -99,6 +106,10 @@ func (r *Repository) PendingConfirmations() storage.PendingConfirmationsStore {
return r.pending return r.pending
} }
func (r *Repository) TreasuryRequests() storage.TreasuryRequestsStore {
return r.treasury
}
func (r *Repository) Outbox() gatewayoutbox.Store { func (r *Repository) Outbox() gatewayoutbox.Store {
return r.outbox return r.outbox
} }

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"strings" "strings"
"time"
"github.com/tech/sendico/gateway/tgsettle/storage" "github.com/tech/sendico/gateway/tgsettle/storage"
"github.com/tech/sendico/gateway/tgsettle/storage/model" "github.com/tech/sendico/gateway/tgsettle/storage/model"
@@ -12,7 +11,6 @@ import (
ri "github.com/tech/sendico/pkg/db/repository/index" ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -20,6 +18,7 @@ import (
const ( const (
paymentsCollection = "payments" paymentsCollection = "payments"
fieldIdempotencyKey = "idempotencyKey" fieldIdempotencyKey = "idempotencyKey"
fieldOperationRef = "operationRef"
) )
type Payments struct { type Payments struct {
@@ -44,6 +43,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)) logger.Error("Failed to create payments idempotency index", zap.Error(err), zap.String("index_field", fieldIdempotencyKey))
return nil, err 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{ p := &Payments{
logger: logger, logger: logger,
@@ -72,6 +79,25 @@ func (p *Payments) FindByIdempotencyKey(ctx context.Context, key string) (*model
return &result, nil 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 { func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) error {
if record == nil { if record == nil {
return merrors.InvalidArgument("payment record is nil", "record") return merrors.InvalidArgument("payment record is nil", "record")
@@ -82,6 +108,7 @@ func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) erro
record.OutgoingLeg = strings.TrimSpace(record.OutgoingLeg) record.OutgoingLeg = strings.TrimSpace(record.OutgoingLeg)
record.TargetChatID = strings.TrimSpace(record.TargetChatID) record.TargetChatID = strings.TrimSpace(record.TargetChatID)
record.IntentRef = strings.TrimSpace(record.IntentRef) record.IntentRef = strings.TrimSpace(record.IntentRef)
record.OperationRef = strings.TrimSpace(record.OperationRef)
if record.PaymentIntentID == "" { if record.PaymentIntentID == "" {
return merrors.InvalidArgument("intention reference is required", "payment_intent_ref") return merrors.InvalidArgument("intention reference is required", "payment_intent_ref")
} }
@@ -91,31 +118,26 @@ func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) erro
if record.IntentRef == "" { if record.IntentRef == "" {
return merrors.InvalidArgument("intention reference key is required", "intent_ref") return merrors.InvalidArgument("intention reference key is required", "intent_ref")
} }
now := time.Now()
if record.CreatedAt.IsZero() {
record.CreatedAt = now
}
record.UpdatedAt = now
record.ID = bson.NilObjectID
filter := repository.Filter(fieldIdempotencyKey, record.IdempotencyKey) filter := repository.Filter(fieldIdempotencyKey, record.IdempotencyKey)
existing := &model.PaymentRecord{} err := p.repo.Insert(ctx, record, filter)
err := p.repo.FindOneByFilter(ctx, filter, existing) if errors.Is(err, merrors.ErrDataConflict) {
switch { patch := repository.Patch().
case err == nil: Set(repository.Field(fieldOperationRef), record.OperationRef).
record.ID = existing.ID Set(repository.Field("paymentIntentId"), record.PaymentIntentID).
err = p.repo.Update(ctx, record) Set(repository.Field("quoteRef"), record.QuoteRef).
case errors.Is(err, merrors.ErrNoData): Set(repository.Field("intentRef"), record.IntentRef).
record.ID = bson.NilObjectID Set(repository.Field("paymentRef"), record.PaymentRef).
err = p.repo.Insert(ctx, record, filter) Set(repository.Field("outgoingLeg"), record.OutgoingLeg).
if errors.Is(err, merrors.ErrDataConflict) { Set(repository.Field("targetChatId"), record.TargetChatID).
if findErr := p.repo.FindOneByFilter(ctx, filter, existing); findErr != nil { Set(repository.Field("requestedMoney"), record.RequestedMoney).
err = findErr Set(repository.Field("executedMoney"), record.ExecutedMoney).
break Set(repository.Field("status"), record.Status).
} Set(repository.Field("failureReason"), record.FailureReason).
record.ID = existing.ID Set(repository.Field("executedAt"), record.ExecutedAt).
err = p.repo.Update(ctx, record) Set(repository.Field("expiresAt"), record.ExpiresAt).
} Set(repository.Field("expiredAt"), record.ExpiredAt)
_, err = p.repo.PatchMany(ctx, filter, patch)
} }
if err != nil { if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {

View File

@@ -13,7 +13,7 @@ import (
ri "github.com/tech/sendico/pkg/db/repository/index" ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson" mutil "github.com/tech/sendico/pkg/mutil/db"
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -86,34 +86,19 @@ func (p *PendingConfirmations) Upsert(ctx context.Context, record *model.Pending
return merrors.InvalidArgument("expires_at is required", "expires_at") return merrors.InvalidArgument("expires_at is required", "expires_at")
} }
now := time.Now()
createdAt := record.CreatedAt
if createdAt.IsZero() {
createdAt = now
}
record.UpdatedAt = now
record.CreatedAt = createdAt
filter := repository.Filter(fieldPendingRequestID, record.RequestID) filter := repository.Filter(fieldPendingRequestID, record.RequestID)
existing := &model.PendingConfirmation{} err := p.repo.Insert(ctx, record, filter)
if errors.Is(err, merrors.ErrDataConflict) {
err := p.repo.FindOneByFilter(ctx, filter, existing) patch := repository.Patch().
switch { Set(repository.Field(fieldPendingMessageID), record.MessageID).
case err == nil: Set(repository.Field("targetChatId"), record.TargetChatID).
record.ID = existing.ID Set(repository.Field("acceptedUserIds"), record.AcceptedUserIDs).
record.CreatedAt = existing.CreatedAt Set(repository.Field("requestedMoney"), record.RequestedMoney).
err = p.repo.Update(ctx, record) Set(repository.Field("sourceService"), record.SourceService).
case errors.Is(err, merrors.ErrNoData): Set(repository.Field("rail"), record.Rail).
record.ID = bson.NilObjectID Set(repository.Field("clarified"), record.Clarified).
err = p.repo.Insert(ctx, record, filter) Set(repository.Field(fieldPendingExpiresAt), record.ExpiresAt)
if errors.Is(err, merrors.ErrDataConflict) { _, err = p.repo.PatchMany(ctx, filter, patch)
if findErr := p.repo.FindOneByFilter(ctx, filter, existing); findErr != nil {
err = findErr
break
}
record.ID = existing.ID
record.CreatedAt = existing.CreatedAt
err = p.repo.Update(ctx, record)
}
} }
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
p.logger.Warn("Failed to upsert pending confirmation", zap.Error(err), zap.String("request_id", record.RequestID)) p.logger.Warn("Failed to upsert pending confirmation", zap.Error(err), zap.String("request_id", record.RequestID))
@@ -201,7 +186,7 @@ func (p *PendingConfirmations) DeleteByRequestID(ctx context.Context, requestID
return p.repo.DeleteMany(ctx, repository.Filter(fieldPendingRequestID, requestID)) return p.repo.DeleteMany(ctx, repository.Filter(fieldPendingRequestID, requestID))
} }
func (p *PendingConfirmations) ListExpired(ctx context.Context, now time.Time, limit int64) ([]*model.PendingConfirmation, error) { func (p *PendingConfirmations) ListExpired(ctx context.Context, now time.Time, limit int64) ([]model.PendingConfirmation, error) {
if limit <= 0 { if limit <= 0 {
limit = 100 limit = 100
} }
@@ -210,19 +195,11 @@ func (p *PendingConfirmations) ListExpired(ctx context.Context, now time.Time, l
Sort(repository.Field(fieldPendingExpiresAt), true). Sort(repository.Field(fieldPendingExpiresAt), true).
Limit(&limit) Limit(&limit)
result := make([]*model.PendingConfirmation, 0) items, err := mutil.GetObjects[model.PendingConfirmation](ctx, p.logger, query, nil, p.repo)
err := p.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
next := &model.PendingConfirmation{}
if err := cur.Decode(next); err != nil {
return err
}
result = append(result, next)
return nil
})
if err != nil && !errors.Is(err, merrors.ErrNoData) { if err != nil && !errors.Is(err, merrors.ErrNoData) {
return nil, err return nil, err
} }
return result, nil return items, nil
} }
var _ storage.PendingConfirmationsStore = (*PendingConfirmations)(nil) var _ storage.PendingConfirmationsStore = (*PendingConfirmations)(nil)

View File

@@ -12,7 +12,6 @@ import (
ri "github.com/tech/sendico/pkg/db/repository/index" ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -67,24 +66,14 @@ func (t *TelegramConfirmations) Upsert(ctx context.Context, record *model.Telegr
record.ReceivedAt = time.Now() record.ReceivedAt = time.Now()
} }
filter := repository.Filter(fieldRequestID, record.RequestID) filter := repository.Filter(fieldRequestID, record.RequestID)
existing := &model.TelegramConfirmation{} err := t.repo.Insert(ctx, record, filter)
if errors.Is(err, merrors.ErrDataConflict) {
err := t.repo.FindOneByFilter(ctx, filter, existing) patch := repository.Patch().
switch { Set(repository.Field("paymentIntentId"), record.PaymentIntentID).
case err == nil: Set(repository.Field("quoteRef"), record.QuoteRef).
record.ID = existing.ID Set(repository.Field("rawReply"), record.RawReply).
err = t.repo.Update(ctx, record) Set(repository.Field("receivedAt"), record.ReceivedAt)
case errors.Is(err, merrors.ErrNoData): _, err = t.repo.PatchMany(ctx, filter, patch)
record.ID = bson.NilObjectID
err = t.repo.Insert(ctx, record, filter)
if errors.Is(err, merrors.ErrDataConflict) {
if findErr := t.repo.FindOneByFilter(ctx, filter, existing); findErr != nil {
err = findErr
break
}
record.ID = existing.ID
err = t.repo.Update(ctx, record)
}
} }
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
fields := []zap.Field{zap.String("request_id", record.RequestID)} fields := []zap.Field{zap.String("request_id", record.RequestID)}

View File

@@ -0,0 +1,374 @@
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"
mutil "github.com/tech/sendico/pkg/mutil/db"
"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.LedgerAccountCode = strings.TrimSpace(record.LedgerAccountCode)
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")
}
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
}
t.logger.Info("Treasury request created",
zap.String("request_id", record.RequestID),
zap.String("telegram_user_id", record.TelegramUserID),
zap.String("chat_id", record.ChatID),
zap.String("ledger_account_id", record.LedgerAccountID),
zap.String("ledger_account_code", record.LedgerAccountCode),
zap.String("operation_type", strings.TrimSpace(string(record.OperationType))),
zap.String("status", strings.TrimSpace(string(record.Status))),
zap.String("amount", record.Amount),
zap.String("currency", record.Currency))
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) {
t.logger.Debug("Treasury request not found", zap.String("request_id", requestID))
return nil, nil
}
if err != nil {
t.logger.Warn("Failed to load treasury request", zap.Error(err), zap.String("request_id", requestID))
return nil, err
}
t.logger.Debug("Treasury request loaded",
zap.String("request_id", requestID),
zap.String("status", strings.TrimSpace(string(result.Status))),
zap.String("ledger_account_id", strings.TrimSpace(result.LedgerAccountID)))
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) {
t.logger.Debug("Active treasury request not found", zap.String("ledger_account_id", ledgerAccountID))
return nil, nil
}
if err != nil {
t.logger.Warn("Failed to load active treasury request", zap.Error(err), zap.String("ledger_account_id", ledgerAccountID))
return nil, err
}
t.logger.Debug("Active treasury request loaded",
zap.String("request_id", strings.TrimSpace(result.RequestID)),
zap.String("ledger_account_id", ledgerAccountID),
zap.String("status", strings.TrimSpace(string(result.Status))))
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, err := mutil.GetObjects[model.TreasuryRequest](ctx, t.logger, query, nil, t.repo)
if err != nil && !errors.Is(err, merrors.ErrNoData) {
t.logger.Warn("Failed to list due treasury requests",
zap.Error(err),
zap.Any("statuses", statusValues),
zap.Time("scheduled_before", now),
zap.Int64("limit", limit))
return nil, err
}
t.logger.Debug("Due treasury requests loaded",
zap.Any("statuses", statusValues),
zap.Time("scheduled_before", now),
zap.Int64("limit", limit),
zap.Int("count", len(result)))
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))
updated, err := t.repo.PatchMany(ctx, repository.Filter(fieldTreasuryRequestID, requestID).And(
repository.Filter(fieldTreasuryStatus, string(model.TreasuryRequestStatusScheduled)),
), patch)
if err != nil {
t.logger.Warn("Failed to claim scheduled treasury request", zap.Error(err), zap.String("request_id", requestID))
return false, err
}
if updated > 0 {
t.logger.Info("Scheduled treasury request claimed", zap.String("request_id", requestID))
} else {
t.logger.Debug("Scheduled treasury request claim skipped", zap.String("request_id", requestID))
}
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)
record.TelegramUserID = strings.TrimSpace(record.TelegramUserID)
record.LedgerAccountID = strings.TrimSpace(record.LedgerAccountID)
record.LedgerAccountCode = strings.TrimSpace(record.LedgerAccountCode)
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")
}
existing, err := t.FindByRequestID(ctx, record.RequestID)
if err != nil {
return err
}
if existing == nil {
return merrors.NoData("treasury request not found")
}
patch := repository.Patch().
Set(repository.Field("operationType"), record.OperationType).
Set(repository.Field("telegramUserId"), record.TelegramUserID).
Set(repository.Field("ledgerAccountId"), record.LedgerAccountID).
Set(repository.Field("ledgerAccountCode"), record.LedgerAccountCode).
Set(repository.Field("organizationRef"), record.OrganizationRef).
Set(repository.Field("chatId"), record.ChatID).
Set(repository.Field("amount"), record.Amount).
Set(repository.Field("currency"), record.Currency).
Set(repository.Field(fieldTreasuryStatus), record.Status).
Set(repository.Field("confirmedAt"), record.ConfirmedAt).
Set(repository.Field("scheduledAt"), record.ScheduledAt).
Set(repository.Field("executedAt"), record.ExecutedAt).
Set(repository.Field("cancelledAt"), record.CancelledAt).
Set(repository.Field(fieldTreasuryIdempotencyKey), record.IdempotencyKey).
Set(repository.Field("ledgerReference"), record.LedgerReference).
Set(repository.Field("errorMessage"), record.ErrorMessage).
Set(repository.Field(fieldTreasuryActive), record.Active)
if _, err := t.repo.PatchMany(ctx, repository.Filter(fieldTreasuryRequestID, record.RequestID), patch); 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
}
t.logger.Info("Treasury request updated",
zap.String("request_id", record.RequestID),
zap.String("telegram_user_id", strings.TrimSpace(record.TelegramUserID)),
zap.String("chat_id", strings.TrimSpace(record.ChatID)),
zap.String("ledger_account_id", strings.TrimSpace(record.LedgerAccountID)),
zap.String("ledger_account_code", strings.TrimSpace(record.LedgerAccountCode)),
zap.String("operation_type", strings.TrimSpace(string(record.OperationType))),
zap.String("status", strings.TrimSpace(string(record.Status))),
zap.String("amount", strings.TrimSpace(record.Amount)),
zap.String("currency", strings.TrimSpace(record.Currency)),
zap.String("error_message", strings.TrimSpace(record.ErrorMessage)))
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, err := mutil.GetObjects[model.TreasuryRequest](ctx, t.logger, query, nil, t.repo)
if err != nil && !errors.Is(err, merrors.ErrNoData) {
t.logger.Warn("Failed to list treasury requests by account and statuses",
zap.Error(err),
zap.String("ledger_account_id", ledgerAccountID),
zap.Any("statuses", statusValues),
zap.Time("day_start", dayStart),
zap.Time("day_end", dayEnd))
return nil, err
}
t.logger.Debug("Treasury requests loaded by account and statuses",
zap.String("ledger_account_id", ledgerAccountID),
zap.Any("statuses", statusValues),
zap.Time("day_start", dayStart),
zap.Time("day_end", dayEnd),
zap.Int("count", len(result)))
return result, nil
}
var _ storage.TreasuryRequestsStore = (*TreasuryRequests)(nil)

View File

@@ -14,10 +14,12 @@ type Repository interface {
Payments() PaymentsStore Payments() PaymentsStore
TelegramConfirmations() TelegramConfirmationsStore TelegramConfirmations() TelegramConfirmationsStore
PendingConfirmations() PendingConfirmationsStore PendingConfirmations() PendingConfirmationsStore
TreasuryRequests() TreasuryRequestsStore
} }
type PaymentsStore interface { type PaymentsStore interface {
FindByIdempotencyKey(ctx context.Context, key string) (*model.PaymentRecord, error) 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 Upsert(ctx context.Context, record *model.PaymentRecord) error
} }
@@ -32,5 +34,15 @@ type PendingConfirmationsStore interface {
MarkClarified(ctx context.Context, requestID string) error MarkClarified(ctx context.Context, requestID string) error
AttachMessage(ctx context.Context, requestID string, messageID string) error AttachMessage(ctx context.Context, requestID string, messageID string) error
DeleteByRequestID(ctx context.Context, requestID string) error DeleteByRequestID(ctx context.Context, requestID string) error
ListExpired(ctx context.Context, now time.Time, limit int64) ([]*model.PendingConfirmation, 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/internal/appversion"
"github.com/tech/sendico/gateway/tron/shared" "github.com/tech/sendico/gateway/tron/shared"
tronstoragemodel "github.com/tech/sendico/gateway/tron/storage/model"
chainasset "github.com/tech/sendico/pkg/chain" chainasset "github.com/tech/sendico/pkg/chain"
"github.com/tech/sendico/pkg/connector/params" "github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
@@ -17,6 +18,7 @@ import (
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
) )
const chainConnectorID = "chain" const chainConnectorID = "chain"
@@ -293,11 +295,21 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" { if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
return nil, merrors.InvalidArgument("get_operation: operation_id is required") 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 { if err != nil {
return nil, err 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) { 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 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 { func gasTopUpResult(amount *moneyv1.Money, capHit bool, transferRef string) *structpb.Struct {
payload := map[string]interface{}{ payload := map[string]interface{}{
"cap_hit": capHit, "cap_hit": capHit,
@@ -518,18 +585,33 @@ func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation
return nil return nil
} }
op := &connectorv1.Operation{ op := &connectorv1.Operation{
OperationId: strings.TrimSpace(transfer.GetTransferRef()), OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Type: connectorv1.OperationType_TRANSFER, Type: connectorv1.OperationType_TRANSFER,
Status: chainTransferStatusToOperation(transfer.GetStatus()), Status: chainTransferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(), Money: transfer.GetRequestedAmount(),
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()), ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
CreatedAt: transfer.GetCreatedAt(), IntentRef: strings.TrimSpace(transfer.GetIntentRef()),
UpdatedAt: transfer.GetUpdatedAt(), OperationRef: strings.TrimSpace(transfer.GetOperationRef()),
CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chainConnectorID, ConnectorId: chainConnectorID,
AccountId: strings.TrimSpace(transfer.GetSourceWalletRef()), 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 { if dest := transfer.GetDestination(); dest != nil {
switch d := dest.GetDestination().(type) { switch d := dest.GetDestination().(type) {
case *chainv1.TransferDestination_ManagedWalletRef: case *chainv1.TransferDestination_ManagedWalletRef:
@@ -629,6 +711,17 @@ func operationAccountID(party *connectorv1.OperationParty) string {
return "" 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 { func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
err := &connectorv1.ConnectorError{ err := &connectorv1.ConnectorError{
Code: code, Code: code,

View File

@@ -554,6 +554,32 @@ func (t *inMemoryTransfers) Get(ctx context.Context, transferRef string) (*model
return transfer, nil 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) { func (t *inMemoryTransfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() 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}}, Keys: []ri.Key{{Field: "transferRef", Sort: ri.Asc}},
Unique: true, Unique: true,
}, },
{
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}, {Field: "operationRef", Sort: ri.Asc}},
},
{ {
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}}, Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}},
Unique: true, Unique: true,
@@ -110,6 +113,25 @@ func (t *Transfers) Get(ctx context.Context, transferRef string) (*model.Transfe
return transfer, nil 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) { func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
query := repository.Query() query := repository.Query()
if src := strings.TrimSpace(filter.SourceWalletRef); src != "" { if src := strings.TrimSpace(filter.SourceWalletRef); src != "" {

View File

@@ -42,6 +42,7 @@ type WalletsStore interface {
type TransfersStore interface { type TransfersStore interface {
Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error) Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error)
Get(ctx context.Context, transferRef string) (*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) 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) 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 modelRole account_role.AccountRole
} }
const defaultLedgerAccountName = "Ledger account"
// validateCreateAccountInput validates and normalizes all fields from the request. // validateCreateAccountInput validates and normalizes all fields from the request.
func validateCreateAccountInput(req *ledgerv1.CreateAccountRequest) (createAccountParams, error) { func validateCreateAccountInput(req *ledgerv1.CreateAccountRequest) (createAccountParams, error) {
if req == nil { if req == nil {
@@ -88,7 +90,17 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
return nil, err 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) { if isRequiredTopologyRole(p.modelRole) {
return s.resolveTopologyAccount(ctx, p.orgRef, p.currency, 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 { if len(metadata) == 0 {
metadata = nil metadata = nil
} }
describable := describableFromProto(req.GetDescribable()) describable := ensureDefaultLedgerAccountName(describableFromProto(req.GetDescribable()))
const maxCreateAttempts = 3 const maxCreateAttempts = 3
for attempt := 0; attempt < maxCreateAttempts; attempt++ { 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 return &ledgerv1.CreateAccountResponse{Account: toProtoAccount(account)}, nil
} }
if errors.Is(err, merrors.ErrDataConflict) { if errors.Is(err, merrors.ErrDataConflict) && attempt < maxCreateAttempts-1 {
existing, lookupErr := s.storage.Accounts().GetByRole(ctx, p.orgRef, p.currency, p.modelRole) continue
if lookupErr == nil && existing != nil {
recordAccountOperation("create", "success")
return &ledgerv1.CreateAccountResponse{Account: toProtoAccount(existing)}, nil
}
if attempt < maxCreateAttempts-1 {
continue
}
} }
recordAccountOperation("create", "error") 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 { func describableToProto(desc pmodel.Describable) *describablev1.Describable {
name := strings.TrimSpace(desc.Name) name := strings.TrimSpace(desc.Name)
var description *string var description *string

View File

@@ -13,6 +13,7 @@ import (
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
pmodel "github.com/tech/sendico/pkg/model" pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/model/account_role" "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" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
) )
@@ -184,12 +185,15 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
// default role // default role
require.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, resp.Account.Role) require.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, resp.Account.Role)
require.Equal(t, "USD", resp.Account.Currency) 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 // Expect: required topology roles + dedicated operating account
require.Len(t, accountStore.created, 5) require.Len(t, accountStore.created, 6)
var settlement *pmodel.LedgerAccount var settlement *pmodel.LedgerAccount
var operating *pmodel.LedgerAccount var operating *pmodel.LedgerAccount
var operatingCount int
roles := make(map[account_role.AccountRole]bool) roles := make(map[account_role.AccountRole]bool)
for _, acc := range accountStore.created { for _, acc := range accountStore.created {
@@ -199,6 +203,7 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
settlement = acc settlement = acc
} }
if acc.Role == account_role.AccountRoleOperating { if acc.Role == account_role.AccountRoleOperating {
operatingCount++
operating = acc operating = acc
} }
@@ -212,12 +217,13 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
require.NotNil(t, settlement) require.NotNil(t, settlement)
require.NotNil(t, operating) require.NotNil(t, operating)
require.Equal(t, 2, operatingCount)
for _, role := range RequiredRolesV1 { for _, role := range RequiredRolesV1 {
require.True(t, roles[role]) 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.AccountCode, resp.Account.AccountCode)
require.Equal(t, operating.GetID().Hex(), resp.Account.LedgerAccountRef) 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"]) 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) { func TestCreateAccountResponder_RetriesOnConflict(t *testing.T) {
t.Parallel() 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) account, err := s.storage.Accounts().GetByRole(ctx, orgRef, normalizedCurrency, role)
if err == nil { 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), s.logger.Warn("Failed to resolve ledger account by role", zap.Error(err),
mzap.ObjRef("organization_ref", orgRef), zap.String("currency", normalizedCurrency), mzap.ObjRef("organization_ref", orgRef), zap.String("currency", normalizedCurrency),
zap.String("role", string(role))) zap.String("role", string(role)))
@@ -105,6 +111,13 @@ func (s *Service) ensureRoleAccount(ctx context.Context, orgRef bson.ObjectID, c
return account, nil 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 { func newSystemAccount(orgRef bson.ObjectID, currency string, role account_role.AccountRole) *pmodel.LedgerAccount {
ref := bson.NewObjectID() ref := bson.NewObjectID()
account := &pmodel.LedgerAccount{ account := &pmodel.LedgerAccount{

View File

@@ -3,6 +3,7 @@ package store
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strings" "strings"
"github.com/tech/sendico/ledger/storage" "github.com/tech/sendico/ledger/storage"
@@ -24,6 +25,28 @@ type accountsStore struct {
repo repository.Repository 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) { func NewAccounts(logger mlogger.Logger, db *mongo.Database) (storage.AccountsStore, error) {
repo := repository.CreateMongoRepository(db, mservice.LedgerAccounts) repo := repository.CreateMongoRepository(db, mservice.LedgerAccounts)
@@ -41,21 +64,45 @@ func NewAccounts(logger mlogger.Logger, db *mongo.Database) (storage.AccountsSto
return nil, err return nil, err
} }
// Create compound index on organizationRef + currency + role (unique) // Keep role uniqueness for non-operating organization accounts.
roleIndex := &ri.Definition{ // 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{ Keys: []ri.Key{
{Field: "organizationRef", Sort: ri.Asc}, {Field: "organizationRef", Sort: ri.Asc},
{Field: "currency", Sort: ri.Asc}, {Field: "currency", Sort: ri.Asc},
{Field: "role", Sort: ri.Asc}, {Field: "role", Sort: ri.Asc},
{Field: "metadata.system", Sort: ri.Asc},
}, },
Unique: true, Unique: true,
PartialFilter: repository.Filter( Name: orgCurrencyRoleSystemOperatingName,
"scope", PartialFilter: repository.Query().
pkm.LedgerAccountScopeOrganization, 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 { if err := repo.CreateIndex(systemOperatingRoleIndex); err != nil {
logger.Error("Failed to ensure accounts role index", zap.Error(err)) logger.Error("Failed to ensure system operating role index", zap.Error(err))
return nil, 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") return nil, merrors.InvalidArgument("accountsStore: empty role")
} }
result := &pkm.LedgerAccount{}
limit := int64(1) 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(). query := repository.Query().
Filter(repository.Field("organizationRef"), orgRef). Filter(repository.Field("organizationRef"), orgRef).
Filter(repository.Field("currency"), currency). Filter(repository.Field("currency"), currency).
Filter(repository.Field("role"), role). Filter(repository.Field("role"), role).
Filter(repository.Field("scope"), pkm.LedgerAccountScopeOrganization).
Limit(&limit) Limit(&limit)
result := &pkm.LedgerAccount{}
if err := a.repo.FindOneByFilter(ctx, query, result); err != nil { if err := a.repo.FindOneByFilter(ctx, query, result); err != nil {
if errors.Is(err, merrors.ErrNoData) { if errors.Is(err, merrors.ErrNoData) {
a.logger.Debug("Account not found by role", zap.String("currency", currency), a.logger.Debug("Account not found by role", zap.String("currency", currency),

View File

@@ -2,6 +2,9 @@ package orchestrator
import ( import (
"context" "context"
"fmt"
"github.com/shopspring/decimal"
chainclient "github.com/tech/sendico/gateway/chain/client"
"github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/discovery"
"strings" "strings"
@@ -48,7 +51,7 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
if err != nil { if err != nil {
return nil, err return nil, err
} }
amount, err := sourceAmount(req.Payment) amount, err := sourceAmount(req.Payment, action)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -90,6 +93,12 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
return nil, refsErr return nil, refsErr
} }
step.ExternalRefs = refs 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.State = agg.StepStateCompleted
step.FailureCode = "" step.FailureCode = ""
step.FailureMsg = "" step.FailureMsg = ""
@@ -161,11 +170,24 @@ func sourceManagedWalletRef(payment *agg.Payment) (string, error) {
return ref, nil 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 { if payment == nil {
return nil, merrors.InvalidArgument("crypto send: payment is required") 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 { if money == nil {
return nil, merrors.InvalidArgument("crypto send: source amount is required") return nil, merrors.InvalidArgument("crypto send: source amount is required")
} }
@@ -180,6 +202,64 @@ func sourceAmount(payment *agg.Payment) (*moneyv1.Money, error) {
}, nil }, 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 { func effectiveSourceAmount(payment *agg.Payment) *paymenttypes.Money {
if payment == nil { if payment == nil {
return nil return nil
@@ -190,6 +270,77 @@ func effectiveSourceAmount(payment *agg.Payment) *paymenttypes.Money {
return payment.IntentSnapshot.Amount 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) { func (e *gatewayCryptoExecutor) resolveDestination(payment *agg.Payment, action model.RailOperation) (*chainv1.TransferDestination, error) {
if payment == nil { if payment == nil {
return nil, merrors.InvalidArgument("crypto send: payment is required") 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 { type fakeGatewayInvokeResolver struct {
lastInvokeURI string lastInvokeURI string
client chainclient.Client client chainclient.Client

View File

@@ -104,9 +104,12 @@ func (s *Service) onPaymentGatewayExecution(ctx context.Context, msg *pmodel.Pay
event, ok := buildGatewayExecutionEvent(payment, msg) event, ok := buildGatewayExecutionEvent(payment, msg)
if !ok { if !ok {
s.logger.Debug("Skipping payment gateway execution event with unsupported status", s.logger.Debug("Dropping payment gateway execution event",
zap.String("payment_ref", paymentRef), zap.String("payment_ref", paymentRef),
zap.String("status", strings.TrimSpace(string(msg.Status))), zap.String("status", strings.TrimSpace(string(msg.Status))),
zap.String("operation_ref", strings.TrimSpace(msg.OperationRef)),
zap.String("transfer_ref", strings.TrimSpace(msg.TransferRef)),
zap.String("drop_reason", gatewayExecutionDropReason(payment, msg)),
) )
return nil return nil
} }
@@ -138,9 +141,15 @@ func buildGatewayExecutionEvent(payment *agg.Payment, msg *pmodel.PaymentGateway
return nil, false return nil, false
} }
stepRef, gatewayInstanceID := matchExecutionStep(payment, msg)
operationRef := strings.TrimSpace(msg.OperationRef) operationRef := strings.TrimSpace(msg.OperationRef)
transferRef := strings.TrimSpace(msg.TransferRef) transferRef := strings.TrimSpace(msg.TransferRef)
stepRef, gatewayInstanceID, matched := matchExecutionStep(payment, msg)
// Drop unmatched events that include correlation refs. This prevents
// unrelated gateway events (for the same payment_ref) from being applied to
// a running observe step via fallback inference.
if !matched && (operationRef != "" || transferRef != "") {
return nil, false
}
if stepRef == "" && operationRef == "" && transferRef == "" { if stepRef == "" && operationRef == "" && transferRef == "" {
return nil, false return nil, false
} }
@@ -185,33 +194,58 @@ func mapGatewayExecutionStatus(status rail.OperationResult) (erecon.GatewayStatu
} }
} }
func matchExecutionStep(payment *agg.Payment, msg *pmodel.PaymentGatewayExecution) (stepRef string, gatewayInstanceID string) { func matchExecutionStep(payment *agg.Payment, msg *pmodel.PaymentGatewayExecution) (stepRef string, gatewayInstanceID string, matched bool) {
if payment == nil || msg == nil { if payment == nil || msg == nil {
return "", "" return "", "", false
} }
transferRef := strings.TrimSpace(msg.TransferRef) transferRef := strings.TrimSpace(msg.TransferRef)
if transferRef != "" { if transferRef != "" {
if stepRef, gatewayInstanceID, ok := findStepByExternalRef(payment, erecon.ExternalRefKindTransfer, transferRef); ok { if stepRef, gatewayInstanceID, ok := findStepByExternalRef(payment, erecon.ExternalRefKindTransfer, transferRef); ok {
return stepRef, gatewayInstanceID return stepRef, gatewayInstanceID, true
} }
if stepRef, gatewayInstanceID, ok := findStepByExternalRef(payment, erecon.ExternalRefKindCardPayout, transferRef); ok { if stepRef, gatewayInstanceID, ok := findStepByExternalRef(payment, erecon.ExternalRefKindCardPayout, transferRef); ok {
return stepRef, gatewayInstanceID return stepRef, gatewayInstanceID, true
} }
} }
operationRef := strings.TrimSpace(msg.OperationRef) operationRef := strings.TrimSpace(msg.OperationRef)
if operationRef != "" { if operationRef != "" {
if stepRef, gatewayInstanceID, ok := findStepByExternalRef(payment, erecon.ExternalRefKindOperation, operationRef); ok { if stepRef, gatewayInstanceID, ok := findStepByExternalRef(payment, erecon.ExternalRefKindOperation, operationRef); ok {
return stepRef, gatewayInstanceID return stepRef, gatewayInstanceID, true
} }
} }
// Fallback inference is allowed only when the event has no refs at all.
// If refs are present but unmatched, treat it as unrelated and skip.
if transferRef != "" || operationRef != "" {
return "", "", false
}
candidates := runningObserveCandidates(payment) candidates := runningObserveCandidates(payment)
if len(candidates) == 1 { if len(candidates) == 1 {
return candidates[0].stepRef, candidates[0].gatewayInstanceID return candidates[0].stepRef, candidates[0].gatewayInstanceID, true
} }
return "", "" return "", "", false
}
func gatewayExecutionDropReason(payment *agg.Payment, msg *pmodel.PaymentGatewayExecution) string {
if msg == nil {
return "nil_event"
}
if _, ok := mapGatewayExecutionStatus(msg.Status); !ok {
return "unsupported_status"
}
operationRef := strings.TrimSpace(msg.OperationRef)
transferRef := strings.TrimSpace(msg.TransferRef)
_, _, matched := matchExecutionStep(payment, msg)
if (operationRef != "" || transferRef != "") && !matched {
return "unmatched_refs"
}
if operationRef == "" && transferRef == "" && !matched {
return "missing_refs_and_no_observe_candidate"
}
return "not_accepted"
} }
func findStepByExternalRef(payment *agg.Payment, kind, ref string) (stepRef string, gatewayInstanceID string, ok bool) { func findStepByExternalRef(payment *agg.Payment, kind, ref string) (stepRef string, gatewayInstanceID string, ok bool) {

View File

@@ -134,6 +134,73 @@ func TestBuildGatewayExecutionEvent_MatchesCardObserveByCardPayoutRef(t *testing
} }
} }
func TestBuildGatewayExecutionEvent_SkipsUnmatchedRefsEvenWithSingleRunningObserve(t *testing.T) {
orgID := bson.NewObjectID()
payment := &agg.Payment{
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
PaymentRef: "payment-settlement-1",
StepExecutions: []agg.StepExecution{
{
StepRef: "hop_2_settlement_observe",
StepCode: "hop.2.settlement.observe",
State: agg.StepStateRunning,
ExternalRefs: []agg.ExternalRef{
{
GatewayInstanceID: "payment_gateway_settlement",
Kind: erecon.ExternalRefKindTransfer,
Ref: "settlement-transfer-ref",
},
},
},
},
}
// This models a foreign success event (e.g. crypto fee transfer) that should
// never close settlement observe by "single running observe" fallback.
_, ok := buildGatewayExecutionEvent(payment, &pm.PaymentGatewayExecution{
PaymentRef: payment.PaymentRef,
Status: rail.OperationResultSuccess,
OperationRef: "payment-1:hop_1_crypto_send:fee",
TransferRef: "fee-transfer-ref",
})
if ok {
t.Fatal("expected unmatched gateway execution event to be skipped")
}
}
func TestBuildGatewayExecutionEvent_AllowsSingleRunningObserveFallbackWhenRefsMissing(t *testing.T) {
orgID := bson.NewObjectID()
payment := &agg.Payment{
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
PaymentRef: "payment-settlement-2",
StepExecutions: []agg.StepExecution{
{
StepRef: "hop_2_settlement_observe",
StepCode: "hop.2.settlement.observe",
State: agg.StepStateRunning,
ExternalRefs: []agg.ExternalRef{
{
GatewayInstanceID: "payment_gateway_settlement",
Kind: erecon.ExternalRefKindTransfer,
Ref: "settlement-transfer-ref",
},
},
},
},
}
event, ok := buildGatewayExecutionEvent(payment, &pm.PaymentGatewayExecution{
PaymentRef: payment.PaymentRef,
Status: rail.OperationResultSuccess,
})
if !ok {
t.Fatal("expected gateway execution event to be accepted")
}
if got, want := event.StepRef, "hop_2_settlement_observe"; got != want {
t.Fatalf("step_ref mismatch: got=%q want=%q", got, want)
}
}
func TestOnPaymentGatewayExecution_ReconcilesUsingGlobalPaymentLookup(t *testing.T) { func TestOnPaymentGatewayExecution_ReconcilesUsingGlobalPaymentLookup(t *testing.T) {
orgID := bson.NewObjectID() orgID := bson.NewObjectID()
payment := &agg.Payment{ payment := &agg.Payment{

View File

@@ -43,6 +43,11 @@ service DocumentService {
// generates it lazily, stores it, and returns it. // generates it lazily, stores it, and returns it.
rpc GetDocument(GetDocumentRequest) rpc GetDocument(GetDocumentRequest)
returns (GetDocumentResponse); 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" // MIME type, typically "application/pdf"
string mime_type = 3; 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

@@ -34,6 +34,8 @@ FROM alpine:latest AS runtime
RUN apk add --no-cache ca-certificates tzdata wget RUN apk add --no-cache ca-certificates tzdata wget
WORKDIR /app WORKDIR /app
COPY api/billing/documents/config.yml /app/config.yml COPY api/billing/documents/config.yml /app/config.yml
COPY api/billing/documents/templates /app/templates
COPY api/billing/documents/assets /app/assets
COPY --from=build /out/billing-documents /app/billing-documents COPY --from=build /out/billing-documents /app/billing-documents
EXPOSE 50061 9409 EXPOSE 50061 9409
ENTRYPOINT ["/app/billing-documents"] ENTRYPOINT ["/app/billing-documents"]

View File

@@ -0,0 +1,34 @@
import 'package:json_annotation/json_annotation.dart';
part 'operation.g.dart';
@JsonSerializable()
class PaymentOperationDTO {
final String? stepRef;
final String? operationRef;
final String? gateway;
final String? code;
final String? state;
final String? label;
final String? failureCode;
final String? failureReason;
final String? startedAt;
final String? completedAt;
const PaymentOperationDTO({
this.stepRef,
this.operationRef,
this.gateway,
this.code,
this.state,
this.label,
this.failureCode,
this.failureReason,
this.startedAt,
this.completedAt,
});
factory PaymentOperationDTO.fromJson(Map<String, dynamic> json) =>
_$PaymentOperationDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentOperationDTOToJson(this);
}

View File

@@ -1,5 +1,6 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/operation.dart';
import 'package:pshared/data/dto/payment/payment_quote.dart'; import 'package:pshared/data/dto/payment/payment_quote.dart';
part 'payment.g.dart'; part 'payment.g.dart';
@@ -12,6 +13,7 @@ class PaymentDTO {
final String? state; final String? state;
final String? failureCode; final String? failureCode;
final String? failureReason; final String? failureReason;
final List<PaymentOperationDTO> operations;
final PaymentQuoteDTO? lastQuote; final PaymentQuoteDTO? lastQuote;
final Map<String, String>? metadata; final Map<String, String>? metadata;
final String? createdAt; final String? createdAt;
@@ -22,6 +24,7 @@ class PaymentDTO {
this.state, this.state,
this.failureCode, this.failureCode,
this.failureReason, this.failureReason,
this.operations = const <PaymentOperationDTO>[],
this.lastQuote, this.lastQuote,
this.metadata, this.metadata,
this.createdAt, this.createdAt,

View File

@@ -9,22 +9,33 @@ import 'package:pshared/models/ledger/account.dart';
extension LedgerAccountDTOMapper on LedgerAccountDTO { extension LedgerAccountDTOMapper on LedgerAccountDTO {
LedgerAccount toDomain() => LedgerAccount( LedgerAccount toDomain() {
ledgerAccountRef: ledgerAccountRef, final mappedDescribable = describable?.toDomain();
organizationRef: organizationRef, final fallbackName = metadata?['name']?.trim() ?? '';
ownerRef: ownerRef, final name = mappedDescribable?.name.trim().isNotEmpty == true
accountCode: accountCode, ? mappedDescribable!.name
accountType: accountType.toDomain(), : fallbackName;
currency: currency,
status: status.toDomain(), return LedgerAccount(
allowNegative: allowNegative, ledgerAccountRef: ledgerAccountRef,
role: role.toDomain(), organizationRef: organizationRef,
metadata: metadata, ownerRef: ownerRef,
createdAt: createdAt, accountCode: accountCode,
updatedAt: updatedAt, accountType: accountType.toDomain(),
describable: describable?.toDomain() ?? newDescribable(name: '', description: null), currency: currency,
balance: balance?.toDomain(), status: status.toDomain(),
); allowNegative: allowNegative,
role: role.toDomain(),
metadata: metadata,
createdAt: createdAt,
updatedAt: updatedAt,
describable: newDescribable(
name: name,
description: mappedDescribable?.description,
),
balance: balance?.toDomain(),
);
}
} }
extension LedgerAccountModelMapper on LedgerAccount { extension LedgerAccountModelMapper on LedgerAccount {

View File

@@ -0,0 +1,39 @@
import 'package:pshared/data/dto/payment/operation.dart';
import 'package:pshared/models/payment/execution_operation.dart';
extension PaymentOperationDTOMapper on PaymentOperationDTO {
PaymentExecutionOperation toDomain() => PaymentExecutionOperation(
stepRef: stepRef,
operationRef: operationRef,
gateway: gateway,
code: code,
state: state,
label: label,
failureCode: failureCode,
failureReason: failureReason,
startedAt: _parseDateTime(startedAt),
completedAt: _parseDateTime(completedAt),
);
}
extension PaymentExecutionOperationMapper on PaymentExecutionOperation {
PaymentOperationDTO toDTO() => PaymentOperationDTO(
stepRef: stepRef,
operationRef: operationRef,
gateway: gateway,
code: code,
state: state,
label: label,
failureCode: failureCode,
failureReason: failureReason,
startedAt: startedAt?.toUtc().toIso8601String(),
completedAt: completedAt?.toUtc().toIso8601String(),
);
}
DateTime? _parseDateTime(String? value) {
final normalized = value?.trim();
if (normalized == null || normalized.isEmpty) return null;
return DateTime.tryParse(normalized);
}

View File

@@ -1,8 +1,10 @@
import 'package:pshared/data/dto/payment/payment.dart'; import 'package:pshared/data/dto/payment/payment.dart';
import 'package:pshared/data/mapper/payment/operation.dart';
import 'package:pshared/data/mapper/payment/quote.dart'; import 'package:pshared/data/mapper/payment/quote.dart';
import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/state.dart'; import 'package:pshared/models/payment/state.dart';
extension PaymentDTOMapper on PaymentDTO { extension PaymentDTOMapper on PaymentDTO {
Payment toDomain() => Payment( Payment toDomain() => Payment(
paymentRef: paymentRef, paymentRef: paymentRef,
@@ -11,6 +13,7 @@ extension PaymentDTOMapper on PaymentDTO {
orchestrationState: paymentOrchestrationStateFromValue(state), orchestrationState: paymentOrchestrationStateFromValue(state),
failureCode: failureCode, failureCode: failureCode,
failureReason: failureReason, failureReason: failureReason,
operations: operations.map((item) => item.toDomain()).toList(),
lastQuote: lastQuote?.toDomain(), lastQuote: lastQuote?.toDomain(),
metadata: metadata, metadata: metadata,
createdAt: createdAt == null ? null : DateTime.tryParse(createdAt!), createdAt: createdAt == null ? null : DateTime.tryParse(createdAt!),
@@ -24,6 +27,7 @@ extension PaymentMapper on Payment {
state: state ?? paymentOrchestrationStateToValue(orchestrationState), state: state ?? paymentOrchestrationStateToValue(orchestrationState),
failureCode: failureCode, failureCode: failureCode,
failureReason: failureReason, failureReason: failureReason,
operations: operations.map((item) => item.toDTO()).toList(),
lastQuote: lastQuote?.toDTO(), lastQuote: lastQuote?.toDTO(),
metadata: metadata, metadata: metadata,
createdAt: createdAt?.toUtc().toIso8601String(), createdAt: createdAt?.toUtc().toIso8601String(),

View File

@@ -10,11 +10,31 @@
"@operationStatusProcessing": { "@operationStatusProcessing": {
"description": "Label for the “processing” operation status" "description": "Label for the “processing” operation status"
}, },
"operationStatusPending": "Pending",
"@operationStatusPending": {
"description": "Label for the “pending” operation status"
},
"operationStatusRetrying": "Retrying",
"@operationStatusRetrying": {
"description": "Label for the “retrying” operation status"
},
"operationStatusSuccess": "Success", "operationStatusSuccess": "Success",
"@operationStatusSuccess": { "@operationStatusSuccess": {
"description": "Label for the “success” operation status" "description": "Label for the “success” operation status"
}, },
"operationStatusSkipped": "Skipped",
"@operationStatusSkipped": {
"description": "Label for the “skipped” operation status"
},
"operationStatusCancelled": "Cancelled",
"@operationStatusCancelled": {
"description": "Label for the “cancelled” operation status"
},
"operationStatusNeedsAttention": "Needs attention",
"@operationStatusNeedsAttention": {
"description": "Label for the “needs attention” operation status"
},
"operationStatusError": "Error", "operationStatusError": "Error",
"@operationStatusError": { "@operationStatusError": {

View File

@@ -10,11 +10,31 @@
"@operationStatusProcessing": { "@operationStatusProcessing": {
"description": "Label for the “processing” operation status" "description": "Label for the “processing” operation status"
}, },
"operationStatusPending": "В ожидании",
"@operationStatusPending": {
"description": "Label for the “pending” operation status"
},
"operationStatusRetrying": "Повтор",
"@operationStatusRetrying": {
"description": "Label for the “retrying” operation status"
},
"operationStatusSuccess": "Успех", "operationStatusSuccess": "Успех",
"@operationStatusSuccess": { "@operationStatusSuccess": {
"description": "Label for the “success” operation status" "description": "Label for the “success” operation status"
}, },
"operationStatusSkipped": "Пропущен",
"@operationStatusSkipped": {
"description": "Label for the “skipped” operation status"
},
"operationStatusCancelled": "Отменен",
"@operationStatusCancelled": {
"description": "Label for the “cancelled” operation status"
},
"operationStatusNeedsAttention": "Требует внимания",
"@operationStatusNeedsAttention": {
"description": "Label for the “needs attention” operation status"
},
"operationStatusError": "Ошибка", "operationStatusError": "Ошибка",
"@operationStatusError": { "@operationStatusError": {

View File

@@ -0,0 +1,25 @@
class PaymentExecutionOperation {
final String? stepRef;
final String? operationRef;
final String? gateway;
final String? code;
final String? state;
final String? label;
final String? failureCode;
final String? failureReason;
final DateTime? startedAt;
final DateTime? completedAt;
const PaymentExecutionOperation({
required this.stepRef,
required this.operationRef,
required this.gateway,
required this.code,
required this.state,
required this.label,
required this.failureCode,
required this.failureReason,
required this.startedAt,
required this.completedAt,
});
}

View File

@@ -1,7 +1,6 @@
import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/status.dart'; import 'package:pshared/models/payment/status.dart';
class OperationItem { class OperationItem {
final OperationStatus status; final OperationStatus status;
final String? fileName; final String? fileName;
@@ -11,6 +10,8 @@ class OperationItem {
final String toCurrency; final String toCurrency;
final String payId; final String payId;
final String? paymentRef; final String? paymentRef;
final String? operationRef;
final String? gatewayService;
final String? cardNumber; final String? cardNumber;
final PaymentMethod? paymentMethod; final PaymentMethod? paymentMethod;
final String name; final String name;
@@ -26,6 +27,8 @@ class OperationItem {
required this.toCurrency, required this.toCurrency,
required this.payId, required this.payId,
this.paymentRef, this.paymentRef,
this.operationRef,
this.gatewayService,
this.cardNumber, this.cardNumber,
this.paymentMethod, this.paymentMethod,
required this.name, required this.name,

View File

@@ -1,3 +1,4 @@
import 'package:pshared/models/payment/execution_operation.dart';
import 'package:pshared/models/payment/quote/quote.dart'; import 'package:pshared/models/payment/quote/quote.dart';
import 'package:pshared/models/payment/state.dart'; import 'package:pshared/models/payment/state.dart';
@@ -8,6 +9,7 @@ class Payment {
final PaymentOrchestrationState orchestrationState; final PaymentOrchestrationState orchestrationState;
final String? failureCode; final String? failureCode;
final String? failureReason; final String? failureReason;
final List<PaymentExecutionOperation> operations;
final PaymentQuote? lastQuote; final PaymentQuote? lastQuote;
final Map<String, String>? metadata; final Map<String, String>? metadata;
final DateTime? createdAt; final DateTime? createdAt;
@@ -19,6 +21,7 @@ class Payment {
required this.orchestrationState, required this.orchestrationState,
required this.failureCode, required this.failureCode,
required this.failureReason, required this.failureReason,
required this.operations,
required this.lastQuote, required this.lastQuote,
required this.metadata, required this.metadata,
required this.createdAt, required this.createdAt,

View File

@@ -2,24 +2,35 @@ import 'package:flutter/widgets.dart';
import 'package:pshared/generated/i18n/ps_localizations.dart'; import 'package:pshared/generated/i18n/ps_localizations.dart';
enum OperationStatus { enum OperationStatus {
pending,
processing, processing,
retrying,
success, success,
skipped,
cancelled,
needsAttention,
error, error,
} }
extension OperationStatusX on OperationStatus { extension OperationStatusX on OperationStatus {
/// Returns the localized string for this status,
/// e.g. “Processing”, “Success”, “Error”.
String localized(BuildContext context) { String localized(BuildContext context) {
final loc = PSLocalizations.of(context)!; final loc = PSLocalizations.of(context)!;
switch (this) { switch (this) {
case OperationStatus.pending:
return loc.operationStatusPending;
case OperationStatus.processing: case OperationStatus.processing:
return loc.operationStatusProcessing; return loc.operationStatusProcessing;
case OperationStatus.retrying:
return loc.operationStatusRetrying;
case OperationStatus.success: case OperationStatus.success:
return loc.operationStatusSuccess; return loc.operationStatusSuccess;
case OperationStatus.skipped:
return loc.operationStatusSkipped;
case OperationStatus.cancelled:
return loc.operationStatusCancelled;
case OperationStatus.needsAttention:
return loc.operationStatusNeedsAttention;
case OperationStatus.error: case OperationStatus.error:
return loc.operationStatusError; return loc.operationStatusError;
} }

View File

@@ -9,13 +9,27 @@ class PaymentDocumentsService {
static final _logger = Logger('service.payment_documents'); static final _logger = Logger('service.payment_documents');
static const String _objectType = Services.payments; static const String _objectType = Services.payments;
static Future<DownloadedFile> getAct(String organizationRef, String paymentRef) async { static Future<DownloadedFile> getOperationDocument(
final encodedRef = Uri.encodeQueryComponent(paymentRef); String organizationRef,
final url = '/documents/act/$organizationRef?payment_ref=$encodedRef'; String gatewayService,
_logger.fine('Downloading act document for payment $paymentRef'); String operationRef,
final response = await AuthorizationService.getGETBinaryResponse(_objectType, url); ) async {
final filename = _filenameFromDisposition(response.header('content-disposition')) ?? final query = <String, String>{
'act_$paymentRef.pdf'; 'gateway_service': gatewayService,
'operation_ref': operationRef,
};
final queryString = Uri(queryParameters: query).query;
final url = '/documents/operation/$organizationRef?$queryString';
_logger.fine(
'Downloading operation document for operation $operationRef in gateway $gatewayService',
);
final response = await AuthorizationService.getGETBinaryResponse(
_objectType,
url,
);
final filename =
_filenameFromDisposition(response.header('content-disposition')) ??
'operation_$operationRef.pdf';
final mimeType = response.header('content-type') ?? 'application/pdf'; final mimeType = response.header('content-type') ?? 'application/pdf';
return DownloadedFile( return DownloadedFile(
bytes: response.bytes, bytes: response.bytes,

View File

@@ -69,6 +69,7 @@ void main() {
orchestrationState: PaymentOrchestrationState.created, orchestrationState: PaymentOrchestrationState.created,
failureCode: null, failureCode: null,
failureReason: null, failureReason: null,
operations: [],
lastQuote: null, lastQuote: null,
metadata: null, metadata: null,
createdAt: null, createdAt: null,
@@ -80,6 +81,7 @@ void main() {
orchestrationState: PaymentOrchestrationState.settled, orchestrationState: PaymentOrchestrationState.settled,
failureCode: null, failureCode: null,
failureReason: null, failureReason: null,
operations: [],
lastQuote: null, lastQuote: null,
metadata: null, metadata: null,
createdAt: null, createdAt: null,
@@ -99,6 +101,7 @@ void main() {
orchestrationState: PaymentOrchestrationState.executing, orchestrationState: PaymentOrchestrationState.executing,
failureCode: 'failure_ledger', failureCode: 'failure_ledger',
failureReason: 'ledger failed', failureReason: 'ledger failed',
operations: [],
lastQuote: null, lastQuote: null,
metadata: null, metadata: null,
createdAt: null, createdAt: null,
@@ -110,6 +113,7 @@ void main() {
orchestrationState: PaymentOrchestrationState.failed, orchestrationState: PaymentOrchestrationState.failed,
failureCode: null, failureCode: null,
failureReason: null, failureReason: null,
operations: [],
lastQuote: null, lastQuote: null,
metadata: null, metadata: null,
createdAt: null, createdAt: null,

View File

@@ -205,13 +205,13 @@ RouteBase payoutShellRoute() => ShellRoute(
), ),
ChangeNotifierProxyProvider2< ChangeNotifierProxyProvider2<
MultiplePayoutsProvider, MultiplePayoutsProvider,
WalletsController, PaymentSourceController,
MultiplePayoutsController MultiplePayoutsController
>( >(
create: (_) => create: (_) =>
MultiplePayoutsController(csvInput: WebCsvInputService()), MultiplePayoutsController(csvInput: WebCsvInputService()),
update: (context, provider, wallets, controller) => update: (context, provider, sourceController, controller) =>
controller!..update(provider, wallets), controller!..update(provider, sourceController),
), ),
], ],
child: PageSelector(child: child, routerState: state), child: PageSelector(child: child, routerState: state),

View File

@@ -7,7 +7,7 @@ import 'package:pshared/models/payment/status.dart';
import 'package:pshared/provider/payment/payments.dart'; import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/models/state/load_more_state.dart'; import 'package:pweb/models/state/load_more_state.dart';
import 'package:pweb/utils/report/operations.dart'; import 'package:pweb/utils/report/operations/operations.dart';
import 'package:pweb/utils/report/payment_mapper.dart'; import 'package:pweb/utils/report/payment_mapper.dart';

View File

@@ -1,15 +1,17 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/execution_operation.dart';
import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/status.dart'; import 'package:pshared/models/payment/status.dart';
import 'package:pshared/provider/payment/payments.dart'; import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/models/documents/operation.dart';
import 'package:pweb/utils/report/operations/document_rule.dart';
import 'package:pweb/utils/report/payment_mapper.dart'; import 'package:pweb/utils/report/payment_mapper.dart';
class PaymentDetailsController extends ChangeNotifier { class PaymentDetailsController extends ChangeNotifier {
PaymentDetailsController({required String paymentId}) PaymentDetailsController({required String paymentId})
: _paymentId = paymentId; : _paymentId = paymentId;
PaymentsProvider? _payments; PaymentsProvider? _payments;
String _paymentId; String _paymentId;
@@ -23,12 +25,44 @@ class PaymentDetailsController extends ChangeNotifier {
bool get canDownload { bool get canDownload {
final current = _payment; final current = _payment;
if (current == null) return false; if (current == null) return false;
final status = statusFromPayment(current); if (statusFromPayment(current) != OperationStatus.success) return false;
final paymentRef = current.paymentRef ?? ''; return primaryOperationDocumentRequest != null;
return status == OperationStatus.success &&
paymentRef.trim().isNotEmpty;
} }
OperationDocumentRequestModel? get primaryOperationDocumentRequest {
final current = _payment;
if (current == null) return null;
for (final operation in current.operations) {
final request = operationDocumentRequest(operation);
if (request != null) {
return request;
}
}
return null;
}
OperationDocumentRequestModel? operationDocumentRequest(
PaymentExecutionOperation operation,
) {
final current = _payment;
if (current == null) return null;
final operationRef = operation.operationRef;
if (operationRef == null || operationRef.isEmpty) return null;
final gatewayService = operation.gateway;
if (gatewayService == null || gatewayService.isEmpty) return null;
if (!isOperationDocumentEligible(operation.code)) return null;
return OperationDocumentRequestModel(
gatewayService: gatewayService,
operationRef: operationRef,
);
}
bool canDownloadOperationDocument(PaymentExecutionOperation operation) =>
operationDocumentRequest(operation) != null;
void update(PaymentsProvider provider, String paymentId) { void update(PaymentsProvider provider, String paymentId) {
if (_paymentId != paymentId) { if (_paymentId != paymentId) {
_paymentId = paymentId; _paymentId = paymentId;

View File

@@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/operation.dart'; import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/provider/payment/payments.dart'; import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/utils/report/operations.dart'; import 'package:pweb/utils/report/operations/operations.dart';
import 'package:pweb/utils/report/payment_mapper.dart'; import 'package:pweb/utils/report/payment_mapper.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/money.dart'; import 'package:pshared/models/money.dart';
import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/quote/status_type.dart'; import 'package:pshared/models/payment/quote/status_type.dart';
@@ -15,15 +15,17 @@ import 'package:pweb/services/payments/csv_input.dart';
class MultiplePayoutsController extends ChangeNotifier { class MultiplePayoutsController extends ChangeNotifier {
final CsvInputService _csvInput; final CsvInputService _csvInput;
MultiplePayoutsProvider? _provider; MultiplePayoutsProvider? _provider;
WalletsController? _wallets; PaymentSourceController? _sourceController;
_PickState _pickState = _PickState.idle; _PickState _pickState = _PickState.idle;
Exception? _uiError; Exception? _uiError;
MultiplePayoutsController({ MultiplePayoutsController({required CsvInputService csvInput})
required CsvInputService csvInput, : _csvInput = csvInput;
}) : _csvInput = csvInput;
void update(MultiplePayoutsProvider provider, WalletsController wallets) { void update(
MultiplePayoutsProvider provider,
PaymentSourceController sourceController,
) {
var shouldNotify = false; var shouldNotify = false;
if (!identical(_provider, provider)) { if (!identical(_provider, provider)) {
_provider?.removeListener(_onProviderChanged); _provider?.removeListener(_onProviderChanged);
@@ -31,10 +33,10 @@ class MultiplePayoutsController extends ChangeNotifier {
_provider?.addListener(_onProviderChanged); _provider?.addListener(_onProviderChanged);
shouldNotify = true; shouldNotify = true;
} }
if (!identical(_wallets, wallets)) { if (!identical(_sourceController, sourceController)) {
_wallets?.removeListener(_onWalletsChanged); _sourceController?.removeListener(_onSourceChanged);
_wallets = wallets; _sourceController = sourceController;
_wallets?.addListener(_onWalletsChanged); _sourceController?.addListener(_onSourceChanged);
shouldNotify = true; shouldNotify = true;
} }
if (shouldNotify) { if (shouldNotify) {
@@ -58,7 +60,7 @@ class MultiplePayoutsController extends ChangeNotifier {
_provider?.quoteStatusType ?? QuoteStatusType.missing; _provider?.quoteStatusType ?? QuoteStatusType.missing;
Duration? get quoteTimeLeft => _provider?.quoteTimeLeft; Duration? get quoteTimeLeft => _provider?.quoteTimeLeft;
bool get canSend => _provider?.canSend ?? false; bool get canSend => (_provider?.canSend ?? false) && _selectedWallet != null;
Money? get aggregateDebitAmount => Money? get aggregateDebitAmount =>
_provider?.aggregateDebitAmountFor(_selectedWallet); _provider?.aggregateDebitAmountFor(_selectedWallet);
Money? get requestedSentAmount => _provider?.requestedSentAmount; Money? get requestedSentAmount => _provider?.requestedSentAmount;
@@ -128,11 +130,11 @@ class MultiplePayoutsController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void _onWalletsChanged() { void _onSourceChanged() {
notifyListeners(); notifyListeners();
} }
Wallet? get _selectedWallet => _wallets?.selectedWallet; Wallet? get _selectedWallet => _sourceController?.selectedWallet;
void _setUiError(Object error) { void _setUiError(Object error) {
_uiError = error is Exception ? error : Exception(error.toString()); _uiError = error is Exception ? error : Exception(error.toString());
@@ -150,7 +152,7 @@ class MultiplePayoutsController extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
_provider?.removeListener(_onProviderChanged); _provider?.removeListener(_onProviderChanged);
_wallets?.removeListener(_onWalletsChanged); _sourceController?.removeListener(_onSourceChanged);
super.dispose(); super.dispose();
} }
} }

View File

@@ -403,6 +403,34 @@
"idempotencyKeyLabel": "Idempotency key", "idempotencyKeyLabel": "Idempotency key",
"quoteIdLabel": "Quote ID", "quoteIdLabel": "Quote ID",
"createdAtLabel": "Created at", "createdAtLabel": "Created at",
"completedAtLabel": "Completed at",
"operationStepStateSkipped": "Skipped",
"operationStepStateNeedsAttention": "Needs attention",
"operationStepStateRetrying": "Retrying",
"paymentOperationPair": "{operation} {action}",
"@paymentOperationPair": {
"description": "Title pattern for one payment execution operation line in payment details",
"placeholders": {
"operation": {
"type": "String"
},
"action": {
"type": "String"
}
}
},
"paymentOperationCardPayout": "Card payout",
"paymentOperationCrypto": "Crypto",
"paymentOperationSettlement": "Settlement",
"paymentOperationLedger": "Ledger",
"paymentOperationActionSend": "Send",
"paymentOperationActionObserve": "Observe",
"paymentOperationActionFxConvert": "FX convert",
"paymentOperationActionCredit": "Credit",
"paymentOperationActionBlock": "Block",
"paymentOperationActionDebit": "Debit",
"paymentOperationActionRelease": "Release",
"paymentOperationActionMove": "Move",
"debitAmountLabel": "You pay", "debitAmountLabel": "You pay",
"debitSettlementAmountLabel": "Debit settlement amount", "debitSettlementAmountLabel": "Debit settlement amount",
"expectedSettlementAmountLabel": "Recipient gets", "expectedSettlementAmountLabel": "Recipient gets",

View File

@@ -403,6 +403,34 @@
"idempotencyKeyLabel": "Ключ идемпотентности", "idempotencyKeyLabel": "Ключ идемпотентности",
"quoteIdLabel": "ID котировки", "quoteIdLabel": "ID котировки",
"createdAtLabel": "Создан", "createdAtLabel": "Создан",
"completedAtLabel": "Завершено",
"operationStepStateSkipped": "Пропущен",
"operationStepStateNeedsAttention": "Требует внимания",
"operationStepStateRetrying": "Повтор",
"paymentOperationPair": "{operation} {action}",
"@paymentOperationPair": {
"description": "Шаблон заголовка строки шага выполнения платежа в деталях платежа",
"placeholders": {
"operation": {
"type": "String"
},
"action": {
"type": "String"
}
}
},
"paymentOperationCardPayout": "Выплата на карту",
"paymentOperationCrypto": "Крипто",
"paymentOperationSettlement": "Расчётный контур",
"paymentOperationLedger": "Леджер",
"paymentOperationActionSend": "Отправка",
"paymentOperationActionObserve": "Проверка",
"paymentOperationActionFxConvert": "FX-конверсия",
"paymentOperationActionCredit": "Зачисление",
"paymentOperationActionBlock": "Блокировка",
"paymentOperationActionDebit": "Списание",
"paymentOperationActionRelease": "Разблокировка",
"paymentOperationActionMove": "Перемещение",
"debitAmountLabel": "Вы платите", "debitAmountLabel": "Вы платите",
"debitSettlementAmountLabel": "Списано к зачислению", "debitSettlementAmountLabel": "Списано к зачислению",
"expectedSettlementAmountLabel": "Получателю поступит", "expectedSettlementAmountLabel": "Получателю поступит",

View File

@@ -0,0 +1,9 @@
class OperationDocumentRequestModel {
final String gatewayService;
final String operationRef;
const OperationDocumentRequestModel({
required this.gatewayService,
required this.operationRef,
});
}

View File

@@ -0,0 +1,6 @@
enum SourceOfFundsVisibleState {
headerAction,
summary,
quoteStatus,
sendAction,
}

View File

@@ -0,0 +1,9 @@
class OperationDocumentInfo {
final String operationRef;
final String gatewayService;
const OperationDocumentInfo({
required this.operationRef,
required this.gatewayService,
});
}

View File

@@ -13,8 +13,6 @@ import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/header.dart'; import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
import 'package:pweb/widgets/refresh_balance/wallet.dart'; import 'package:pweb/widgets/refresh_balance/wallet.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletCard extends StatelessWidget { class WalletCard extends StatelessWidget {
final Wallet wallet; final Wallet wallet;
@@ -30,7 +28,6 @@ class WalletCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final networkLabel = (wallet.network == null || wallet.network == ChainNetwork.unspecified) final networkLabel = (wallet.network == null || wallet.network == ChainNetwork.unspecified)
? null ? null
: wallet.network!.localizedName(context); : wallet.network!.localizedName(context);
@@ -53,11 +50,12 @@ class WalletCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
BalanceHeader( BalanceHeader(
title: loc.paymentTypeCryptoWallet, title: wallet.name,
subtitle: networkLabel, subtitle: networkLabel,
badge: (symbol == null || symbol.isEmpty) ? null : symbol, badge: (symbol == null || symbol.isEmpty) ? null : symbol,
), ),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
BalanceAmount( BalanceAmount(
wallet: wallet, wallet: wallet,
@@ -65,12 +63,16 @@ class WalletCard extends StatelessWidget {
context.read<WalletsController>().toggleBalanceMask(wallet.id); context.read<WalletsController>().toggleBalanceMask(wallet.id);
}, },
), ),
WalletBalanceRefreshButton( Column(
walletRef: wallet.id, children: [
WalletBalanceRefreshButton(
walletRef: wallet.id,
),
BalanceAddFunds(onTopUp: onTopUp),
],
), ),
], ],
), ),
BalanceAddFunds(onTopUp: onTopUp),
], ],
), ),
), ),

View File

@@ -17,10 +17,7 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class LedgerAccountCard extends StatelessWidget { class LedgerAccountCard extends StatelessWidget {
final LedgerAccount account; final LedgerAccount account;
const LedgerAccountCard({ const LedgerAccountCard({super.key, required this.account});
super.key,
required this.account,
});
String _formatBalance() { String _formatBalance() {
final money = account.balance?.balance; final money = account.balance?.balance;
@@ -62,8 +59,13 @@ class LedgerAccountCard extends StatelessWidget {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
final subtitle = account.name.isNotEmpty ? account.name : account.accountCode; final accountName = account.name.trim();
final badge = account.currency.trim().isEmpty ? null : account.currency.toUpperCase(); final accountCode = account.accountCode.trim();
final title = accountName.isNotEmpty ? accountName : loc.paymentTypeLedger;
final subtitle = accountCode.isNotEmpty ? accountCode : null;
final badge = account.currency.trim().isEmpty
? null
: account.currency.toUpperCase();
return Card( return Card(
color: colorScheme.onSecondary, color: colorScheme.onSecondary,
@@ -76,16 +78,14 @@ class LedgerAccountCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
BalanceHeader( BalanceHeader(title: title, subtitle: subtitle, badge: badge),
title: loc.paymentTypeLedger,
subtitle: subtitle.isNotEmpty ? subtitle : null,
badge: badge,
),
Row( Row(
children: [ children: [
Consumer<LedgerBalanceMaskController>( Consumer<LedgerBalanceMaskController>(
builder: (context, controller, _) { builder: (context, controller, _) {
final isMasked = controller.isBalanceMasked(account.ledgerAccountRef); final isMasked = controller.isBalanceMasked(
account.ledgerAccountRef,
);
return Row( return Row(
children: [ children: [
Text( Text(
@@ -97,7 +97,9 @@ class LedgerAccountCard extends StatelessWidget {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
GestureDetector( GestureDetector(
onTap: () => controller.toggleBalanceMask(account.ledgerAccountRef), onTap: () => controller.toggleBalanceMask(
account.ledgerAccountRef,
),
child: Icon( child: Icon(
isMasked ? Icons.visibility_off : Icons.visibility, isMasked ? Icons.visibility_off : Icons.visibility,
size: 24, size: 24,

View File

@@ -1,22 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SourceQuotePanelHeader extends StatelessWidget {
const SourceQuotePanelHeader({
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
return Text(
l10n.sourceOfFunds,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
);
}
}

View File

@@ -2,93 +2,133 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/provider/payment/multiple/quotation.dart'; import 'package:pshared/provider/payment/multiple/quotation.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart'; import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/controllers/payouts/payout_verification.dart'; import 'package:pweb/controllers/payouts/payout_verification.dart';
import 'package:pweb/models/payment/source_funds.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/actions.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/actions.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/header.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/widgets/quote_status.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/widgets/quote_status.dart';
import 'package:pweb/pages/payout_page/send/widgets/send_button.dart'; import 'package:pweb/pages/payout_page/send/widgets/send_button.dart';
import 'package:pweb/widgets/payment/source_wallet_selector.dart'; import 'package:pweb/widgets/payment/source_of_funds_panel.dart';
import 'package:pweb/widgets/payment/source_wallet_selector/view.dart';
import 'package:pweb/widgets/cooldown_hint.dart'; import 'package:pweb/widgets/cooldown_hint.dart';
import 'package:pweb/widgets/refresh_balance/wallet.dart';
import 'package:pweb/models/state/control_state.dart'; import 'package:pweb/models/state/control_state.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SourceQuotePanel extends StatelessWidget { class SourceQuotePanel extends StatelessWidget {
const SourceQuotePanel({ const SourceQuotePanel({super.key, required this.controller});
super.key,
required this.controller,
required this.walletsController,
});
final MultiplePayoutsController controller; final MultiplePayoutsController controller;
final WalletsController walletsController;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final l10n = AppLocalizations.of(context)!;
final verificationController = final sourceController = context.watch<PaymentSourceController>();
context.watch<PayoutVerificationController>(); final verificationController = context
.watch<PayoutVerificationController>();
final quotationProvider = context.watch<MultiQuotationProvider>(); final quotationProvider = context.watch<MultiQuotationProvider>();
final verificationContextKey = quotationProvider.quotation?.quoteRef ?? final verificationContextKey =
quotationProvider.quotation?.quoteRef ??
quotationProvider.quotation?.idempotencyKey; quotationProvider.quotation?.idempotencyKey;
final isCooldownActive = verificationController.isCooldownActiveFor( final isCooldownActive = verificationController.isCooldownActiveFor(
verificationContextKey, verificationContextKey,
); );
final canSend = controller.canSend && !isCooldownActive; final canSend = controller.canSend && !isCooldownActive;
return Container( return SourceOfFundsPanel(
width: double.infinity, title: l10n.sourceOfFunds,
padding: const EdgeInsets.all(12), sourceSelector: SourceWalletSelector(
decoration: BoxDecoration( sourceController: sourceController,
color: theme.colorScheme.surface, isBusy: controller.isBusy,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.colorScheme.outlineVariant),
), ),
visibleStates: const <SourceOfFundsVisibleState>{
SourceOfFundsVisibleState.headerAction,
SourceOfFundsVisibleState.summary,
SourceOfFundsVisibleState.quoteStatus,
SourceOfFundsVisibleState.sendAction,
},
stateWidgets: <SourceOfFundsVisibleState, Widget>{
SourceOfFundsVisibleState.headerAction: _MultipleRefreshAction(
sourceController: sourceController,
),
SourceOfFundsVisibleState.summary: SourceQuoteSummary(
controller: controller,
spacing: 12,
),
SourceOfFundsVisibleState.quoteStatus: MultipleQuoteStatusCard(
controller: controller,
),
SourceOfFundsVisibleState.sendAction: _MultipleSendAction(
controller: controller,
canSend: canSend,
isCooldownActive: isCooldownActive,
verificationController: verificationController,
verificationContextKey: verificationContextKey,
),
},
);
}
}
class _MultipleRefreshAction extends StatelessWidget {
const _MultipleRefreshAction({required this.sourceController});
final PaymentSourceController sourceController;
@override
Widget build(BuildContext context) {
final selectedWallet = sourceController.selectedWallet;
if (selectedWallet == null) {
return const SizedBox.shrink();
}
return WalletBalanceRefreshButton(walletRef: selectedWallet.id);
}
}
class _MultipleSendAction extends StatelessWidget {
const _MultipleSendAction({
required this.controller,
required this.canSend,
required this.isCooldownActive,
required this.verificationController,
required this.verificationContextKey,
});
final MultiplePayoutsController controller;
final bool canSend;
final bool isCooldownActive;
final PayoutVerificationController verificationController;
final String? verificationContextKey;
@override
Widget build(BuildContext context) {
return Center(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
SourceQuotePanelHeader(), SendButton(
const SizedBox(height: 8), onPressed: () => handleMultiplePayoutSend(context, controller),
SourceWalletSelector( state: controller.isSending
walletsController: walletsController, ? ControlState.loading
isBusy: controller.isBusy, : canSend
? ControlState.enabled
: ControlState.disabled,
), ),
const SizedBox(height: 12), if (isCooldownActive) ...[
const Divider(height: 1), const SizedBox(height: 8),
const SizedBox(height: 12), CooldownHint(
SourceQuoteSummary(controller: controller, spacing: 12), seconds: verificationController.cooldownRemainingSecondsFor(
const SizedBox(height: 12), verificationContextKey,
MultipleQuoteStatusCard(controller: controller), ),
const SizedBox(height: 12),
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SendButton(
onPressed: () => handleMultiplePayoutSend(context, controller),
state: controller.isSending
? ControlState.loading
: canSend
? ControlState.enabled
: ControlState.disabled,
),
if (isCooldownActive) ...[
const SizedBox(height: 8),
CooldownHint(
seconds: verificationController.cooldownRemainingSecondsFor(
verificationContextKey,
),
),
],
],
), ),
), ],
], ],
), ),
); );
} }
} }

View File

@@ -1,7 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart'; import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/widget.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/widget.dart';
@@ -9,14 +7,9 @@ import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/panel_
class UploadCsvLayout extends StatelessWidget { class UploadCsvLayout extends StatelessWidget {
const UploadCsvLayout({ const UploadCsvLayout({super.key, required this.controller});
super.key,
required this.controller,
required this.walletsController,
});
final MultiplePayoutsController controller; final MultiplePayoutsController controller;
final WalletsController walletsController;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -27,28 +20,17 @@ class UploadCsvLayout extends StatelessWidget {
if (!useHorizontal) { if (!useHorizontal) {
return Column( return Column(
children: [ children: [
PanelCard( PanelCard(child: UploadPanel(controller: controller)),
child: UploadPanel(
controller: controller,
),
),
if (hasFile) ...[ if (hasFile) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
SourceQuotePanel( SourceQuotePanel(controller: controller),
controller: controller,
walletsController: walletsController,
),
], ],
], ],
); );
} }
if (!hasFile) { if (!hasFile) {
return PanelCard( return PanelCard(child: UploadPanel(controller: controller));
child: UploadPanel(
controller: controller,
),
);
} }
return IntrinsicHeight( return IntrinsicHeight(
@@ -57,19 +39,12 @@ class UploadCsvLayout extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
flex: 3, flex: 3,
child: PanelCard( child: PanelCard(child: UploadPanel(controller: controller)),
child: UploadPanel(
controller: controller,
),
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
flex: 5, flex: 5,
child: SourceQuotePanel( child: SourceQuotePanel(controller: controller),
controller: controller,
walletsController: walletsController,
),
), ),
], ],
), ),

View File

@@ -6,7 +6,6 @@ import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/header.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/header.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart';
class UploadCSVSection extends StatelessWidget { class UploadCSVSection extends StatelessWidget {
const UploadCSVSection({super.key}); const UploadCSVSection({super.key});
@@ -22,10 +21,7 @@ class UploadCSVSection extends StatelessWidget {
children: [ children: [
UploadCsvHeader(theme: theme), UploadCsvHeader(theme: theme),
const SizedBox(height: _verticalSpacing), const SizedBox(height: _verticalSpacing),
UploadCsvLayout( UploadCsvLayout(controller: controller),
controller: controller,
walletsController: context.watch(),
),
], ],
); );
} }

View File

@@ -4,7 +4,7 @@ import 'package:provider/provider.dart';
import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pweb/widgets/payment/source_wallet_selector.dart'; import 'package:pweb/widgets/payment/source_wallet_selector/view.dart';
class PaymentMethodSelector extends StatelessWidget { class PaymentMethodSelector extends StatelessWidget {
const PaymentMethodSelector({super.key}); const PaymentMethodSelector({super.key});

View File

@@ -4,13 +4,14 @@ import 'package:provider/provider.dart';
import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/controllers/payment/source.dart';
import 'package:pweb/models/payment/source_funds.dart';
import 'package:pweb/pages/payout_page/send/widgets/method_selector.dart'; import 'package:pweb/pages/payout_page/send/widgets/method_selector.dart';
import 'package:pweb/pages/payout_page/send/widgets/section/title.dart';
import 'package:pweb/pages/payout_page/send/widgets/section/card.dart';
import 'package:pweb/utils/dimensions.dart'; import 'package:pweb/utils/dimensions.dart';
import 'package:pweb/widgets/payment/source_of_funds_panel.dart';
import 'package:pweb/widgets/refresh_balance/ledger.dart'; import 'package:pweb/widgets/refresh_balance/ledger.dart';
import 'package:pweb/widgets/refresh_balance/wallet.dart'; import 'package:pweb/widgets/refresh_balance/wallet.dart';
class PaymentSourceOfFundsCard extends StatelessWidget { class PaymentSourceOfFundsCard extends StatelessWidget {
final AppDimensions dimensions; final AppDimensions dimensions;
final String title; final String title;
@@ -23,38 +24,33 @@ class PaymentSourceOfFundsCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PaymentSectionCard( return SourceOfFundsPanel(
child: Column( title: title,
crossAxisAlignment: CrossAxisAlignment.start, selectorSpacing: dimensions.paddingSmall,
children: [ sourceSelector: const PaymentMethodSelector(),
Row( visibleStates: const <SourceOfFundsVisibleState>{
children: [ SourceOfFundsVisibleState.headerAction,
Expanded(child: SectionTitle(title)), },
Consumer<PaymentSourceController>( stateWidgets: <SourceOfFundsVisibleState, Widget>{
builder: (context, provider, _) { SourceOfFundsVisibleState
final selectedWallet = provider.selectedWallet; .headerAction: Consumer<PaymentSourceController>(
if (selectedWallet != null) { builder: (context, provider, _) {
return WalletBalanceRefreshButton( final selectedWallet = provider.selectedWallet;
walletRef: selectedWallet.id, if (selectedWallet != null) {
); return WalletBalanceRefreshButton(walletRef: selectedWallet.id);
} }
final selectedLedger = provider.selectedLedgerAccount; final selectedLedger = provider.selectedLedgerAccount;
if (selectedLedger != null) { if (selectedLedger != null) {
return LedgerBalanceRefreshButton( return LedgerBalanceRefreshButton(
ledgerAccountRef: selectedLedger.ledgerAccountRef, ledgerAccountRef: selectedLedger.ledgerAccountRef,
); );
} }
return const SizedBox.shrink(); return const SizedBox.shrink();
}, },
), ),
], },
),
SizedBox(height: dimensions.paddingSmall),
const PaymentMethodSelector(),
],
),
); );
} }
} }

View File

@@ -2,12 +2,9 @@ import 'package:flutter/material.dart';
import 'package:pshared/models/payment/operation.dart'; import 'package:pshared/models/payment/operation.dart';
bool shouldShowToAmount(OperationItem operation) { bool shouldShowToAmount(OperationItem operation) {
if (operation.toCurrency.trim().isEmpty) return false; if (operation.toCurrency.trim().isEmpty) return false;
if (operation.currency.trim().isEmpty) return true; return true;
if (operation.currency != operation.toCurrency) return true;
return (operation.toAmount - operation.amount).abs() > 0.0001;
} }
String formatOperationTime(BuildContext context, DateTime date) { String formatOperationTime(BuildContext context, DateTime date) {

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/models/payment/execution_operation.dart';
import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/payment.dart';
import 'package:pweb/pages/report/details/header.dart'; import 'package:pweb/pages/report/details/header.dart';
@@ -13,12 +14,17 @@ class PaymentDetailsContent extends StatelessWidget {
final Payment payment; final Payment payment;
final VoidCallback onBack; final VoidCallback onBack;
final VoidCallback? onDownloadAct; final VoidCallback? onDownloadAct;
final bool Function(PaymentExecutionOperation operation)?
canDownloadOperationDocument;
final ValueChanged<PaymentExecutionOperation>? onDownloadOperationDocument;
const PaymentDetailsContent({ const PaymentDetailsContent({
super.key, super.key,
required this.payment, required this.payment,
required this.onBack, required this.onBack,
this.onDownloadAct, this.onDownloadAct,
this.canDownloadOperationDocument,
this.onDownloadOperationDocument,
}); });
@override @override
@@ -29,17 +35,15 @@ class PaymentDetailsContent extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
PaymentDetailsHeader( PaymentDetailsHeader(title: loc.paymentInfo, onBack: onBack),
title: loc.paymentInfo,
onBack: onBack,
),
const SizedBox(height: 16), const SizedBox(height: 16),
PaymentSummaryCard( PaymentSummaryCard(payment: payment, onDownloadAct: onDownloadAct),
const SizedBox(height: 16),
PaymentDetailsSections(
payment: payment, payment: payment,
onDownloadAct: onDownloadAct, canDownloadOperationDocument: canDownloadOperationDocument,
onDownloadOperationDocument: onDownloadOperationDocument,
), ),
const SizedBox(height: 16),
PaymentDetailsSections(payment: payment),
], ],
), ),
); );

View File

@@ -19,17 +19,17 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentDetailsPage extends StatelessWidget { class PaymentDetailsPage extends StatelessWidget {
final String paymentId; final String paymentId;
const PaymentDetailsPage({ const PaymentDetailsPage({super.key, required this.paymentId});
super.key,
required this.paymentId,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ChangeNotifierProxyProvider<PaymentsProvider, PaymentDetailsController>( return ChangeNotifierProxyProvider<
PaymentsProvider,
PaymentDetailsController
>(
create: (_) => PaymentDetailsController(paymentId: paymentId), create: (_) => PaymentDetailsController(paymentId: paymentId),
update: (_, payments, controller) => controller! update: (_, payments, controller) =>
..update(payments, paymentId), controller!..update(payments, paymentId),
child: const _PaymentDetailsView(), child: const _PaymentDetailsView(),
); );
} }
@@ -65,8 +65,27 @@ class _PaymentDetailsView extends StatelessWidget {
payment: payment, payment: payment,
onBack: () => _handleBack(context), onBack: () => _handleBack(context),
onDownloadAct: controller.canDownload onDownloadAct: controller.canDownload
? () => downloadPaymentAct(context, payment.paymentRef ?? '') ? () {
final request = controller.primaryOperationDocumentRequest;
if (request == null) return;
downloadPaymentAct(
context,
gatewayService: request.gatewayService,
operationRef: request.operationRef,
);
}
: null, : null,
canDownloadOperationDocument:
controller.canDownloadOperationDocument,
onDownloadOperationDocument: (operation) {
final request = controller.operationDocumentRequest(operation);
if (request == null) return;
downloadPaymentAct(
context,
gatewayService: request.gatewayService,
operationRef: request.operationRef,
);
},
); );
}, },
), ),

View File

@@ -1,36 +1,48 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/models/payment/execution_operation.dart';
import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/payment.dart';
import 'package:pweb/pages/report/details/sections/fx.dart'; import 'package:pweb/pages/report/details/sections/fx.dart';
import 'package:pweb/pages/report/details/sections/metadata.dart'; import 'package:pweb/pages/report/details/sections/operations/section.dart';
class PaymentDetailsSections extends StatelessWidget { class PaymentDetailsSections extends StatelessWidget {
final Payment payment; final Payment payment;
final bool Function(PaymentExecutionOperation operation)?
canDownloadOperationDocument;
final ValueChanged<PaymentExecutionOperation>? onDownloadOperationDocument;
const PaymentDetailsSections({ const PaymentDetailsSections({
super.key, super.key,
required this.payment, required this.payment,
this.canDownloadOperationDocument,
this.onDownloadOperationDocument,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasFx = _hasFxQuote(payment); final hasFx = _hasFxQuote(payment);
if (!hasFx) { final hasOperations = payment.operations.isNotEmpty;
return PaymentMetadataSection(payment: payment);
}
return Row( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded(child: PaymentFxSection(payment: payment)), if (hasFx) ...[
const SizedBox(width: 16), PaymentFxSection(payment: payment),
Expanded(child: PaymentMetadataSection(payment: payment)), const SizedBox(height: 16),
],
if (hasOperations) ...[
PaymentOperationsSection(
payment: payment,
canDownloadDocument: canDownloadOperationDocument,
onDownloadDocument: onDownloadOperationDocument,
),
const SizedBox(height: 16),
],
], ],
); );
} }
bool _hasFxQuote(Payment payment) => payment.lastQuote?.fxQuote != null; bool _hasFxQuote(Payment payment) => payment.lastQuote?.fxQuote != null;
} }

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/execution_operation.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pweb/pages/report/details/section.dart';
import 'package:pweb/pages/report/details/sections/operations/tile.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentOperationsSection extends StatelessWidget {
final Payment payment;
final bool Function(PaymentExecutionOperation operation)? canDownloadDocument;
final ValueChanged<PaymentExecutionOperation>? onDownloadDocument;
const PaymentOperationsSection({
super.key,
required this.payment,
this.canDownloadDocument,
this.onDownloadDocument,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final operations = payment.operations;
if (operations.isEmpty) {
return const SizedBox.shrink();
}
final children = <Widget>[];
for (var i = 0; i < operations.length; i++) {
final operation = operations[i];
final canDownload = canDownloadDocument?.call(operation) ?? false;
children.add(
OperationHistoryTile(
operation: operation,
canDownloadDocument: canDownload,
onDownloadDocument: canDownload && onDownloadDocument != null
? () => onDownloadDocument!(operation)
: null,
),
);
if (i < operations.length - 1) {
children.addAll([
const SizedBox(height: 8),
Divider(
height: 1,
color: Theme.of(context).dividerColor.withAlpha(20),
),
const SizedBox(height: 8),
]);
}
}
return DetailsSection(title: loc.operationfryTitle, children: children);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:pweb/utils/payment/status_view.dart';
class StepStateChip extends StatelessWidget {
final StatusView view;
const StepStateChip({super.key, required this.view});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: view.backgroundColor,
borderRadius: BorderRadius.circular(999),
),
child: Text(
view.label.toUpperCase(),
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: view.foregroundColor,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
);
}
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/execution_operation.dart';
import 'package:pweb/utils/report/operations/state_mapper.dart';
import 'package:pweb/pages/report/details/sections/operations/state_chip.dart';
import 'package:pweb/utils/report/operations/time_format.dart';
import 'package:pweb/utils/report/operations/title_mapper.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class OperationHistoryTile extends StatelessWidget {
final PaymentExecutionOperation operation;
final bool canDownloadDocument;
final VoidCallback? onDownloadDocument;
const OperationHistoryTile({
super.key,
required this.operation,
required this.canDownloadDocument,
this.onDownloadDocument,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final title = resolveOperationTitle(loc, operation.code);
final stateView = resolveStepStateView(context, operation.state);
final completedAt = formatCompletedAt(context, operation.completedAt);
final canDownload = canDownloadDocument && onDownloadDocument != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
title,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
StepStateChip(view: stateView),
],
),
const SizedBox(height: 6),
Text(
'${loc.completedAtLabel}: $completedAt',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (canDownload) ...[
const SizedBox(height: 8),
TextButton.icon(
onPressed: onDownloadDocument,
icon: const Icon(Icons.download),
label: Text(loc.downloadAct),
),
],
],
);
}
}

View File

@@ -42,7 +42,9 @@ class PaymentSummaryCard extends StatelessWidget {
final feeLabel = formatMoney(fee); final feeLabel = formatMoney(fee);
final paymentRef = (payment.paymentRef ?? '').trim(); final paymentRef = (payment.paymentRef ?? '').trim();
final showToAmount = toAmountLabel != '-' && toAmountLabel != amountLabel; final showToAmount = toAmountLabel != '-';
final showFee = payment.lastQuote != null;
final feeText = feeLabel != '-' ? loc.fee(feeLabel) : loc.fee(loc.noFee);
final showPaymentId = paymentRef.isNotEmpty; final showPaymentId = paymentRef.isNotEmpty;
final amountParts = splitAmount(amountLabel); final amountParts = splitAmount(amountLabel);
@@ -85,10 +87,10 @@ class PaymentSummaryCard extends StatelessWidget {
icon: Icons.south_east, icon: Icons.south_east,
text: loc.recipientWillReceive(toAmountLabel), text: loc.recipientWillReceive(toAmountLabel),
), ),
if (feeLabel != '-') if (showFee)
InfoLine( InfoLine(
icon: Icons.receipt_long_outlined, icon: Icons.receipt_long_outlined,
text: loc.fee(feeLabel), text: feeText,
muted: true, muted: true,
), ),
if (onDownloadAct != null) ...[ if (onDownloadAct != null) ...[

View File

@@ -14,37 +14,34 @@ class OperationStatusBadge extends StatelessWidget {
const OperationStatusBadge({super.key, required this.status}); const OperationStatusBadge({super.key, required this.status});
Color _badgeColor(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return operationStatusView(l10n, status).color;
}
Color _textColor(Color background) {
// computeLuminance returns 0 for black, 1 for white
return background.computeLuminance() > 0.5 ? Colors.black : Colors.white;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final label = status.localized(context); final l10n = AppLocalizations.of(context)!;
final bg = _badgeColor(context); final view = operationStatusView(
final fg = _textColor(bg); l10n,
Theme.of(context).colorScheme,
status,
);
final label = view.label;
final bg = view.backgroundColor;
final fg = view.foregroundColor;
return badges.Badge( return badges.Badge(
badgeStyle: badges.BadgeStyle( badgeStyle: badges.BadgeStyle(
shape: badges.BadgeShape.square, shape: badges.BadgeShape.square,
badgeColor: bg, badgeColor: bg,
borderRadius: BorderRadius.circular(12), // fully rounded borderRadius: BorderRadius.circular(12), // fully rounded
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2 // tighter padding horizontal: 6,
vertical: 2, // tighter padding
), ),
), ),
badgeContent: Text( badgeContent: Text(
label.toUpperCase(), // or keep sentence case label.toUpperCase(), // or keep sentence case
style: TextStyle( style: TextStyle(
color: fg, color: fg,
fontSize: 11, // smaller text fontSize: 11, // smaller text
fontWeight: FontWeight.w500, // medium weight fontWeight: FontWeight.w500, // medium weight
), ),
), ),
); );

View File

@@ -31,9 +31,7 @@ class OperationFilters extends StatelessWidget {
: '${dateToLocalFormat(context, selectedRange!.start)} ${dateToLocalFormat(context, selectedRange!.end)}'; : '${dateToLocalFormat(context, selectedRange!.start)} ${dateToLocalFormat(context, selectedRange!.end)}';
return Card( return Card(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
borderRadius: BorderRadius.circular(12),
),
elevation: 0, elevation: 0,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -61,12 +59,12 @@ class OperationFilters extends StatelessWidget {
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onPickRange, onPressed: onPickRange,
icon: const Icon(Icons.date_range_outlined, size: 18), icon: const Icon(Icons.date_range_outlined, size: 18),
label: Text( label: Text(periodLabel, overflow: TextOverflow.ellipsis),
periodLabel,
overflow: TextOverflow.ellipsis,
),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
@@ -76,11 +74,7 @@ class OperationFilters extends StatelessWidget {
Wrap( Wrap(
spacing: 10, spacing: 10,
runSpacing: 8, runSpacing: 8,
children: const [ children: OperationStatus.values.map((status) {
OperationStatus.success,
OperationStatus.processing,
OperationStatus.error,
].map((status) {
final label = status.localized(context); final label = status.localized(context);
final isSelected = selectedStatuses.contains(status); final isSelected = selectedStatuses.contains(status);
return FilterChip( return FilterChip(

View File

@@ -9,7 +9,6 @@ import 'package:pweb/utils/report/download_act.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
class OperationRow { class OperationRow {
static DataRow build(OperationItem op, BuildContext context) { static DataRow build(OperationItem op, BuildContext context) {
final isUnknownDate = op.date.millisecondsSinceEpoch == 0; final isUnknownDate = op.date.millisecondsSinceEpoch == 0;
@@ -18,29 +17,37 @@ class OperationRow {
final dateLabel = isUnknownDate final dateLabel = isUnknownDate
? '-' ? '-'
: '${TimeOfDay.fromDateTime(localDate).format(context)}\n' : '${TimeOfDay.fromDateTime(localDate).format(context)}\n'
'${localDate.toIso8601String().split("T").first}'; '${localDate.toIso8601String().split("T").first}';
final canDownload = op.status == OperationStatus.success && final canDownload =
(op.paymentRef ?? '').trim().isNotEmpty; op.status == OperationStatus.success &&
(op.operationRef ?? '').trim().isNotEmpty &&
(op.gatewayService ?? '').trim().isNotEmpty;
final documentCell = canDownload final documentCell = canDownload
? TextButton.icon( ? TextButton.icon(
onPressed: () => downloadPaymentAct(context, op.paymentRef ?? ''), onPressed: () => downloadPaymentAct(
context,
gatewayService: op.gatewayService ?? '',
operationRef: op.operationRef ?? '',
),
icon: const Icon(Icons.download), icon: const Icon(Icons.download),
label: Text(loc.downloadAct), label: Text(loc.downloadAct),
) )
: Text(op.fileName ?? ''); : Text(op.fileName ?? '');
return DataRow(cells: [ return DataRow(
DataCell(OperationStatusBadge(status: op.status)), cells: [
DataCell(documentCell), DataCell(OperationStatusBadge(status: op.status)),
DataCell(Text('${amountToString(op.amount)} ${op.currency}')), DataCell(documentCell),
DataCell(Text('${amountToString(op.toAmount)} ${op.toCurrency}')), DataCell(Text('${amountToString(op.amount)} ${op.currency}')),
DataCell(Text(op.payId)), DataCell(Text('${amountToString(op.toAmount)} ${op.toCurrency}')),
DataCell(Text(op.cardNumber ?? '-')), DataCell(Text(op.payId)),
DataCell(Text(op.name)), DataCell(Text(op.cardNumber ?? '-')),
DataCell(Text(dateLabel)), DataCell(Text(op.name)),
DataCell(Text(op.comment)), DataCell(Text(dateLabel)),
]); DataCell(Text(op.comment)),
],
);
} }
} }

View File

@@ -0,0 +1,23 @@
class OperationCodePair {
final String operation;
final String action;
const OperationCodePair({required this.operation, required this.action});
}
OperationCodePair? parseOperationCodePair(String? code) {
final normalized = code?.trim().toLowerCase();
if (normalized == null || normalized.isEmpty) return null;
final parts = normalized.split('.').where((part) => part.isNotEmpty).toList();
if (parts.length >= 4 && (parts.first == 'hop' || parts.first == 'edge')) {
return OperationCodePair(operation: parts[2], action: parts[3]);
}
if (parts.length >= 2) {
return OperationCodePair(
operation: parts[parts.length - 2],
action: parts.last,
);
}
return null;
}

View File

@@ -7,50 +7,150 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class StatusView { class StatusView {
final String label; final String label;
final Color color; final Color backgroundColor;
final Color foregroundColor;
const StatusView(this.label, this.color); StatusView({
} required this.label,
required this.backgroundColor,
Color? foregroundColor,
}) : foregroundColor =
foregroundColor ??
(backgroundColor.computeLuminance() > 0.5
? Colors.black
: Colors.white);
StatusView statusView(AppLocalizations l10n, String? raw) { Color get color => backgroundColor;
final trimmed = (raw ?? '').trim();
final upper = trimmed.toUpperCase();
final normalized = upper.startsWith('PAYMENT_STATE_')
? upper.substring('PAYMENT_STATE_'.length)
: upper;
switch (normalized) {
case 'SETTLED':
return StatusView(l10n.paymentStatusPending, Colors.orange);
case 'SUCCESS':
return StatusView(l10n.paymentStatusSuccessful, Colors.green);
case 'FUNDS_RESERVED':
return StatusView(l10n.paymentStatusReserved, Colors.blue);
case 'ACCEPTED':
return StatusView(l10n.paymentStatusProcessing, Colors.orange);
case 'SUBMITTED':
return StatusView(l10n.paymentStatusProcessing, Colors.blue);
case 'FAILED':
return StatusView(l10n.paymentStatusFailed, Colors.red);
case 'CANCELLED':
return StatusView(l10n.paymentStatusCancelled, Colors.grey);
case 'UNSPECIFIED':
case '':
default:
return StatusView(l10n.paymentStatusPending, Colors.grey);
}
} }
StatusView operationStatusView( StatusView operationStatusView(
AppLocalizations l10n, AppLocalizations l10n,
ColorScheme scheme,
OperationStatus status, OperationStatus status,
) { ) {
switch (status) { return operationStatusViewFromToken(
case OperationStatus.success: l10n,
return statusView(l10n, 'SUCCESS'); scheme,
case OperationStatus.error: operationStatusTokenFromEnum(status),
return statusView(l10n, 'FAILED'); );
case OperationStatus.processing: }
return statusView(l10n, 'ACCEPTED');
StatusView operationStatusViewFromToken(
AppLocalizations l10n,
ColorScheme scheme,
String? rawState, {
String? fallbackLabel,
}) {
final token = normalizeOperationStatusToken(rawState);
switch (token) {
case 'success':
case 'succeeded':
case 'completed':
case 'confirmed':
case 'settled':
return StatusView(
label: l10n.operationStatusSuccessful,
backgroundColor: scheme.tertiaryContainer,
foregroundColor: scheme.onTertiaryContainer,
);
case 'skipped':
return StatusView(
label: l10n.operationStepStateSkipped,
backgroundColor: scheme.secondaryContainer,
foregroundColor: scheme.onSecondaryContainer,
);
case 'error':
case 'failed':
case 'rejected':
case 'aborted':
return StatusView(
label: l10n.operationStatusUnsuccessful,
backgroundColor: scheme.errorContainer,
foregroundColor: scheme.onErrorContainer,
);
case 'cancelled':
case 'canceled':
return StatusView(
label: l10n.paymentStatusCancelled,
backgroundColor: scheme.surfaceContainerHighest,
foregroundColor: scheme.onSurfaceVariant,
);
case 'processing':
case 'running':
case 'executing':
case 'in_progress':
case 'started':
return StatusView(
label: l10n.paymentStatusProcessing,
backgroundColor: scheme.primaryContainer,
foregroundColor: scheme.onPrimaryContainer,
);
case 'pending':
case 'queued':
case 'waiting':
case 'created':
case 'scheduled':
return StatusView(
label: l10n.operationStatusPending,
backgroundColor: scheme.secondary,
foregroundColor: scheme.onSecondary,
);
case 'needs_attention':
return StatusView(
label: l10n.operationStepStateNeedsAttention,
backgroundColor: scheme.tertiary,
foregroundColor: scheme.onTertiary,
);
case 'retrying':
return StatusView(
label: l10n.operationStepStateRetrying,
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
);
default:
return StatusView(
label: fallbackLabel ?? humanizeOperationStatusToken(token),
backgroundColor: scheme.surfaceContainerHighest,
foregroundColor: scheme.onSurfaceVariant,
);
} }
} }
String operationStatusTokenFromEnum(OperationStatus status) {
switch (status) {
case OperationStatus.pending:
return 'pending';
case OperationStatus.processing:
return 'processing';
case OperationStatus.retrying:
return 'retrying';
case OperationStatus.success:
return 'success';
case OperationStatus.skipped:
return 'skipped';
case OperationStatus.cancelled:
return 'cancelled';
case OperationStatus.needsAttention:
return 'needs_attention';
case OperationStatus.error:
return 'error';
}
}
String normalizeOperationStatusToken(String? state) {
final normalized = (state ?? '').trim().toLowerCase();
if (normalized.isEmpty) return 'pending';
return normalized
.replaceFirst(RegExp(r'^step_execution_state_'), '')
.replaceFirst(RegExp(r'^orchestration_state_'), '');
}
String humanizeOperationStatusToken(String token) {
final parts = token.split('_').where((part) => part.isNotEmpty).toList();
if (parts.isEmpty) return token;
return parts
.map(
(part) => '${part[0].toUpperCase()}${part.substring(1).toLowerCase()}',
)
.join(' ');
}

View File

@@ -10,14 +10,18 @@ import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
Future<void> downloadPaymentAct(
Future<void> downloadPaymentAct(BuildContext context, String paymentRef) async { BuildContext context, {
required String gatewayService,
required String operationRef,
}) async {
final organizations = context.read<OrganizationsProvider>(); final organizations = context.read<OrganizationsProvider>();
if (!organizations.isOrganizationSet) { if (!organizations.isOrganizationSet) {
return; return;
} }
final trimmed = paymentRef.trim(); final gateway = gatewayService.trim();
if (trimmed.isEmpty) { final operation = operationRef.trim();
if (gateway.isEmpty || operation.isEmpty) {
return; return;
} }
@@ -25,9 +29,10 @@ Future<void> downloadPaymentAct(BuildContext context, String paymentRef) async {
await executeActionWithNotification( await executeActionWithNotification(
context: context, context: context,
action: () async { action: () async {
final file = await PaymentDocumentsService.getAct( final file = await PaymentDocumentsService.getOperationDocument(
organizations.current.id, organizations.current.id,
trimmed, gateway,
operation,
); );
await downloadFile(file); await downloadFile(file);
}, },

Some files were not shown because too many files have changed in this diff Show More