Compare commits
22 Commits
SEND062
...
364731a8c7
| Author | SHA1 | Date | |
|---|---|---|---|
| 364731a8c7 | |||
|
|
519a2b1304 | ||
| d027f2deda | |||
|
|
ba5a3312b5 | ||
| f2c9685eb1 | |||
|
|
e80cb3eed1 | ||
| 5f647904d7 | |||
|
|
b6f05f52dc | ||
| 75555520f3 | |||
|
|
d666c4ce51 | ||
| 706a57e860 | |||
|
|
f7b0915303 | ||
|
|
c59538869b | ||
|
|
aff804ec58 | ||
| 2bab8371b8 | |||
|
|
af8ab8238e | ||
|
|
92a6191014 | ||
| 80b25a8608 | |||
| 17d954c689 | |||
|
|
349e8afdc5 | ||
|
|
8a1e44c038 | ||
|
|
3fcbbfb08a |
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -148,18 +147,17 @@ func (s *Service) Shutdown() {
|
||||
|
||||
func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) {
|
||||
start := time.Now()
|
||||
|
||||
var paymentRefs []string
|
||||
paymentRefs := 0
|
||||
if req != nil {
|
||||
paymentRefs = req.GetPaymentRefs()
|
||||
paymentRefs = len(req.GetPaymentRefs())
|
||||
}
|
||||
|
||||
logger := s.logger.With(zap.Int("payment_refs", len(paymentRefs)))
|
||||
logger := s.logger.With(zap.Int("payment_refs", paymentRefs))
|
||||
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start))
|
||||
observeBatchSize(len(paymentRefs))
|
||||
observeBatchSize(paymentRefs)
|
||||
|
||||
itemsCount := 0
|
||||
if resp != nil {
|
||||
@@ -181,80 +179,16 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba
|
||||
logger.Info("BatchResolveDocuments finished", fields...)
|
||||
}()
|
||||
|
||||
if len(paymentRefs) == 0 {
|
||||
resp = &documentsv1.BatchResolveDocumentsResponse{}
|
||||
_ = ctx
|
||||
err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if s.storage == nil {
|
||||
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refs := make([]string, 0, len(paymentRefs))
|
||||
for _, ref := range paymentRefs {
|
||||
clean := strings.TrimSpace(ref)
|
||||
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
refs = append(refs, clean)
|
||||
}
|
||||
|
||||
if len(refs) == 0 {
|
||||
resp = &documentsv1.BatchResolveDocumentsResponse{}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
records, err := s.storage.Documents().ListByPaymentRefs(ctx, refs)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
recordByRef := map[string]*model.DocumentRecord{}
|
||||
|
||||
for _, record := range records {
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
recordByRef[record.PaymentRef] = record
|
||||
}
|
||||
|
||||
items := make([]*documentsv1.DocumentMeta, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
meta := &documentsv1.DocumentMeta{PaymentRef: ref}
|
||||
if record := recordByRef[ref]; record != nil {
|
||||
record.Normalize()
|
||||
|
||||
available := []model.DocumentType{model.DocumentTypeAct}
|
||||
|
||||
ready := make([]model.DocumentType, 0, 1)
|
||||
if path, ok := record.StoragePaths[model.DocumentTypeAct]; ok && path != "" {
|
||||
ready = append(ready, model.DocumentTypeAct)
|
||||
}
|
||||
|
||||
meta.AvailableTypes = toProtoTypes(available)
|
||||
meta.ReadyTypes = toProtoTypes(ready)
|
||||
}
|
||||
|
||||
items = append(items, meta)
|
||||
}
|
||||
|
||||
resp = &documentsv1.BatchResolveDocumentsResponse{Items: items}
|
||||
|
||||
return resp, nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
|
||||
start := time.Now()
|
||||
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
||||
paymentRef := ""
|
||||
|
||||
if req != nil {
|
||||
docType = req.GetType()
|
||||
paymentRef = strings.TrimSpace(req.GetPaymentRef())
|
||||
@@ -293,92 +227,94 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
logger.Info("GetDocument finished", fields...)
|
||||
}()
|
||||
|
||||
if paymentRef == "" {
|
||||
err = status.Error(codes.InvalidArgument, "payment_ref is required")
|
||||
_ = ctx
|
||||
err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument")
|
||||
|
||||
return nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (s *Service) GetOperationDocument(_ context.Context, req *documentsv1.GetOperationDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) {
|
||||
start := time.Now()
|
||||
organizationRef := ""
|
||||
gatewayService := ""
|
||||
operationRef := ""
|
||||
|
||||
if req != nil {
|
||||
organizationRef = strings.TrimSpace(req.GetOrganizationRef())
|
||||
gatewayService = strings.TrimSpace(req.GetGatewayService())
|
||||
operationRef = strings.TrimSpace(req.GetOperationRef())
|
||||
}
|
||||
|
||||
if docType == documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED {
|
||||
err = status.Error(codes.InvalidArgument, "document type is required")
|
||||
logger := s.logger.With(
|
||||
zap.String("organization_ref", organizationRef),
|
||||
zap.String("gateway_service", gatewayService),
|
||||
zap.String("operation_ref", operationRef),
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
||||
observeRequest("get_operation_document", docType, statusLabel, time.Since(start))
|
||||
|
||||
if s.storage == nil {
|
||||
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.docStore == nil {
|
||||
err = status.Error(codes.Unavailable, errDocStoreUnavailable.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.template == nil {
|
||||
err = status.Error(codes.FailedPrecondition, errTemplateUnavailable.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record, err := s.storage.Documents().GetByPaymentRef(ctx, paymentRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrDocumentNotFound) {
|
||||
return nil, status.Error(codes.NotFound, "document record not found")
|
||||
if resp != nil {
|
||||
observeDocumentBytes(docType, len(resp.GetContent()))
|
||||
}
|
||||
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
record.Normalize()
|
||||
|
||||
targetType := model.DocumentTypeFromProto(docType)
|
||||
|
||||
if docType != documentsv1.DocumentType_DOCUMENT_TYPE_ACT {
|
||||
return nil, status.Error(codes.Unimplemented, "document type not implemented")
|
||||
}
|
||||
|
||||
if path, ok := record.StoragePaths[targetType]; ok && path != "" {
|
||||
content, loadErr := s.docStore.Load(ctx, path)
|
||||
if loadErr != nil {
|
||||
return nil, status.Error(codes.Internal, loadErr.Error())
|
||||
contentBytes := 0
|
||||
if resp != nil {
|
||||
contentBytes = len(resp.GetContent())
|
||||
}
|
||||
|
||||
return &documentsv1.GetDocumentResponse{
|
||||
Content: content,
|
||||
Filename: documentFilename(docType, paymentRef),
|
||||
MimeType: "application/pdf",
|
||||
}, nil
|
||||
fields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Int("content_bytes", contentBytes),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Warn("GetOperationDocument failed", append(fields, zap.Error(err))...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("GetOperationDocument finished", fields...)
|
||||
}()
|
||||
|
||||
if req == nil {
|
||||
err = status.Error(codes.InvalidArgument, "request is required")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content, hash, genErr := s.generateActPDF(record.Snapshot)
|
||||
if organizationRef == "" {
|
||||
err = status.Error(codes.InvalidArgument, "organization_ref is required")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if gatewayService == "" {
|
||||
err = status.Error(codes.InvalidArgument, "gateway_service is required")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if operationRef == "" {
|
||||
err = status.Error(codes.InvalidArgument, "operation_ref is required")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snapshot := operationSnapshotFromRequest(req)
|
||||
content, _, genErr := s.generateOperationPDF(snapshot)
|
||||
if genErr != nil {
|
||||
logger.Warn("Failed to generate document", zap.Error(genErr))
|
||||
err = status.Error(codes.Internal, genErr.Error())
|
||||
|
||||
return nil, status.Error(codes.Internal, genErr.Error())
|
||||
}
|
||||
|
||||
path := documentStoragePath(paymentRef, docType)
|
||||
if saveErr := s.docStore.Save(ctx, path, content); saveErr != nil {
|
||||
logger.Warn("Failed to store document", zap.Error(saveErr))
|
||||
|
||||
return nil, status.Error(codes.Internal, saveErr.Error())
|
||||
}
|
||||
|
||||
record.StoragePaths[targetType] = path
|
||||
record.Hashes[targetType] = hash
|
||||
|
||||
if updateErr := s.storage.Documents().Update(ctx, record); updateErr != nil {
|
||||
logger.Warn("Failed to update document record", zap.Error(updateErr))
|
||||
|
||||
return nil, status.Error(codes.Internal, updateErr.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp = &documentsv1.GetDocumentResponse{
|
||||
Content: content,
|
||||
Filename: documentFilename(docType, paymentRef),
|
||||
Filename: operationDocumentFilename(operationRef),
|
||||
MimeType: "application/pdf",
|
||||
}
|
||||
|
||||
@@ -392,7 +328,7 @@ func (s *Service) startDiscoveryAnnouncer() {
|
||||
|
||||
announce := discovery.Announcement{
|
||||
Service: mservice.BillingDocuments,
|
||||
Operations: []string{discovery.OperationDocumentsBatchResolve, discovery.OperationDocumentsGet},
|
||||
Operations: []string{discovery.OperationDocumentsGet},
|
||||
InvokeURI: s.invokeURI,
|
||||
Version: appversion.Create().Short(),
|
||||
}
|
||||
@@ -418,10 +354,19 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return s.renderPDFWithIntegrity(blocks)
|
||||
}
|
||||
|
||||
func (s *Service) generateOperationPDF(snapshot operationSnapshot) ([]byte, string, error) {
|
||||
return s.renderPDFWithIntegrity(buildOperationBlocks(snapshot))
|
||||
}
|
||||
|
||||
func (s *Service) renderPDFWithIntegrity(blocks []renderer.Block) ([]byte, string, error) {
|
||||
generated := renderer.Renderer{
|
||||
Issuer: s.config.Issuer,
|
||||
OwnerPassword: s.config.Protection.OwnerPassword,
|
||||
}
|
||||
|
||||
placeholder := strings.Repeat("0", 64)
|
||||
|
||||
firstPass, err := generated.Render(blocks, placeholder)
|
||||
@@ -440,6 +385,157 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
|
||||
return finalBytes, footerHex, nil
|
||||
}
|
||||
|
||||
type operationSnapshot struct {
|
||||
OrganizationRef string
|
||||
GatewayService string
|
||||
OperationRef string
|
||||
PaymentRef string
|
||||
OperationCode string
|
||||
OperationLabel string
|
||||
OperationState string
|
||||
FailureCode string
|
||||
FailureReason string
|
||||
Amount string
|
||||
Currency string
|
||||
StartedAt time.Time
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
func operationSnapshotFromRequest(req *documentsv1.GetOperationDocumentRequest) operationSnapshot {
|
||||
snapshot := operationSnapshot{
|
||||
OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
|
||||
GatewayService: strings.TrimSpace(req.GetGatewayService()),
|
||||
OperationRef: strings.TrimSpace(req.GetOperationRef()),
|
||||
PaymentRef: strings.TrimSpace(req.GetPaymentRef()),
|
||||
OperationCode: strings.TrimSpace(req.GetOperationCode()),
|
||||
OperationLabel: strings.TrimSpace(req.GetOperationLabel()),
|
||||
OperationState: strings.TrimSpace(req.GetOperationState()),
|
||||
FailureCode: strings.TrimSpace(req.GetFailureCode()),
|
||||
FailureReason: strings.TrimSpace(req.GetFailureReason()),
|
||||
Amount: strings.TrimSpace(req.GetAmount()),
|
||||
Currency: strings.TrimSpace(req.GetCurrency()),
|
||||
}
|
||||
|
||||
if ts := req.GetStartedAtUnixMs(); ts > 0 {
|
||||
snapshot.StartedAt = time.UnixMilli(ts).UTC()
|
||||
}
|
||||
if ts := req.GetCompletedAtUnixMs(); ts > 0 {
|
||||
snapshot.CompletedAt = time.UnixMilli(ts).UTC()
|
||||
}
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func buildOperationBlocks(snapshot operationSnapshot) []renderer.Block {
|
||||
rows := [][]string{
|
||||
{"Organization", snapshot.OrganizationRef},
|
||||
{"Gateway Service", snapshot.GatewayService},
|
||||
{"Operation Ref", snapshot.OperationRef},
|
||||
{"Payment Ref", safeValue(snapshot.PaymentRef)},
|
||||
{"Code", safeValue(snapshot.OperationCode)},
|
||||
{"State", safeValue(snapshot.OperationState)},
|
||||
{"Label", safeValue(snapshot.OperationLabel)},
|
||||
{"Started At (UTC)", formatSnapshotTime(snapshot.StartedAt)},
|
||||
{"Completed At (UTC)", formatSnapshotTime(snapshot.CompletedAt)},
|
||||
}
|
||||
if snapshot.Amount != "" || snapshot.Currency != "" {
|
||||
rows = append(rows, []string{"Amount", strings.TrimSpace(strings.TrimSpace(snapshot.Amount) + " " + strings.TrimSpace(snapshot.Currency))})
|
||||
}
|
||||
|
||||
blocks := []renderer.Block{
|
||||
{
|
||||
Tag: renderer.TagTitle,
|
||||
Lines: []string{"OPERATION BILLING DOCUMENT"},
|
||||
},
|
||||
{
|
||||
Tag: renderer.TagSubtitle,
|
||||
Lines: []string{"Gateway operation statement"},
|
||||
},
|
||||
{
|
||||
Tag: renderer.TagMeta,
|
||||
Lines: []string{
|
||||
"Document Type: Operation",
|
||||
},
|
||||
},
|
||||
{
|
||||
Tag: renderer.TagSection,
|
||||
Lines: []string{"OPERATION DETAILS"},
|
||||
},
|
||||
{
|
||||
Tag: renderer.TagKV,
|
||||
Rows: rows,
|
||||
},
|
||||
}
|
||||
|
||||
if snapshot.FailureCode != "" || snapshot.FailureReason != "" {
|
||||
blocks = append(blocks,
|
||||
renderer.Block{Tag: renderer.TagSection, Lines: []string{"FAILURE DETAILS"}},
|
||||
renderer.Block{
|
||||
Tag: renderer.TagKV,
|
||||
Rows: [][]string{
|
||||
{"Failure Code", safeValue(snapshot.FailureCode)},
|
||||
{"Failure Reason", safeValue(snapshot.FailureReason)},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
func formatSnapshotTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return "n/a"
|
||||
}
|
||||
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func safeValue(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "n/a"
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func operationDocumentFilename(operationRef string) string {
|
||||
clean := sanitizeFilenameComponent(operationRef)
|
||||
if clean == "" {
|
||||
clean = "operation"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("operation_%s.pdf", clean)
|
||||
}
|
||||
|
||||
func sanitizeFilenameComponent(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(trimmed))
|
||||
|
||||
for _, r := range trimmed {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '-', r == '_':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Trim(b.String(), "_")
|
||||
}
|
||||
|
||||
func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType {
|
||||
if len(types) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/tech/sendico/billing/documents/storage/model"
|
||||
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type stubRepo struct {
|
||||
@@ -94,9 +96,7 @@ func (s *stubTemplate) Render(_ model.ActSnapshot) ([]renderer.Block, error) {
|
||||
return s.blocks, nil
|
||||
}
|
||||
|
||||
func TestGetDocument_IdempotentAndHashed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) {
|
||||
snapshot := model.ActSnapshot{
|
||||
PaymentID: "PAY-123",
|
||||
Date: time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC),
|
||||
@@ -105,14 +105,6 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
record := &model.DocumentRecord{
|
||||
PaymentRef: "PAY-123",
|
||||
Snapshot: snapshot,
|
||||
}
|
||||
|
||||
documentsStore := &stubDocumentsStore{record: record}
|
||||
repo := &stubRepo{store: documentsStore}
|
||||
store := newMemDocStore()
|
||||
tmpl := &stubTemplate{
|
||||
blocks: []renderer.Block{
|
||||
{Tag: renderer.TagTitle, Lines: []string{"ACT"}},
|
||||
@@ -127,62 +119,47 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewService(zap.NewNop(), repo, nil,
|
||||
svc := NewService(zap.NewNop(), nil, nil,
|
||||
WithConfig(cfg),
|
||||
WithDocumentStore(store),
|
||||
WithTemplateRenderer(tmpl),
|
||||
)
|
||||
|
||||
resp1, err := svc.GetDocument(ctx, &documentsv1.GetDocumentRequest{
|
||||
PaymentRef: "PAY-123",
|
||||
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
|
||||
})
|
||||
pdf1, hash1, err := svc.generateActPDF(snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocument first call: %v", err)
|
||||
t.Fatalf("generateActPDF first call: %v", err)
|
||||
}
|
||||
|
||||
if len(resp1.GetContent()) == 0 {
|
||||
if len(pdf1) == 0 {
|
||||
t.Fatalf("expected content on first call")
|
||||
}
|
||||
|
||||
stored := record.Hashes[model.DocumentTypeAct]
|
||||
|
||||
if stored == "" {
|
||||
t.Fatalf("expected stored hash")
|
||||
if hash1 == "" {
|
||||
t.Fatalf("expected non-empty hash on first call")
|
||||
}
|
||||
|
||||
footerHash := extractFooterHash(resp1.GetContent())
|
||||
footerHash := extractFooterHash(pdf1)
|
||||
|
||||
if footerHash == "" {
|
||||
t.Fatalf("expected footer hash in PDF")
|
||||
}
|
||||
|
||||
if stored != footerHash {
|
||||
t.Fatalf("stored hash mismatch: got %s", stored)
|
||||
if hash1 != footerHash {
|
||||
t.Fatalf("stored hash mismatch: got %s", hash1)
|
||||
}
|
||||
|
||||
resp2, err := svc.GetDocument(ctx, &documentsv1.GetDocumentRequest{
|
||||
PaymentRef: "PAY-123",
|
||||
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
|
||||
})
|
||||
pdf2, hash2, err := svc.generateActPDF(snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocument second call: %v", err)
|
||||
t.Fatalf("generateActPDF second call: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(resp1.GetContent(), resp2.GetContent()) {
|
||||
t.Fatalf("expected identical PDF bytes on second call")
|
||||
if hash2 == "" {
|
||||
t.Fatalf("expected non-empty hash on second call")
|
||||
}
|
||||
|
||||
if tmpl.calls != 1 {
|
||||
t.Fatalf("expected template to be rendered once, got %d", tmpl.calls)
|
||||
footerHash2 := extractFooterHash(pdf2)
|
||||
if footerHash2 == "" {
|
||||
t.Fatalf("expected footer hash in second PDF")
|
||||
}
|
||||
|
||||
if store.saveCount != 1 {
|
||||
t.Fatalf("expected document save once, got %d", store.saveCount)
|
||||
}
|
||||
|
||||
if store.loadCount == 0 {
|
||||
t.Fatalf("expected document load on second call")
|
||||
if footerHash2 != hash2 {
|
||||
t.Fatalf("second hash mismatch: got=%s want=%s", footerHash2, hash2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,3 +189,48 @@ func extractFooterHash(pdf []byte) string {
|
||||
func isHexDigit(b byte) bool {
|
||||
return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F')
|
||||
}
|
||||
|
||||
func TestGetOperationDocument_GeneratesPDF(t *testing.T) {
|
||||
svc := NewService(zap.NewNop(), nil, nil, WithConfig(Config{
|
||||
Issuer: renderer.Issuer{
|
||||
LegalName: "Sendico Ltd",
|
||||
},
|
||||
}))
|
||||
|
||||
resp, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
|
||||
OrganizationRef: "org-1",
|
||||
GatewayService: "chain_gateway",
|
||||
OperationRef: "pay-1:step-1",
|
||||
PaymentRef: "pay-1",
|
||||
OperationCode: "crypto.transfer",
|
||||
OperationLabel: "Outbound transfer",
|
||||
OperationState: "completed",
|
||||
Amount: "100.50",
|
||||
Currency: "USDT",
|
||||
StartedAtUnixMs: time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC).UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetOperationDocument failed: %v", err)
|
||||
}
|
||||
if len(resp.GetContent()) == 0 {
|
||||
t.Fatalf("expected non-empty PDF content")
|
||||
}
|
||||
if got, want := resp.GetMimeType(), "application/pdf"; got != want {
|
||||
t.Fatalf("mime_type mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := resp.GetFilename(), "operation_pay-1_step-1.pdf"; got != want {
|
||||
t.Fatalf("filename mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOperationDocument_RequiresOperationRef(t *testing.T) {
|
||||
svc := NewService(zap.NewNop(), nil, nil)
|
||||
|
||||
_, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{
|
||||
OrganizationRef: "org-1",
|
||||
GatewayService: "chain_gateway",
|
||||
})
|
||||
if status.Code(err) != codes.InvalidArgument {
|
||||
t.Fatalf("expected InvalidArgument, got=%v err=%v", status.Code(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
@@ -77,14 +79,18 @@ type Payment struct {
|
||||
}
|
||||
|
||||
type PaymentOperation struct {
|
||||
StepRef string `json:"stepRef,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
FailureCode string `json:"failureCode,omitempty"`
|
||||
FailureReason string `json:"failureReason,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt time.Time `json:"completedAt,omitempty"`
|
||||
StepRef string `json:"stepRef,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Amount *paymenttypes.Money `json:"amount,omitempty"`
|
||||
ConvertedAmount *paymenttypes.Money `json:"convertedAmount,omitempty"`
|
||||
OperationRef string `json:"operationRef,omitempty"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
FailureCode string `json:"failureCode,omitempty"`
|
||||
FailureReason string `json:"failureReason,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt time.Time `json:"completedAt,omitempty"`
|
||||
}
|
||||
|
||||
type paymentQuoteResponse struct {
|
||||
@@ -283,7 +289,7 @@ func toPayment(p *orchestrationv2.Payment) *Payment {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
operations := toUserVisibleOperations(p.GetStepExecutions())
|
||||
operations := toUserVisibleOperations(p.GetStepExecutions(), p.GetQuoteSnapshot())
|
||||
failureCode, failureReason := firstFailure(operations)
|
||||
return &Payment{
|
||||
PaymentRef: p.GetPaymentRef(),
|
||||
@@ -308,7 +314,7 @@ func firstFailure(operations []PaymentOperation) (string, string) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOperation {
|
||||
func toUserVisibleOperations(steps []*orchestrationv2.StepExecution, quote *quotationv2.PaymentQuote) []PaymentOperation {
|
||||
if len(steps) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -317,7 +323,7 @@ func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOp
|
||||
if step == nil || !isUserVisibleStep(step.GetReportVisibility()) {
|
||||
continue
|
||||
}
|
||||
ops = append(ops, toPaymentOperation(step))
|
||||
ops = append(ops, toPaymentOperation(step, quote))
|
||||
}
|
||||
if len(ops) == 0 {
|
||||
return nil
|
||||
@@ -325,14 +331,20 @@ func toUserVisibleOperations(steps []*orchestrationv2.StepExecution) []PaymentOp
|
||||
return ops
|
||||
}
|
||||
|
||||
func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation {
|
||||
func toPaymentOperation(step *orchestrationv2.StepExecution, quote *quotationv2.PaymentQuote) PaymentOperation {
|
||||
operationRef, gateway := operationRefAndGateway(step.GetStepCode(), step.GetRefs())
|
||||
amount, convertedAmount := operationAmounts(step.GetStepCode(), quote)
|
||||
op := PaymentOperation{
|
||||
StepRef: step.GetStepRef(),
|
||||
Code: step.GetStepCode(),
|
||||
State: enumJSONName(step.GetState().String()),
|
||||
Label: strings.TrimSpace(step.GetUserLabel()),
|
||||
StartedAt: timestampAsTime(step.GetStartedAt()),
|
||||
CompletedAt: timestampAsTime(step.GetCompletedAt()),
|
||||
StepRef: step.GetStepRef(),
|
||||
Code: step.GetStepCode(),
|
||||
State: enumJSONName(step.GetState().String()),
|
||||
Label: strings.TrimSpace(step.GetUserLabel()),
|
||||
Amount: amount,
|
||||
ConvertedAmount: convertedAmount,
|
||||
OperationRef: operationRef,
|
||||
Gateway: string(gateway),
|
||||
StartedAt: timestampAsTime(step.GetStartedAt()),
|
||||
CompletedAt: timestampAsTime(step.GetCompletedAt()),
|
||||
}
|
||||
failure := step.GetFailure()
|
||||
if failure == nil {
|
||||
@@ -346,6 +358,165 @@ func toPaymentOperation(step *orchestrationv2.StepExecution) PaymentOperation {
|
||||
return op
|
||||
}
|
||||
|
||||
func operationAmounts(stepCode string, quote *quotationv2.PaymentQuote) (*paymenttypes.Money, *paymenttypes.Money) {
|
||||
if quote == nil {
|
||||
return nil, nil
|
||||
}
|
||||
operation := stepOperationToken(stepCode)
|
||||
|
||||
primary := firstValidMoney(
|
||||
toMoney(quote.GetDestinationAmount()),
|
||||
toMoney(quote.GetTransferPrincipalAmount()),
|
||||
toMoney(quote.GetPayerTotalDebitAmount()),
|
||||
)
|
||||
if operation != "fx_convert" {
|
||||
return primary, nil
|
||||
}
|
||||
|
||||
base := firstValidMoney(
|
||||
toMoney(quote.GetTransferPrincipalAmount()),
|
||||
toMoney(quote.GetPayerTotalDebitAmount()),
|
||||
toMoney(quote.GetFxQuote().GetBaseAmount()),
|
||||
)
|
||||
quoteAmount := firstValidMoney(
|
||||
toMoney(quote.GetDestinationAmount()),
|
||||
toMoney(quote.GetFxQuote().GetQuoteAmount()),
|
||||
)
|
||||
return base, quoteAmount
|
||||
}
|
||||
|
||||
func stepOperationToken(stepCode string) string {
|
||||
parts := strings.Split(strings.ToLower(strings.TrimSpace(stepCode)), ".")
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parts[len(parts)-1])
|
||||
}
|
||||
|
||||
func firstValidMoney(values ...*paymenttypes.Money) *paymenttypes.Money {
|
||||
for _, value := range values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(value.GetAmount()) == "" || strings.TrimSpace(value.GetCurrency()) == "" {
|
||||
continue
|
||||
}
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
externalRefKindOperation = "operation_ref"
|
||||
)
|
||||
|
||||
func operationRefAndGateway(stepCode string, refs []*orchestrationv2.ExternalReference) (string, mservice.Type) {
|
||||
var (
|
||||
operationRef string
|
||||
gateway mservice.Type
|
||||
)
|
||||
|
||||
for _, ref := range refs {
|
||||
if ref == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
kind := strings.ToLower(strings.TrimSpace(ref.GetKind()))
|
||||
value := strings.TrimSpace(ref.GetRef())
|
||||
candidateGateway := inferGatewayType(ref.GetGatewayInstanceId(), ref.GetRail(), stepCode)
|
||||
|
||||
if kind == externalRefKindOperation && operationRef == "" && value != "" {
|
||||
operationRef = value
|
||||
}
|
||||
if gateway == "" && candidateGateway != "" {
|
||||
gateway = candidateGateway
|
||||
}
|
||||
}
|
||||
if gateway == "" {
|
||||
gateway = inferGatewayType("", gatewayv1.Rail_RAIL_UNSPECIFIED, stepCode)
|
||||
}
|
||||
return operationRef, gateway
|
||||
}
|
||||
|
||||
func inferGatewayType(gatewayInstanceID string, rail gatewayv1.Rail, stepCode string) mservice.Type {
|
||||
if gateway := gatewayTypeFromInstanceID(gatewayInstanceID); gateway != "" {
|
||||
return gateway
|
||||
}
|
||||
if gateway := gatewayTypeFromRail(rail); gateway != "" {
|
||||
return gateway
|
||||
}
|
||||
return gatewayTypeFromStepCode(stepCode)
|
||||
}
|
||||
|
||||
func gatewayTypeFromInstanceID(raw string) mservice.Type {
|
||||
value := strings.ToLower(strings.TrimSpace(raw))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch mservice.Type(value) {
|
||||
case mservice.ChainGateway, mservice.TronGateway, mservice.MntxGateway, mservice.PaymentGateway, mservice.TgSettle, mservice.Ledger:
|
||||
return mservice.Type(value)
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(value, "ledger"):
|
||||
return mservice.Ledger
|
||||
case strings.Contains(value, "tgsettle"):
|
||||
return mservice.TgSettle
|
||||
case strings.Contains(value, "payment_gateway"),
|
||||
strings.Contains(value, "settlement"),
|
||||
strings.Contains(value, "onramp"),
|
||||
strings.Contains(value, "offramp"):
|
||||
return mservice.PaymentGateway
|
||||
case strings.Contains(value, "mntx"), strings.Contains(value, "mcards"):
|
||||
return mservice.MntxGateway
|
||||
case strings.Contains(value, "tron"):
|
||||
return mservice.TronGateway
|
||||
case strings.Contains(value, "chain"), strings.Contains(value, "crypto"):
|
||||
return mservice.ChainGateway
|
||||
case strings.Contains(value, "card"):
|
||||
return mservice.MntxGateway
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func gatewayTypeFromRail(rail gatewayv1.Rail) mservice.Type {
|
||||
switch rail {
|
||||
case gatewayv1.Rail_RAIL_LEDGER:
|
||||
return mservice.Ledger
|
||||
case gatewayv1.Rail_RAIL_CARD:
|
||||
return mservice.MntxGateway
|
||||
case gatewayv1.Rail_RAIL_SETTLEMENT, gatewayv1.Rail_RAIL_ONRAMP, gatewayv1.Rail_RAIL_OFFRAMP:
|
||||
return mservice.PaymentGateway
|
||||
case gatewayv1.Rail_RAIL_CRYPTO:
|
||||
return mservice.ChainGateway
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func gatewayTypeFromStepCode(stepCode string) mservice.Type {
|
||||
code := strings.ToLower(strings.TrimSpace(stepCode))
|
||||
switch {
|
||||
case strings.Contains(code, "ledger"):
|
||||
return mservice.Ledger
|
||||
case strings.Contains(code, "card_payout"), strings.Contains(code, ".card."):
|
||||
return mservice.MntxGateway
|
||||
case strings.Contains(code, "provider_settlement"),
|
||||
strings.Contains(code, "settlement"),
|
||||
strings.Contains(code, "fx_convert"),
|
||||
strings.Contains(code, "onramp"),
|
||||
strings.Contains(code, "offramp"):
|
||||
return mservice.PaymentGateway
|
||||
case strings.Contains(code, "crypto"), strings.Contains(code, "chain"):
|
||||
return mservice.ChainGateway
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isUserVisibleStep(visibility orchestrationv2.ReportVisibility) bool {
|
||||
switch visibility {
|
||||
case orchestrationv2.ReportVisibility_REPORT_VISIBILITY_HIDDEN,
|
||||
|
||||
@@ -3,6 +3,8 @@ package sresponse
|
||||
import (
|
||||
"testing"
|
||||
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
@@ -32,7 +34,7 @@ func TestToUserVisibleOperationsFiltersByVisibility(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ops := toUserVisibleOperations(steps)
|
||||
ops := toUserVisibleOperations(steps, nil)
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("operations count mismatch: got=%d want=2", len(ops))
|
||||
}
|
||||
@@ -134,3 +136,118 @@ func TestToPaymentQuote_MapsIntentRef(t *testing.T) {
|
||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPaymentOperation_MapsOperationRefAndGateway(t *testing.T) {
|
||||
op := toPaymentOperation(&orchestrationv2.StepExecution{
|
||||
StepRef: "step-1",
|
||||
StepCode: "hop.4.card_payout.send",
|
||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
|
||||
Refs: []*orchestrationv2.ExternalReference{
|
||||
{
|
||||
Rail: gatewayv1.Rail_RAIL_CARD,
|
||||
GatewayInstanceId: "mcards",
|
||||
Kind: "operation_ref",
|
||||
Ref: "op-123",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
if got, want := op.OperationRef, "op-123"; got != want {
|
||||
t.Fatalf("operation_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := op.Gateway, "mntx_gateway"; got != want {
|
||||
t.Fatalf("gateway mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPaymentOperation_InfersGatewayFromStepCode(t *testing.T) {
|
||||
op := toPaymentOperation(&orchestrationv2.StepExecution{
|
||||
StepRef: "step-2",
|
||||
StepCode: "edge.1_2.ledger.debit",
|
||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
|
||||
}, nil)
|
||||
|
||||
if got := op.OperationRef; got != "" {
|
||||
t.Fatalf("expected empty operation_ref, got=%q", got)
|
||||
}
|
||||
if got, want := op.Gateway, "ledger"; got != want {
|
||||
t.Fatalf("gateway mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPaymentOperation_DoesNotFallbackToCardPayoutRef(t *testing.T) {
|
||||
op := toPaymentOperation(&orchestrationv2.StepExecution{
|
||||
StepRef: "step-3",
|
||||
StepCode: "hop.4.card_payout.send",
|
||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
|
||||
Refs: []*orchestrationv2.ExternalReference{
|
||||
{
|
||||
Rail: gatewayv1.Rail_RAIL_CARD,
|
||||
GatewayInstanceId: "mcards",
|
||||
Kind: "card_payout_ref",
|
||||
Ref: "payout-123",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
|
||||
if got := op.OperationRef; got != "" {
|
||||
t.Fatalf("expected empty operation_ref, got=%q", got)
|
||||
}
|
||||
if got, want := op.Gateway, "mntx_gateway"; got != want {
|
||||
t.Fatalf("gateway mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToPaymentOperation_MapsAmount(t *testing.T) {
|
||||
op := toPaymentOperation(&orchestrationv2.StepExecution{
|
||||
StepRef: "step-4",
|
||||
StepCode: "hop.4.card_payout.send",
|
||||
State: orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED,
|
||||
}, "ationv2.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,
|
||||
}, "ationv2.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -23,43 +24,90 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
const (
|
||||
documentsServiceName = "BILLING_DOCUMENTS"
|
||||
documentsOperationGet = discovery.OperationDocumentsGet
|
||||
documentsDialTimeout = 5 * time.Second
|
||||
documentsCallTimeout = 10 * time.Second
|
||||
gatewayCallTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var allowedOperationGatewayServices = map[mservice.Type]struct{}{
|
||||
mservice.ChainGateway: {},
|
||||
mservice.TronGateway: {},
|
||||
mservice.MntxGateway: {},
|
||||
mservice.PaymentGateway: {},
|
||||
mservice.TgSettle: {},
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) getOperationDocument(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, denied := a.authorizeDocumentDownload(r, account)
|
||||
if denied != nil {
|
||||
return denied
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
gatewayService := normalizeGatewayService(query.Get("gateway_service"))
|
||||
if gatewayService == "" {
|
||||
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "gateway_service is required")
|
||||
}
|
||||
if _, ok := allowedOperationGatewayServices[gatewayService]; !ok {
|
||||
return response.BadRequest(a.logger, a.Name(), "invalid_parameter", "unsupported gateway_service")
|
||||
}
|
||||
|
||||
operationRef := strings.TrimSpace(query.Get("operation_ref"))
|
||||
if operationRef == "" {
|
||||
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "operation_ref is required")
|
||||
}
|
||||
|
||||
service, gateway, h := a.resolveOperationDocumentDeps(r.Context(), gatewayService)
|
||||
if h != nil {
|
||||
return h
|
||||
}
|
||||
|
||||
op, err := a.fetchGatewayOperation(r.Context(), gateway.InvokeURI, operationRef)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch gateway operation for document generation", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), zap.String("gateway_service", string(gatewayService)), zap.String("operation_ref", operationRef))
|
||||
return documentErrorResponse(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
req := operationDocumentRequest(orgRef.Hex(), gatewayService, operationRef, op)
|
||||
|
||||
docResp, err := a.fetchOperationDocument(r.Context(), service.InvokeURI, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch operation document", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), zap.String("gateway_service", string(gatewayService)), zap.String("operation_ref", operationRef))
|
||||
return documentErrorResponse(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return operationDocumentResponse(a.logger, a.Name(), docResp, fmt.Sprintf("operation_%s.pdf", sanitizeFilenameComponent(operationRef)))
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) authorizeDocumentDownload(r *http.Request, account *model.Account) (bson.ObjectID, http.HandlerFunc) {
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization reference for document request", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
return bson.NilObjectID, response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
return bson.NilObjectID, response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !allowed {
|
||||
a.logger.Debug("Access denied when downloading act", mutil.PLog(a.oph, r))
|
||||
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
|
||||
a.logger.Debug("Access denied when downloading document", mutil.PLog(a.oph, r))
|
||||
return bson.NilObjectID, response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
|
||||
}
|
||||
|
||||
paymentRef := strings.TrimSpace(r.URL.Query().Get("payment_ref"))
|
||||
if paymentRef == "" {
|
||||
paymentRef = strings.TrimSpace(r.URL.Query().Get("paymentRef"))
|
||||
}
|
||||
if paymentRef == "" {
|
||||
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "payment_ref is required")
|
||||
}
|
||||
return orgRef, nil
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) resolveOperationDocumentDeps(ctx context.Context, gatewayService mservice.Type) (*discovery.ServiceSummary, *discovery.GatewaySummary, http.HandlerFunc) {
|
||||
if a.discovery == nil {
|
||||
return response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "discovery client is not configured")
|
||||
return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "discovery client is not configured")
|
||||
}
|
||||
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout)
|
||||
@@ -68,27 +116,35 @@ func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ *
|
||||
lookupResp, err := a.discovery.Lookup(lookupCtx)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to lookup discovery registry", zap.Error(err))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
return nil, nil, response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
service := findDocumentsService(lookupResp.Services)
|
||||
if service == nil {
|
||||
return response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "billing documents service unavailable")
|
||||
return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "billing documents service unavailable")
|
||||
}
|
||||
|
||||
docResp, err := a.fetchActDocument(ctx, service.InvokeURI, paymentRef)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch act document", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return documentErrorResponse(a.logger, a.Name(), err)
|
||||
gateway := findGatewayForService(lookupResp.Gateways, gatewayService)
|
||||
if gateway == nil {
|
||||
return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "gateway service unavailable")
|
||||
}
|
||||
if len(docResp.GetContent()) == 0 {
|
||||
return response.Error(a.logger, a.Name(), http.StatusInternalServerError, "empty_document", "document service returned empty payload")
|
||||
|
||||
return service, gateway, nil
|
||||
}
|
||||
|
||||
func operationDocumentResponse(logger mlogger.Logger, source mservice.Type, docResp *documentsv1.GetDocumentResponse, fallbackFilename string) http.HandlerFunc {
|
||||
if docResp == nil || len(docResp.GetContent()) == 0 {
|
||||
return response.Error(logger, source, http.StatusInternalServerError, "empty_document", "document service returned empty payload")
|
||||
}
|
||||
|
||||
filename := strings.TrimSpace(docResp.GetFilename())
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("act_%s.pdf", paymentRef)
|
||||
filename = strings.TrimSpace(fallbackFilename)
|
||||
}
|
||||
if filename == "" {
|
||||
filename = "document.pdf"
|
||||
}
|
||||
|
||||
mimeType := strings.TrimSpace(docResp.GetMimeType())
|
||||
if mimeType == "" {
|
||||
mimeType = "application/pdf"
|
||||
@@ -98,13 +154,67 @@ func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ *
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, writeErr := w.Write(docResp.GetContent()); writeErr != nil {
|
||||
a.logger.Warn("Failed to write document response", zap.Error(writeErr))
|
||||
if _, err := w.Write(docResp.GetContent()); err != nil {
|
||||
logger.Warn("Failed to write document response", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) fetchActDocument(ctx context.Context, invokeURI, paymentRef string) (*documentsv1.GetDocumentResponse, error) {
|
||||
func normalizeGatewayService(raw string) mservice.Type {
|
||||
value := strings.ToLower(strings.TrimSpace(raw))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch value {
|
||||
case string(mservice.ChainGateway):
|
||||
return mservice.ChainGateway
|
||||
case string(mservice.TronGateway):
|
||||
return mservice.TronGateway
|
||||
case string(mservice.MntxGateway):
|
||||
return mservice.MntxGateway
|
||||
case string(mservice.PaymentGateway):
|
||||
return mservice.PaymentGateway
|
||||
case string(mservice.TgSettle):
|
||||
return mservice.TgSettle
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeFilenameComponent(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(trimmed))
|
||||
|
||||
for _, r := range trimmed {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '-', r == '_':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
|
||||
clean := strings.Trim(b.String(), "_")
|
||||
if clean == "" {
|
||||
return "operation"
|
||||
}
|
||||
|
||||
return clean
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) fetchOperationDocument(ctx context.Context, invokeURI string, req *documentsv1.GetOperationDocumentRequest) (*documentsv1.GetDocumentResponse, error) {
|
||||
conn, err := grpc.NewClient(invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "dial billing documents")
|
||||
@@ -116,10 +226,160 @@ func (a *PaymentAPI) fetchActDocument(ctx context.Context, invokeURI, paymentRef
|
||||
callCtx, callCancel := context.WithTimeout(ctx, documentsCallTimeout)
|
||||
defer callCancel()
|
||||
|
||||
return client.GetDocument(callCtx, &documentsv1.GetDocumentRequest{
|
||||
PaymentRef: paymentRef,
|
||||
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
|
||||
})
|
||||
return client.GetOperationDocument(callCtx, req)
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) fetchGatewayOperation(ctx context.Context, invokeURI, operationRef string) (*connectorv1.Operation, error) {
|
||||
conn, err := grpc.NewClient(invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "dial gateway connector")
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := connectorv1.NewConnectorServiceClient(conn)
|
||||
|
||||
callCtx, callCancel := context.WithTimeout(ctx, gatewayCallTimeout)
|
||||
defer callCancel()
|
||||
|
||||
resp, err := client.GetOperation(callCtx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(operationRef)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
op := resp.GetOperation()
|
||||
if op == nil {
|
||||
return nil, merrors.NoData("gateway returned empty operation payload")
|
||||
}
|
||||
|
||||
return op, nil
|
||||
}
|
||||
|
||||
func findGatewayForService(gateways []discovery.GatewaySummary, gatewayService mservice.Type) *discovery.GatewaySummary {
|
||||
candidates := make([]discovery.GatewaySummary, 0, len(gateways))
|
||||
for _, gw := range gateways {
|
||||
if !gw.Healthy || strings.TrimSpace(gw.InvokeURI) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
rail := discovery.NormalizeRail(gw.Rail)
|
||||
network := strings.ToLower(strings.TrimSpace(gw.Network))
|
||||
switch gatewayService {
|
||||
case mservice.MntxGateway:
|
||||
if rail == discovery.NormalizeRail(discovery.RailCardPayout) {
|
||||
candidates = append(candidates, gw)
|
||||
}
|
||||
case mservice.PaymentGateway, mservice.TgSettle:
|
||||
if rail == discovery.NormalizeRail(discovery.RailProviderSettlement) {
|
||||
candidates = append(candidates, gw)
|
||||
}
|
||||
case mservice.TronGateway:
|
||||
if rail == discovery.NormalizeRail(discovery.RailCrypto) && strings.Contains(network, "tron") {
|
||||
candidates = append(candidates, gw)
|
||||
}
|
||||
case mservice.ChainGateway:
|
||||
if rail == discovery.NormalizeRail(discovery.RailCrypto) && !strings.Contains(network, "tron") {
|
||||
candidates = append(candidates, gw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(candidates) == 0 && gatewayService == mservice.ChainGateway {
|
||||
for _, gw := range gateways {
|
||||
if gw.Healthy && strings.TrimSpace(gw.InvokeURI) != "" && discovery.NormalizeRail(gw.Rail) == discovery.NormalizeRail(discovery.RailCrypto) {
|
||||
candidates = append(candidates, gw)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
best := candidates[0]
|
||||
for _, candidate := range candidates[1:] {
|
||||
if candidate.RoutingPriority > best.RoutingPriority {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return &best
|
||||
}
|
||||
|
||||
func operationDocumentRequest(organizationRef string, gatewayService mservice.Type, requestedOperationRef string, op *connectorv1.Operation) *documentsv1.GetOperationDocumentRequest {
|
||||
req := &documentsv1.GetOperationDocumentRequest{
|
||||
OrganizationRef: strings.TrimSpace(organizationRef),
|
||||
GatewayService: string(gatewayService),
|
||||
OperationRef: firstNonEmpty(strings.TrimSpace(op.GetOperationRef()), strings.TrimSpace(requestedOperationRef)),
|
||||
OperationCode: strings.TrimSpace(op.GetType().String()),
|
||||
OperationLabel: operationLabel(op.GetType()),
|
||||
OperationState: strings.TrimSpace(op.GetStatus().String()),
|
||||
Amount: strings.TrimSpace(op.GetMoney().GetAmount()),
|
||||
Currency: strings.TrimSpace(op.GetMoney().GetCurrency()),
|
||||
}
|
||||
|
||||
if ts := op.GetCreatedAt(); ts != nil {
|
||||
req.StartedAtUnixMs = ts.AsTime().UnixMilli()
|
||||
}
|
||||
if ts := op.GetUpdatedAt(); ts != nil {
|
||||
req.CompletedAtUnixMs = ts.AsTime().UnixMilli()
|
||||
}
|
||||
|
||||
req.PaymentRef = operationParamValue(op.GetParams(), "payment_ref", "parent_payment_ref", "paymentRef", "parentPaymentRef")
|
||||
req.FailureCode = firstNonEmpty(
|
||||
operationParamValue(op.GetParams(), "failure_code", "provider_code", "error_code"),
|
||||
failureCodeFromStatus(op.GetStatus()),
|
||||
)
|
||||
req.FailureReason = operationParamValue(op.GetParams(), "failure_reason", "provider_message", "error", "message")
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
func operationLabel(opType connectorv1.OperationType) string {
|
||||
switch opType {
|
||||
case connectorv1.OperationType_CREDIT:
|
||||
return "Credit"
|
||||
case connectorv1.OperationType_DEBIT:
|
||||
return "Debit"
|
||||
case connectorv1.OperationType_TRANSFER:
|
||||
return "Transfer"
|
||||
case connectorv1.OperationType_PAYOUT:
|
||||
return "Payout"
|
||||
case connectorv1.OperationType_FEE_ESTIMATE:
|
||||
return "Fee Estimate"
|
||||
case connectorv1.OperationType_FX:
|
||||
return "FX"
|
||||
case connectorv1.OperationType_GAS_TOPUP:
|
||||
return "Gas Top Up"
|
||||
default:
|
||||
return strings.TrimSpace(opType.String())
|
||||
}
|
||||
}
|
||||
|
||||
func failureCodeFromStatus(status connectorv1.OperationStatus) string {
|
||||
switch status {
|
||||
case connectorv1.OperationStatus_OPERATION_FAILED, connectorv1.OperationStatus_OPERATION_CANCELLED:
|
||||
return strings.TrimSpace(status.String())
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func operationParamValue(params *structpb.Struct, keys ...string) string {
|
||||
if params == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
values := params.AsMap()
|
||||
for _, key := range keys {
|
||||
raw, ok := values[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if text := strings.TrimSpace(fmt.Sprint(raw)); text != "" && text != "<nil>" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func findDocumentsService(services []discovery.ServiceSummary) *discovery.ServiceSummary {
|
||||
|
||||
@@ -106,7 +106,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-quote"), api.Post, p.initiateByQuote)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-multiquote"), api.Post, p.initiatePaymentsByQuote)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listPayments)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/documents/act"), api.Get, p.getActDocument)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/documents/operation"), api.Get, p.getOperationDocument)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry"), api.Get, p.listDiscoveryRegistry)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry/refresh"), api.Get, p.getDiscoveryRefresh)
|
||||
|
||||
@@ -207,7 +207,7 @@ type grpcQuotationClient struct {
|
||||
callTimeout time.Duration
|
||||
}
|
||||
|
||||
func newQuotationClient(ctx context.Context, cfg quotationClientConfig, opts ...grpc.DialOption) (quotationClient, error) {
|
||||
func newQuotationClient(_ context.Context, cfg quotationClientConfig, opts ...grpc.DialOption) (quotationClient, error) {
|
||||
cfg.setDefaults()
|
||||
if strings.TrimSpace(cfg.Address) == "" {
|
||||
return nil, merrors.InvalidArgument("payment quotation: address is required")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/gateway/chain/internal/appversion"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
chainstoragemodel "github.com/tech/sendico/gateway/chain/storage/model"
|
||||
chainasset "github.com/tech/sendico/pkg/chain"
|
||||
"github.com/tech/sendico/pkg/connector/params"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
const chainConnectorID = "chain"
|
||||
@@ -293,11 +295,21 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio
|
||||
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
|
||||
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
|
||||
}
|
||||
resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())})
|
||||
|
||||
operationRef := strings.TrimSpace(req.GetOperationId())
|
||||
if s.storage == nil || s.storage.Transfers() == nil {
|
||||
return nil, merrors.Internal("get_operation: storage is not configured")
|
||||
}
|
||||
|
||||
transfer, err := s.storage.Transfers().FindByOperationRef(ctx, "", operationRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(resp.GetTransfer())}, nil
|
||||
if transfer == nil {
|
||||
return nil, merrors.NoData("transfer not found")
|
||||
}
|
||||
|
||||
return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(storageTransferToProto(transfer))}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListOperations(ctx context.Context, req *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
|
||||
@@ -493,6 +505,61 @@ func feeEstimateResult(resp *chainv1.EstimateTransferFeeResponse) *structpb.Stru
|
||||
return result
|
||||
}
|
||||
|
||||
func storageTransferToProto(transfer *chainstoragemodel.Transfer) *chainv1.Transfer {
|
||||
if transfer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
destination := &chainv1.TransferDestination{Memo: strings.TrimSpace(transfer.Destination.Memo)}
|
||||
if managedWalletRef := strings.TrimSpace(transfer.Destination.ManagedWalletRef); managedWalletRef != "" {
|
||||
destination.Destination = &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: managedWalletRef}
|
||||
} else if externalAddress := strings.TrimSpace(transfer.Destination.ExternalAddress); externalAddress != "" {
|
||||
destination.Destination = &chainv1.TransferDestination_ExternalAddress{ExternalAddress: externalAddress}
|
||||
}
|
||||
|
||||
fees := make([]*chainv1.ServiceFeeBreakdown, 0, len(transfer.Fees))
|
||||
for _, fee := range transfer.Fees {
|
||||
fees = append(fees, &chainv1.ServiceFeeBreakdown{
|
||||
FeeCode: strings.TrimSpace(fee.FeeCode),
|
||||
Amount: fee.Amount,
|
||||
Description: strings.TrimSpace(fee.Description),
|
||||
})
|
||||
}
|
||||
|
||||
asset := &chainv1.Asset{
|
||||
Chain: shared.ChainEnumFromName(transfer.Network),
|
||||
TokenSymbol: strings.TrimSpace(transfer.TokenSymbol),
|
||||
ContractAddress: strings.TrimSpace(transfer.ContractAddress),
|
||||
}
|
||||
|
||||
protoTransfer := &chainv1.Transfer{
|
||||
TransferRef: strings.TrimSpace(transfer.TransferRef),
|
||||
IdempotencyKey: strings.TrimSpace(transfer.IdempotencyKey),
|
||||
IntentRef: strings.TrimSpace(transfer.IntentRef),
|
||||
OperationRef: strings.TrimSpace(transfer.OperationRef),
|
||||
OrganizationRef: strings.TrimSpace(transfer.OrganizationRef),
|
||||
SourceWalletRef: strings.TrimSpace(transfer.SourceWalletRef),
|
||||
Destination: destination,
|
||||
Asset: asset,
|
||||
RequestedAmount: shared.MonenyToProto(transfer.RequestedAmount),
|
||||
NetAmount: shared.MonenyToProto(transfer.NetAmount),
|
||||
Fees: fees,
|
||||
Status: shared.TransferStatusToProto(transfer.Status),
|
||||
TransactionHash: strings.TrimSpace(transfer.TxHash),
|
||||
FailureReason: strings.TrimSpace(transfer.FailureReason),
|
||||
PaymentRef: strings.TrimSpace(transfer.PaymentRef),
|
||||
}
|
||||
|
||||
if !transfer.CreatedAt.IsZero() {
|
||||
protoTransfer.CreatedAt = timestamppb.New(transfer.CreatedAt.UTC())
|
||||
}
|
||||
if !transfer.UpdatedAt.IsZero() {
|
||||
protoTransfer.UpdatedAt = timestamppb.New(transfer.UpdatedAt.UTC())
|
||||
}
|
||||
|
||||
return protoTransfer
|
||||
}
|
||||
|
||||
func gasTopUpResult(amount *moneyv1.Money, capHit bool, transferRef string) *structpb.Struct {
|
||||
payload := map[string]interface{}{
|
||||
"cap_hit": capHit,
|
||||
@@ -518,18 +585,33 @@ func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation
|
||||
return nil
|
||||
}
|
||||
op := &connectorv1.Operation{
|
||||
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
|
||||
Type: connectorv1.OperationType_TRANSFER,
|
||||
Status: chainTransferStatusToOperation(transfer.GetStatus()),
|
||||
Money: transfer.GetRequestedAmount(),
|
||||
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
|
||||
CreatedAt: transfer.GetCreatedAt(),
|
||||
UpdatedAt: transfer.GetUpdatedAt(),
|
||||
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
|
||||
Type: connectorv1.OperationType_TRANSFER,
|
||||
Status: chainTransferStatusToOperation(transfer.GetStatus()),
|
||||
Money: transfer.GetRequestedAmount(),
|
||||
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
|
||||
IntentRef: strings.TrimSpace(transfer.GetIntentRef()),
|
||||
OperationRef: strings.TrimSpace(transfer.GetOperationRef()),
|
||||
CreatedAt: transfer.GetCreatedAt(),
|
||||
UpdatedAt: transfer.GetUpdatedAt(),
|
||||
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
||||
ConnectorId: chainConnectorID,
|
||||
AccountId: strings.TrimSpace(transfer.GetSourceWalletRef()),
|
||||
}}},
|
||||
}
|
||||
params := map[string]interface{}{}
|
||||
if paymentRef := strings.TrimSpace(transfer.GetPaymentRef()); paymentRef != "" {
|
||||
params["payment_ref"] = paymentRef
|
||||
}
|
||||
if organizationRef := strings.TrimSpace(transfer.GetOrganizationRef()); organizationRef != "" {
|
||||
params["organization_ref"] = organizationRef
|
||||
}
|
||||
if failureReason := strings.TrimSpace(transfer.GetFailureReason()); failureReason != "" {
|
||||
params["failure_reason"] = failureReason
|
||||
}
|
||||
if len(params) > 0 {
|
||||
op.Params = structFromMap(params)
|
||||
}
|
||||
if dest := transfer.GetDestination(); dest != nil {
|
||||
switch d := dest.GetDestination().(type) {
|
||||
case *chainv1.TransferDestination_ManagedWalletRef:
|
||||
@@ -629,6 +711,17 @@ func operationAccountID(party *connectorv1.OperationParty) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func structFromMap(values map[string]interface{}) *structpb.Struct {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result, err := structpb.NewStruct(values)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
|
||||
err := &connectorv1.ConnectorError{
|
||||
Code: code,
|
||||
|
||||
@@ -500,6 +500,32 @@ func (t *inMemoryTransfers) Get(ctx context.Context, transferRef string) (*model
|
||||
return transfer, nil
|
||||
}
|
||||
|
||||
func (t *inMemoryTransfers) FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
org := strings.TrimSpace(organizationRef)
|
||||
opRef := strings.TrimSpace(operationRef)
|
||||
if opRef == "" {
|
||||
return nil, merrors.InvalidArgument("transfersStore: empty operationRef")
|
||||
}
|
||||
|
||||
for _, transfer := range t.items {
|
||||
if transfer == nil {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(transfer.OperationRef), opRef) {
|
||||
continue
|
||||
}
|
||||
if org != "" && !strings.EqualFold(strings.TrimSpace(transfer.OrganizationRef), org) {
|
||||
continue
|
||||
}
|
||||
return transfer, nil
|
||||
}
|
||||
|
||||
return nil, merrors.NoData("transfer not found")
|
||||
}
|
||||
|
||||
func (t *inMemoryTransfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
@@ -40,6 +40,9 @@ func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error)
|
||||
Keys: []ri.Key{{Field: "transferRef", Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}, {Field: "operationRef", Sort: ri.Asc}},
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
@@ -110,6 +113,25 @@ func (t *Transfers) Get(ctx context.Context, transferRef string) (*model.Transfe
|
||||
return transfer, nil
|
||||
}
|
||||
|
||||
func (t *Transfers) FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) {
|
||||
operationRef = strings.TrimSpace(operationRef)
|
||||
if operationRef == "" {
|
||||
return nil, merrors.InvalidArgument("transfersStore: empty operationRef")
|
||||
}
|
||||
|
||||
query := repository.Query().Filter(repository.Field("operationRef"), operationRef)
|
||||
if org := strings.TrimSpace(organizationRef); org != "" {
|
||||
query = query.Filter(repository.Field("organizationRef"), org)
|
||||
}
|
||||
|
||||
transfer := &model.Transfer{}
|
||||
if err := t.repo.FindOneByFilter(ctx, query, transfer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return transfer, nil
|
||||
}
|
||||
|
||||
func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
|
||||
query := repository.Query()
|
||||
if src := strings.TrimSpace(filter.SourceWalletRef); src != "" {
|
||||
|
||||
@@ -42,6 +42,7 @@ type WalletsStore interface {
|
||||
type TransfersStore interface {
|
||||
Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error)
|
||||
Get(ctx context.Context, transferRef string) (*model.Transfer, error)
|
||||
FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error)
|
||||
List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error)
|
||||
UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ mcards:
|
||||
request_timeout_seconds: 15
|
||||
status_success: "success"
|
||||
status_processing: "processing"
|
||||
strict_operation_mode: true
|
||||
strict_operation_mode: false
|
||||
|
||||
gateway:
|
||||
id: "mcards"
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
const mntxConnectorID = "mntx"
|
||||
@@ -92,11 +93,21 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio
|
||||
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
|
||||
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
|
||||
}
|
||||
resp, err := s.GetCardPayoutStatus(ctx, &mntxv1.GetCardPayoutStatusRequest{PayoutId: strings.TrimSpace(req.GetOperationId())})
|
||||
|
||||
operationRef := strings.TrimSpace(req.GetOperationId())
|
||||
if s.storage == nil || s.storage.Payouts() == nil {
|
||||
return nil, merrors.Internal("get_operation: storage is not configured")
|
||||
}
|
||||
|
||||
payout, err := s.storage.Payouts().FindByOperationRef(ctx, operationRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &connectorv1.GetOperationResponse{Operation: payoutToOperation(resp.GetPayout())}, nil
|
||||
if payout == nil {
|
||||
return nil, merrors.NoData("payout not found")
|
||||
}
|
||||
|
||||
return &connectorv1.GetOperationResponse{Operation: payoutToOperation(StateToProto(payout))}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
|
||||
@@ -274,7 +285,7 @@ func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
return &connectorv1.Operation{
|
||||
op := &connectorv1.Operation{
|
||||
OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())),
|
||||
Type: connectorv1.OperationType_PAYOUT,
|
||||
Status: payoutStatusToOperation(state.GetStatus()),
|
||||
@@ -282,10 +293,30 @@ func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation {
|
||||
Amount: minorToDecimal(state.GetAmountMinor()),
|
||||
Currency: strings.ToUpper(strings.TrimSpace(state.GetCurrency())),
|
||||
},
|
||||
ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()),
|
||||
CreatedAt: state.GetCreatedAt(),
|
||||
UpdatedAt: state.GetUpdatedAt(),
|
||||
ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()),
|
||||
IntentRef: strings.TrimSpace(state.GetIntentRef()),
|
||||
OperationRef: strings.TrimSpace(state.GetOperationRef()),
|
||||
CreatedAt: state.GetCreatedAt(),
|
||||
UpdatedAt: state.GetUpdatedAt(),
|
||||
}
|
||||
|
||||
params := map[string]interface{}{}
|
||||
if paymentRef := strings.TrimSpace(state.GetParentPaymentRef()); paymentRef != "" {
|
||||
params["payment_ref"] = paymentRef
|
||||
params["parent_payment_ref"] = paymentRef
|
||||
}
|
||||
if providerCode := strings.TrimSpace(state.GetProviderCode()); providerCode != "" {
|
||||
params["provider_code"] = providerCode
|
||||
}
|
||||
if providerMessage := strings.TrimSpace(state.GetProviderMessage()); providerMessage != "" {
|
||||
params["provider_message"] = providerMessage
|
||||
params["failure_reason"] = providerMessage
|
||||
}
|
||||
if len(params) > 0 {
|
||||
op.Params = structFromMap(params)
|
||||
}
|
||||
|
||||
return op
|
||||
}
|
||||
|
||||
func minorToDecimal(amount int64) string {
|
||||
@@ -316,6 +347,17 @@ func payoutStatusToOperation(status mntxv1.PayoutStatus) connectorv1.OperationSt
|
||||
}
|
||||
}
|
||||
|
||||
func structFromMap(values map[string]interface{}) *structpb.Struct {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result, err := structpb.NewStruct(values)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
|
||||
err := &connectorv1.ConnectorError{
|
||||
Code: code,
|
||||
|
||||
@@ -41,3 +41,15 @@ gateway:
|
||||
timeout_seconds: 345600
|
||||
accepted_user_ids: []
|
||||
success_reaction: "\U0001FAE1"
|
||||
|
||||
treasury:
|
||||
execution_delay: 60s
|
||||
poll_interval: 60s
|
||||
telegram:
|
||||
allowed_chats: []
|
||||
users: []
|
||||
ledger:
|
||||
timeout: 5s
|
||||
limits:
|
||||
max_amount_per_operation: "1000000"
|
||||
max_daily_amount: "5000000"
|
||||
|
||||
@@ -41,3 +41,17 @@ gateway:
|
||||
timeout_seconds: 345600
|
||||
accepted_user_ids: []
|
||||
success_reaction: "\U0001FAE1"
|
||||
|
||||
treasury:
|
||||
execution_delay: 60s
|
||||
poll_interval: 60s
|
||||
ledger:
|
||||
timeout: 5s
|
||||
limits:
|
||||
max_amount_per_operation: ""
|
||||
max_daily_amount: ""
|
||||
telegram:
|
||||
allowed_chats: []
|
||||
users:
|
||||
- telegram_user_id: "8273799472"
|
||||
- ledger_account: "6972c738949b91ea0395e5fb"
|
||||
|
||||
@@ -3,6 +3,7 @@ package serverimp
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/tgsettle/internal/service/gateway"
|
||||
@@ -28,11 +29,17 @@ type Imp struct {
|
||||
config *config
|
||||
app *grpcapp.App[storage.Repository]
|
||||
service *gateway.Service
|
||||
|
||||
discoveryWatcher *discovery.RegistryWatcher
|
||||
discoveryReg *discovery.Registry
|
||||
}
|
||||
|
||||
type config struct {
|
||||
*grpcapp.Config `yaml:",inline"`
|
||||
Gateway gatewayConfig `yaml:"gateway"`
|
||||
Gateway gatewayConfig `yaml:"gateway"`
|
||||
Treasury treasuryConfig `yaml:"treasury"`
|
||||
Ledger ledgerConfig `yaml:"ledger"` // deprecated: use treasury.ledger
|
||||
Telegram telegramConfig `yaml:"telegram"` // deprecated: use treasury.telegram
|
||||
}
|
||||
|
||||
type gatewayConfig struct {
|
||||
@@ -43,6 +50,33 @@ type gatewayConfig struct {
|
||||
SuccessReaction string `yaml:"success_reaction"`
|
||||
}
|
||||
|
||||
type telegramConfig struct {
|
||||
AllowedChats []string `yaml:"allowed_chats"`
|
||||
Users []telegramUserConfig `yaml:"users"`
|
||||
}
|
||||
|
||||
type telegramUserConfig struct {
|
||||
TelegramUserID string `yaml:"telegram_user_id"`
|
||||
LedgerAccount string `yaml:"ledger_account"`
|
||||
}
|
||||
|
||||
type treasuryConfig struct {
|
||||
ExecutionDelay time.Duration `yaml:"execution_delay"`
|
||||
PollInterval time.Duration `yaml:"poll_interval"`
|
||||
Telegram telegramConfig `yaml:"telegram"`
|
||||
Ledger ledgerConfig `yaml:"ledger"`
|
||||
Limits treasuryLimitsConfig `yaml:"limits"`
|
||||
}
|
||||
|
||||
type treasuryLimitsConfig struct {
|
||||
MaxAmountPerOperation string `yaml:"max_amount_per_operation"`
|
||||
MaxDailyAmount string `yaml:"max_daily_amount"`
|
||||
}
|
||||
|
||||
type ledgerConfig struct {
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
}
|
||||
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
return &Imp{
|
||||
logger: logger.Named("server"),
|
||||
@@ -62,6 +96,9 @@ func (i *Imp) Shutdown() {
|
||||
if i.service != nil {
|
||||
i.service.Shutdown()
|
||||
}
|
||||
if i.discoveryWatcher != nil {
|
||||
i.discoveryWatcher.Stop()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
i.app.Shutdown(ctx)
|
||||
@@ -81,6 +118,19 @@ func (i *Imp) Start() error {
|
||||
i.logger.Warn("Failed to create messaging broker", zap.Error(err))
|
||||
}
|
||||
}
|
||||
if broker != nil {
|
||||
registry := discovery.NewRegistry()
|
||||
watcher, watcherErr := discovery.NewRegistryWatcher(i.logger, broker, registry)
|
||||
if watcherErr != nil {
|
||||
i.logger.Warn("Failed to initialise discovery registry watcher", zap.Error(watcherErr))
|
||||
} else if startErr := watcher.Start(); startErr != nil {
|
||||
i.logger.Warn("Failed to start discovery registry watcher", zap.Error(startErr))
|
||||
} else {
|
||||
i.discoveryWatcher = watcher
|
||||
i.discoveryReg = registry
|
||||
i.logger.Info("Discovery registry watcher started")
|
||||
}
|
||||
}
|
||||
|
||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||
return gatewaymongo.New(logger, conn)
|
||||
@@ -95,6 +145,8 @@ func (i *Imp) Start() error {
|
||||
if cfg.Messaging != nil {
|
||||
msgSettings = cfg.Messaging.Settings
|
||||
}
|
||||
treasuryTelegram := treasuryTelegramConfig(cfg, i.logger)
|
||||
treasuryLedger := treasuryLedgerConfig(cfg, i.logger)
|
||||
gwCfg := gateway.Config{
|
||||
Rail: cfg.Gateway.Rail,
|
||||
TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv,
|
||||
@@ -103,6 +155,22 @@ func (i *Imp) Start() error {
|
||||
SuccessReaction: cfg.Gateway.SuccessReaction,
|
||||
InvokeURI: invokeURI,
|
||||
MessagingSettings: msgSettings,
|
||||
DiscoveryRegistry: i.discoveryReg,
|
||||
Treasury: gateway.TreasuryConfig{
|
||||
ExecutionDelay: cfg.Treasury.ExecutionDelay,
|
||||
PollInterval: cfg.Treasury.PollInterval,
|
||||
Telegram: gateway.TelegramConfig{
|
||||
AllowedChats: treasuryTelegram.AllowedChats,
|
||||
Users: telegramUsers(treasuryTelegram.Users),
|
||||
},
|
||||
Ledger: gateway.LedgerConfig{
|
||||
Timeout: treasuryLedger.Timeout,
|
||||
},
|
||||
Limits: gateway.TreasuryLimitsConfig{
|
||||
MaxAmountPerOperation: cfg.Treasury.Limits.MaxAmountPerOperation,
|
||||
MaxDailyAmount: cfg.Treasury.Limits.MaxDailyAmount,
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := gateway.NewService(logger, repo, producer, broker, gwCfg)
|
||||
i.service = svc
|
||||
@@ -142,6 +210,15 @@ func (i *Imp) loadConfig() (*config, error) {
|
||||
if cfg.Metrics == nil {
|
||||
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9406"}
|
||||
}
|
||||
if cfg.Treasury.ExecutionDelay <= 0 {
|
||||
cfg.Treasury.ExecutionDelay = 30 * time.Second
|
||||
}
|
||||
if cfg.Treasury.PollInterval <= 0 {
|
||||
cfg.Treasury.PollInterval = 30 * time.Second
|
||||
}
|
||||
if cfg.Treasury.Ledger.Timeout <= 0 {
|
||||
cfg.Treasury.Ledger.Timeout = 5 * time.Second
|
||||
}
|
||||
cfg.Gateway.Rail = discovery.NormalizeRail(cfg.Gateway.Rail)
|
||||
if cfg.Gateway.Rail == "" {
|
||||
return nil, merrors.InvalidArgument("gateway rail is required", "gateway.rail")
|
||||
@@ -151,3 +228,46 @@ func (i *Imp) loadConfig() (*config, error) {
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func telegramUsers(input []telegramUserConfig) []gateway.TelegramUserBinding {
|
||||
result := make([]gateway.TelegramUserBinding, 0, len(input))
|
||||
for _, next := range input {
|
||||
result = append(result, gateway.TelegramUserBinding{
|
||||
TelegramUserID: strings.TrimSpace(next.TelegramUserID),
|
||||
LedgerAccount: strings.TrimSpace(next.LedgerAccount),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func treasuryTelegramConfig(cfg *config, logger mlogger.Logger) telegramConfig {
|
||||
if cfg == nil {
|
||||
return telegramConfig{}
|
||||
}
|
||||
if len(cfg.Treasury.Telegram.Users) > 0 || len(cfg.Treasury.Telegram.AllowedChats) > 0 {
|
||||
return cfg.Treasury.Telegram
|
||||
}
|
||||
if len(cfg.Telegram.Users) > 0 || len(cfg.Telegram.AllowedChats) > 0 {
|
||||
if logger != nil {
|
||||
logger.Warn("Deprecated config path used: telegram.*; move these settings to treasury.telegram.*")
|
||||
}
|
||||
return cfg.Telegram
|
||||
}
|
||||
return cfg.Treasury.Telegram
|
||||
}
|
||||
|
||||
func treasuryLedgerConfig(cfg *config, logger mlogger.Logger) ledgerConfig {
|
||||
if cfg == nil {
|
||||
return ledgerConfig{}
|
||||
}
|
||||
if cfg.Treasury.Ledger.Timeout > 0 {
|
||||
return cfg.Treasury.Ledger
|
||||
}
|
||||
if cfg.Ledger.Timeout > 0 {
|
||||
if logger != nil {
|
||||
logger.Warn("Deprecated config path used: ledger.*; move these settings to treasury.ledger.*")
|
||||
}
|
||||
return cfg.Ledger
|
||||
}
|
||||
return cfg.Treasury.Ledger
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWe
|
||||
message := update.Message
|
||||
replyToID := strings.TrimSpace(message.ReplyToMessageID)
|
||||
if replyToID == "" {
|
||||
s.handleTreasuryTelegramUpdate(ctx, update)
|
||||
return nil
|
||||
}
|
||||
replyFields := telegramReplyLogFields(update)
|
||||
@@ -154,6 +155,9 @@ func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWe
|
||||
return err
|
||||
}
|
||||
if pending == nil {
|
||||
if s.handleTreasuryTelegramUpdate(ctx, update) {
|
||||
return nil
|
||||
}
|
||||
s.logger.Warn("Telegram confirmation reply dropped",
|
||||
append(replyFields,
|
||||
zap.String("outcome", "dropped"),
|
||||
@@ -272,6 +276,13 @@ func (s *Service) onTelegramUpdate(ctx context.Context, update *model.TelegramWe
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) handleTreasuryTelegramUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
|
||||
if s == nil || s.treasury == nil || update == nil || update.Message == nil {
|
||||
return false
|
||||
}
|
||||
return s.treasury.HandleUpdate(ctx, update)
|
||||
}
|
||||
|
||||
func telegramReplyLogFields(update *model.TelegramWebhookUpdate) []zap.Field {
|
||||
if update == nil || update.Message == nil {
|
||||
return nil
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
const tgsettleConnectorID = "tgsettle"
|
||||
@@ -152,12 +155,22 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio
|
||||
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
|
||||
}
|
||||
operationID := strings.TrimSpace(req.GetOperationId())
|
||||
resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: operationID})
|
||||
|
||||
if s.repo == nil || s.repo.Payments() == nil {
|
||||
s.logger.Warn("Get operation storage unavailable", zap.String("operation_id", operationID))
|
||||
return nil, merrors.Internal("get_operation: storage is not configured")
|
||||
}
|
||||
|
||||
record, err := s.repo.Payments().FindByOperationRef(ctx, operationID)
|
||||
if err != nil {
|
||||
s.logger.Warn("Get operation failed", zap.String("operation_id", operationID), zap.Error(err))
|
||||
s.logger.Warn("Get operation lookup by operation_ref failed", zap.String("operation_id", operationID), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
return &connectorv1.GetOperationResponse{Operation: transferToOperation(resp.GetTransfer())}, nil
|
||||
if record == nil {
|
||||
return nil, status.Error(codes.NotFound, "operation not found")
|
||||
}
|
||||
|
||||
return &connectorv1.GetOperationResponse{Operation: transferToOperation(transferFromPayment(record, nil))}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
|
||||
@@ -221,6 +234,19 @@ func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
|
||||
CreatedAt: transfer.GetCreatedAt(),
|
||||
UpdatedAt: transfer.GetUpdatedAt(),
|
||||
}
|
||||
params := map[string]interface{}{}
|
||||
if paymentRef := strings.TrimSpace(transfer.GetPaymentRef()); paymentRef != "" {
|
||||
params["payment_ref"] = paymentRef
|
||||
}
|
||||
if organizationRef := strings.TrimSpace(transfer.GetOrganizationRef()); organizationRef != "" {
|
||||
params["organization_ref"] = organizationRef
|
||||
}
|
||||
if failureReason := strings.TrimSpace(transfer.GetFailureReason()); failureReason != "" {
|
||||
params["failure_reason"] = failureReason
|
||||
}
|
||||
if len(params) > 0 {
|
||||
op.Params = structFromMap(params)
|
||||
}
|
||||
if source := strings.TrimSpace(transfer.GetSourceWalletRef()); source != "" {
|
||||
op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
||||
ConnectorId: tgsettleConnectorID,
|
||||
@@ -281,6 +307,17 @@ func operationAccountID(party *connectorv1.OperationParty) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func structFromMap(values map[string]interface{}) *structpb.Struct {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result, err := structpb.NewStruct(values)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func operationLogFields(op *connectorv1.Operation) []zap.Field {
|
||||
if op == nil {
|
||||
return nil
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"time"
|
||||
|
||||
gatewayoutbox "github.com/tech/sendico/gateway/common/outbox"
|
||||
treasurysvc "github.com/tech/sendico/gateway/tgsettle/internal/service/treasury"
|
||||
treasuryledger "github.com/tech/sendico/gateway/tgsettle/internal/service/treasury/ledger"
|
||||
"github.com/tech/sendico/gateway/tgsettle/storage"
|
||||
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
@@ -40,6 +42,9 @@ const (
|
||||
defaultConfirmationTimeoutSeconds = 345600
|
||||
defaultTelegramSuccessReaction = "\U0001FAE1"
|
||||
defaultConfirmationSweepInterval = 5 * time.Second
|
||||
defaultTreasuryExecutionDelay = 30 * time.Second
|
||||
defaultTreasuryPollInterval = 30 * time.Second
|
||||
defaultTreasuryLedgerTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -59,6 +64,35 @@ type Config struct {
|
||||
SuccessReaction string
|
||||
InvokeURI string
|
||||
MessagingSettings pmodel.SettingsT
|
||||
DiscoveryRegistry *discovery.Registry
|
||||
Treasury TreasuryConfig
|
||||
}
|
||||
|
||||
type TelegramConfig struct {
|
||||
AllowedChats []string
|
||||
Users []TelegramUserBinding
|
||||
}
|
||||
|
||||
type TelegramUserBinding struct {
|
||||
TelegramUserID string
|
||||
LedgerAccount string
|
||||
}
|
||||
|
||||
type TreasuryConfig struct {
|
||||
ExecutionDelay time.Duration
|
||||
PollInterval time.Duration
|
||||
Telegram TelegramConfig
|
||||
Ledger LedgerConfig
|
||||
Limits TreasuryLimitsConfig
|
||||
}
|
||||
|
||||
type TreasuryLimitsConfig struct {
|
||||
MaxAmountPerOperation string
|
||||
MaxDailyAmount string
|
||||
}
|
||||
|
||||
type LedgerConfig struct {
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -80,6 +114,8 @@ type Service struct {
|
||||
timeoutCancel context.CancelFunc
|
||||
timeoutWG sync.WaitGroup
|
||||
|
||||
treasury *treasurysvc.Module
|
||||
|
||||
connectorv1.UnimplementedConnectorServiceServer
|
||||
}
|
||||
|
||||
@@ -112,6 +148,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
svc.startConsumers()
|
||||
svc.startAnnouncer()
|
||||
svc.startConfirmationTimeoutWatcher()
|
||||
svc.startTreasuryModule()
|
||||
return svc
|
||||
}
|
||||
|
||||
@@ -134,12 +171,91 @@ func (s *Service) Shutdown() {
|
||||
consumer.Close()
|
||||
}
|
||||
}
|
||||
if s.treasury != nil {
|
||||
s.treasury.Shutdown()
|
||||
}
|
||||
if s.timeoutCancel != nil {
|
||||
s.timeoutCancel()
|
||||
}
|
||||
s.timeoutWG.Wait()
|
||||
}
|
||||
|
||||
func (s *Service) startTreasuryModule() {
|
||||
if s == nil || s.repo == nil || s.repo.TreasuryRequests() == nil {
|
||||
return
|
||||
}
|
||||
if s.cfg.DiscoveryRegistry == nil {
|
||||
s.logger.Warn("Treasury module disabled: discovery registry is unavailable")
|
||||
return
|
||||
}
|
||||
if len(s.cfg.Treasury.Telegram.Users) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ledgerTimeout := s.cfg.Treasury.Ledger.Timeout
|
||||
if ledgerTimeout <= 0 {
|
||||
ledgerTimeout = defaultTreasuryLedgerTimeout
|
||||
}
|
||||
ledgerClient, err := treasuryledger.NewDiscoveryClient(treasuryledger.DiscoveryConfig{
|
||||
Logger: s.logger,
|
||||
Registry: s.cfg.DiscoveryRegistry,
|
||||
Timeout: ledgerTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to initialise treasury ledger client", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
executionDelay := s.cfg.Treasury.ExecutionDelay
|
||||
if executionDelay <= 0 {
|
||||
executionDelay = defaultTreasuryExecutionDelay
|
||||
}
|
||||
pollInterval := s.cfg.Treasury.PollInterval
|
||||
if pollInterval <= 0 {
|
||||
pollInterval = defaultTreasuryPollInterval
|
||||
}
|
||||
|
||||
users := make([]treasurysvc.UserBinding, 0, len(s.cfg.Treasury.Telegram.Users))
|
||||
for _, binding := range s.cfg.Treasury.Telegram.Users {
|
||||
users = append(users, treasurysvc.UserBinding{
|
||||
TelegramUserID: binding.TelegramUserID,
|
||||
LedgerAccount: binding.LedgerAccount,
|
||||
})
|
||||
}
|
||||
|
||||
module, err := treasurysvc.NewModule(
|
||||
s.logger,
|
||||
s.repo.TreasuryRequests(),
|
||||
ledgerClient,
|
||||
treasurysvc.Config{
|
||||
AllowedChats: s.cfg.Treasury.Telegram.AllowedChats,
|
||||
Users: users,
|
||||
ExecutionDelay: executionDelay,
|
||||
PollInterval: pollInterval,
|
||||
MaxAmountPerOperation: s.cfg.Treasury.Limits.MaxAmountPerOperation,
|
||||
MaxDailyAmount: s.cfg.Treasury.Limits.MaxDailyAmount,
|
||||
},
|
||||
func(ctx context.Context, chatID string, text string) error {
|
||||
return s.sendTelegramText(ctx, &model.TelegramTextRequest{
|
||||
ChatID: chatID,
|
||||
Text: text,
|
||||
})
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to initialise treasury module", zap.Error(err))
|
||||
_ = ledgerClient.Close()
|
||||
return
|
||||
}
|
||||
if !module.Enabled() {
|
||||
_ = ledgerClient.Close()
|
||||
return
|
||||
}
|
||||
module.Start()
|
||||
s.treasury = module
|
||||
s.logger.Info("Treasury module started", zap.Duration("execution_delay", executionDelay), zap.Duration("poll_interval", pollInterval))
|
||||
}
|
||||
|
||||
func (s *Service) startConsumers() {
|
||||
if s == nil || s.broker == nil {
|
||||
if s != nil && s.logger != nil {
|
||||
@@ -675,6 +791,9 @@ func transferFromRequest(req *chainv1.SubmitTransferRequest) *chainv1.Transfer {
|
||||
SourceWalletRef: strings.TrimSpace(req.GetSourceWalletRef()),
|
||||
Destination: req.GetDestination(),
|
||||
RequestedAmount: req.GetAmount(),
|
||||
IntentRef: strings.TrimSpace(req.GetIntentRef()),
|
||||
OperationRef: strings.TrimSpace(req.GetOperationRef()),
|
||||
PaymentRef: strings.TrimSpace(req.GetPaymentRef()),
|
||||
Status: chainv1.TransferStatus_TRANSFER_CREATED,
|
||||
}
|
||||
}
|
||||
@@ -714,6 +833,10 @@ func transferFromPayment(record *storagemodel.PaymentRecord, req *chainv1.Submit
|
||||
IdempotencyKey: strings.TrimSpace(record.IdempotencyKey),
|
||||
RequestedAmount: requested,
|
||||
NetAmount: net,
|
||||
IntentRef: strings.TrimSpace(record.IntentRef),
|
||||
OperationRef: strings.TrimSpace(record.OperationRef),
|
||||
PaymentRef: strings.TrimSpace(record.PaymentRef),
|
||||
FailureReason: strings.TrimSpace(record.FailureReason),
|
||||
Status: status,
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,20 @@ func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string)
|
||||
return f.records[key], nil
|
||||
}
|
||||
|
||||
func (f *fakePaymentsStore) FindByOperationRef(_ context.Context, key string) (*storagemodel.PaymentRecord, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.records == nil {
|
||||
return nil, nil
|
||||
}
|
||||
for _, record := range f.records {
|
||||
if record != nil && record.OperationRef == key {
|
||||
return record, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePaymentsStore) Upsert(_ context.Context, record *storagemodel.PaymentRecord) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@@ -66,6 +80,7 @@ type fakeRepo struct {
|
||||
payments *fakePaymentsStore
|
||||
tg *fakeTelegramStore
|
||||
pending *fakePendingStore
|
||||
treasury storage.TreasuryRequestsStore
|
||||
}
|
||||
|
||||
func (f *fakeRepo) Payments() storage.PaymentsStore {
|
||||
@@ -80,6 +95,10 @@ func (f *fakeRepo) PendingConfirmations() storage.PendingConfirmationsStore {
|
||||
return f.pending
|
||||
}
|
||||
|
||||
func (f *fakeRepo) TreasuryRequests() storage.TreasuryRequestsStore {
|
||||
return f.treasury
|
||||
}
|
||||
|
||||
type fakePendingStore struct {
|
||||
mu sync.Mutex
|
||||
records map[string]*storagemodel.PendingConfirmation
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
370
api/gateway/tgsettle/internal/service/treasury/bot/router.go
Normal file
370
api/gateway/tgsettle/internal/service/treasury/bot/router.go
Normal file
@@ -0,0 +1,370 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const unauthorizedMessage = "Sorry, your Telegram account is not authorized to perform treasury operations."
|
||||
const welcomeMessage = "Welcome to tgsettle treasury bot.\n\nUse /fund to credit your account and /withdraw to debit it.\nAfter entering an amount, use /confirm or /cancel."
|
||||
|
||||
type SendTextFunc func(ctx context.Context, chatID string, text string) error
|
||||
|
||||
type ScheduleTracker interface {
|
||||
TrackScheduled(record *storagemodel.TreasuryRequest)
|
||||
Untrack(requestID string)
|
||||
}
|
||||
|
||||
type CreateRequestInput struct {
|
||||
OperationType storagemodel.TreasuryOperationType
|
||||
TelegramUserID string
|
||||
LedgerAccountID string
|
||||
ChatID string
|
||||
Amount string
|
||||
}
|
||||
|
||||
type TreasuryService interface {
|
||||
ExecutionDelay() time.Duration
|
||||
MaxPerOperationLimit() string
|
||||
|
||||
GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error)
|
||||
CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error)
|
||||
ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error)
|
||||
CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error)
|
||||
}
|
||||
|
||||
type limitError interface {
|
||||
error
|
||||
LimitKind() string
|
||||
LimitMax() string
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
logger mlogger.Logger
|
||||
|
||||
service TreasuryService
|
||||
dialogs *Dialogs
|
||||
send SendTextFunc
|
||||
tracker ScheduleTracker
|
||||
|
||||
allowedChats map[string]struct{}
|
||||
userAccounts map[string]string
|
||||
allowAnyChat bool
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
logger mlogger.Logger,
|
||||
service TreasuryService,
|
||||
send SendTextFunc,
|
||||
tracker ScheduleTracker,
|
||||
allowedChats []string,
|
||||
userAccounts map[string]string,
|
||||
) *Router {
|
||||
if logger != nil {
|
||||
logger = logger.Named("treasury_router")
|
||||
}
|
||||
allowed := map[string]struct{}{}
|
||||
for _, chatID := range allowedChats {
|
||||
chatID = strings.TrimSpace(chatID)
|
||||
if chatID == "" {
|
||||
continue
|
||||
}
|
||||
allowed[chatID] = struct{}{}
|
||||
}
|
||||
users := map[string]string{}
|
||||
for userID, accountID := range userAccounts {
|
||||
userID = strings.TrimSpace(userID)
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if userID == "" || accountID == "" {
|
||||
continue
|
||||
}
|
||||
users[userID] = accountID
|
||||
}
|
||||
return &Router{
|
||||
logger: logger,
|
||||
service: service,
|
||||
dialogs: NewDialogs(),
|
||||
send: send,
|
||||
tracker: tracker,
|
||||
allowedChats: allowed,
|
||||
userAccounts: users,
|
||||
allowAnyChat: len(allowed) == 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) Enabled() bool {
|
||||
return r != nil && r.service != nil && len(r.userAccounts) > 0
|
||||
}
|
||||
|
||||
func (r *Router) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
|
||||
if !r.Enabled() || update == nil || update.Message == nil {
|
||||
return false
|
||||
}
|
||||
message := update.Message
|
||||
chatID := strings.TrimSpace(message.ChatID)
|
||||
userID := strings.TrimSpace(message.FromUserID)
|
||||
text := strings.TrimSpace(message.Text)
|
||||
|
||||
if chatID == "" || userID == "" {
|
||||
return false
|
||||
}
|
||||
if !r.allowAnyChat {
|
||||
if _, ok := r.allowedChats[chatID]; !ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
accountID, ok := r.userAccounts[userID]
|
||||
if !ok || strings.TrimSpace(accountID) == "" {
|
||||
r.logUnauthorized(update)
|
||||
_ = r.sendText(ctx, chatID, unauthorizedMessage)
|
||||
return true
|
||||
}
|
||||
|
||||
command := parseCommand(text)
|
||||
switch command {
|
||||
case "start":
|
||||
_ = r.sendText(ctx, chatID, welcomeMessage)
|
||||
return true
|
||||
case "fund":
|
||||
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationFund)
|
||||
return true
|
||||
case "withdraw":
|
||||
r.startAmountDialog(ctx, userID, accountID, chatID, storagemodel.TreasuryOperationWithdraw)
|
||||
return true
|
||||
case "confirm":
|
||||
r.confirm(ctx, userID, accountID, chatID)
|
||||
return true
|
||||
case "cancel":
|
||||
r.cancel(ctx, userID, accountID, chatID)
|
||||
return true
|
||||
}
|
||||
|
||||
session, hasSession := r.dialogs.Get(userID)
|
||||
if hasSession {
|
||||
switch session.State {
|
||||
case DialogStateWaitingAmount:
|
||||
r.captureAmount(ctx, userID, accountID, chatID, session.OperationType, text)
|
||||
return true
|
||||
case DialogStateWaitingConfirmation:
|
||||
_ = r.sendText(ctx, chatID, "Confirm operation?\n\n/confirm\n/cancel")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(text, "/") {
|
||||
_ = r.sendText(ctx, chatID, "Supported commands:\n/start\n/fund\n/withdraw\n/confirm\n/cancel")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *Router) startAmountDialog(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType) {
|
||||
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
r.logger.Warn("Failed to check active treasury request", zap.Error(err), zap.String("telegram_user_id", userID), zap.String("ledger_account_id", accountID))
|
||||
return
|
||||
}
|
||||
if active != nil {
|
||||
_ = r.sendText(ctx, chatID, pendingRequestMessage(active))
|
||||
r.dialogs.Set(userID, DialogSession{
|
||||
State: DialogStateWaitingConfirmation,
|
||||
LedgerAccountID: accountID,
|
||||
RequestID: active.RequestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
r.dialogs.Set(userID, DialogSession{
|
||||
State: DialogStateWaitingAmount,
|
||||
OperationType: operation,
|
||||
LedgerAccountID: accountID,
|
||||
})
|
||||
_ = r.sendText(ctx, chatID, "Enter amount:")
|
||||
}
|
||||
|
||||
func (r *Router) captureAmount(ctx context.Context, userID, accountID, chatID string, operation storagemodel.TreasuryOperationType, amount string) {
|
||||
record, err := r.service.CreateRequest(ctx, CreateRequestInput{
|
||||
OperationType: operation,
|
||||
TelegramUserID: userID,
|
||||
LedgerAccountID: accountID,
|
||||
ChatID: chatID,
|
||||
Amount: amount,
|
||||
})
|
||||
if err != nil {
|
||||
if record != nil {
|
||||
_ = r.sendText(ctx, chatID, pendingRequestMessage(record))
|
||||
r.dialogs.Set(userID, DialogSession{
|
||||
State: DialogStateWaitingConfirmation,
|
||||
LedgerAccountID: accountID,
|
||||
RequestID: record.RequestID,
|
||||
})
|
||||
return
|
||||
}
|
||||
if typed, ok := err.(limitError); ok {
|
||||
switch typed.LimitKind() {
|
||||
case "per_operation":
|
||||
_ = r.sendText(ctx, chatID, "Amount exceeds allowed limit.\n\nMax per operation: "+typed.LimitMax()+"\n\nEnter another amount or /cancel")
|
||||
return
|
||||
case "daily":
|
||||
_ = r.sendText(ctx, chatID, "Daily amount limit exceeded.\n\nMax per day: "+typed.LimitMax()+"\n\nEnter another amount or /cancel")
|
||||
return
|
||||
}
|
||||
}
|
||||
if errors.Is(err, merrors.ErrInvalidArg) {
|
||||
_ = r.sendText(ctx, chatID, "Invalid amount.\n\nEnter another amount or /cancel")
|
||||
return
|
||||
}
|
||||
_ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or /cancel")
|
||||
return
|
||||
}
|
||||
if record == nil {
|
||||
_ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or /cancel")
|
||||
return
|
||||
}
|
||||
r.dialogs.Set(userID, DialogSession{
|
||||
State: DialogStateWaitingConfirmation,
|
||||
LedgerAccountID: accountID,
|
||||
RequestID: record.RequestID,
|
||||
})
|
||||
_ = r.sendText(ctx, chatID, confirmationPrompt(record))
|
||||
}
|
||||
|
||||
func (r *Router) confirm(ctx context.Context, userID string, accountID string, chatID string) {
|
||||
requestID := ""
|
||||
if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" {
|
||||
requestID = strings.TrimSpace(session.RequestID)
|
||||
} else {
|
||||
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
|
||||
if err == nil && active != nil {
|
||||
requestID = strings.TrimSpace(active.RequestID)
|
||||
}
|
||||
}
|
||||
if requestID == "" {
|
||||
_ = r.sendText(ctx, chatID, "No pending treasury operation.")
|
||||
return
|
||||
}
|
||||
record, err := r.service.ConfirmRequest(ctx, requestID, userID)
|
||||
if err != nil {
|
||||
_ = r.sendText(ctx, chatID, "Unable to confirm treasury request.\n\nUse /cancel or create a new request with /fund or /withdraw.")
|
||||
return
|
||||
}
|
||||
if r.tracker != nil {
|
||||
r.tracker.TrackScheduled(record)
|
||||
}
|
||||
r.dialogs.Clear(userID)
|
||||
delay := int64(r.service.ExecutionDelay().Seconds())
|
||||
if delay < 0 {
|
||||
delay = 0
|
||||
}
|
||||
_ = r.sendText(ctx, chatID, "Operation confirmed.\n\nExecution scheduled in "+formatSeconds(delay)+".\n\nRequest ID: "+strings.TrimSpace(record.RequestID))
|
||||
}
|
||||
|
||||
func (r *Router) cancel(ctx context.Context, userID string, accountID string, chatID string) {
|
||||
requestID := ""
|
||||
if session, ok := r.dialogs.Get(userID); ok && strings.TrimSpace(session.RequestID) != "" {
|
||||
requestID = strings.TrimSpace(session.RequestID)
|
||||
} else {
|
||||
active, err := r.service.GetActiveRequestForAccount(ctx, accountID)
|
||||
if err == nil && active != nil {
|
||||
requestID = strings.TrimSpace(active.RequestID)
|
||||
}
|
||||
}
|
||||
if requestID == "" {
|
||||
r.dialogs.Clear(userID)
|
||||
_ = r.sendText(ctx, chatID, "No pending treasury operation.")
|
||||
return
|
||||
}
|
||||
record, err := r.service.CancelRequest(ctx, requestID, userID)
|
||||
if err != nil {
|
||||
_ = r.sendText(ctx, chatID, "Unable to cancel treasury request.")
|
||||
return
|
||||
}
|
||||
if r.tracker != nil {
|
||||
r.tracker.Untrack(record.RequestID)
|
||||
}
|
||||
r.dialogs.Clear(userID)
|
||||
_ = r.sendText(ctx, chatID, "Operation cancelled.\n\nRequest ID: "+strings.TrimSpace(record.RequestID))
|
||||
}
|
||||
|
||||
func (r *Router) sendText(ctx context.Context, chatID string, text string) error {
|
||||
if r == nil || r.send == nil {
|
||||
return nil
|
||||
}
|
||||
chatID = strings.TrimSpace(chatID)
|
||||
text = strings.TrimSpace(text)
|
||||
if chatID == "" || text == "" {
|
||||
return nil
|
||||
}
|
||||
return r.send(ctx, chatID, text)
|
||||
}
|
||||
|
||||
func (r *Router) logUnauthorized(update *model.TelegramWebhookUpdate) {
|
||||
if r == nil || r.logger == nil || update == nil || update.Message == nil {
|
||||
return
|
||||
}
|
||||
message := update.Message
|
||||
r.logger.Warn("unauthorized_access",
|
||||
zap.String("event", "unauthorized_access"),
|
||||
zap.String("telegram_user_id", strings.TrimSpace(message.FromUserID)),
|
||||
zap.String("chat_id", strings.TrimSpace(message.ChatID)),
|
||||
zap.String("message_text", strings.TrimSpace(message.Text)),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
}
|
||||
|
||||
func pendingRequestMessage(record *storagemodel.TreasuryRequest) string {
|
||||
if record == nil {
|
||||
return "You already have a pending treasury operation.\n\n/cancel"
|
||||
}
|
||||
return "You already have a pending treasury operation.\n\n" +
|
||||
"Request ID: " + strings.TrimSpace(record.RequestID) + "\n" +
|
||||
"Status: " + strings.TrimSpace(string(record.Status)) + "\n" +
|
||||
"Amount: " + strings.TrimSpace(record.Amount) + " " + strings.TrimSpace(record.Currency) + "\n\n" +
|
||||
"Wait for execution or cancel it.\n\n/cancel"
|
||||
}
|
||||
|
||||
func confirmationPrompt(record *storagemodel.TreasuryRequest) string {
|
||||
if record == nil {
|
||||
return "Request created.\n\n/confirm\n/cancel"
|
||||
}
|
||||
title := "Funding request created."
|
||||
if record.OperationType == storagemodel.TreasuryOperationWithdraw {
|
||||
title = "Withdrawal request created."
|
||||
}
|
||||
return title + "\n\n" +
|
||||
"Account: " + strings.TrimSpace(record.LedgerAccountID) + "\n" +
|
||||
"Amount: " + strings.TrimSpace(record.Amount) + " " + strings.TrimSpace(record.Currency) + "\n\n" +
|
||||
"Confirm operation?\n\n/confirm\n/cancel"
|
||||
}
|
||||
|
||||
func parseCommand(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if !strings.HasPrefix(text, "/") {
|
||||
return ""
|
||||
}
|
||||
token := text
|
||||
if idx := strings.IndexAny(token, " \t\n\r"); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimPrefix(token, "/")
|
||||
if idx := strings.Index(token, "@"); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(token))
|
||||
}
|
||||
|
||||
func formatSeconds(value int64) string {
|
||||
if value == 1 {
|
||||
return "1 second"
|
||||
}
|
||||
return strconv.FormatInt(value, 10) + " seconds"
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type fakeService struct{}
|
||||
|
||||
func (fakeService) ExecutionDelay() time.Duration {
|
||||
return 30 * time.Second
|
||||
}
|
||||
|
||||
func (fakeService) MaxPerOperationLimit() string {
|
||||
return "1000000"
|
||||
}
|
||||
|
||||
func (fakeService) GetActiveRequestForAccount(context.Context, string) (*storagemodel.TreasuryRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) CreateRequest(context.Context, CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) ConfirmRequest(context.Context, string, string) (*storagemodel.TreasuryRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) CancelRequest(context.Context, string, string) (*storagemodel.TreasuryRequest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestRouterUnauthorizedInAllowedChatSendsAccessDenied(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
[]string{"100"},
|
||||
map[string]string{"123": "acct-1"},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "100",
|
||||
FromUserID: "999",
|
||||
Text: "/fund",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != unauthorizedMessage {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterUnknownChatIsIgnored(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
[]string{"100"},
|
||||
map[string]string{"123": "acct-1"},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "999",
|
||||
FromUserID: "123",
|
||||
Text: "/fund",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 0 {
|
||||
t.Fatalf("expected no messages, got %d", len(sent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterEmptyAllowedChats_AllowsAnyChatForAuthorizedUser(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]string{"123": "acct-1"},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "999",
|
||||
FromUserID: "123",
|
||||
Text: "/fund",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != "Enter amount:" {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterEmptyAllowedChats_UnauthorizedUserGetsDenied(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]string{"123": "acct-1"},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "777",
|
||||
FromUserID: "999",
|
||||
Text: "/fund",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != unauthorizedMessage {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterStartAuthorizedShowsWelcome(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]string{"123": "acct-1"},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "777",
|
||||
FromUserID: "123",
|
||||
Text: "/start",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != welcomeMessage {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterStartUnauthorizedGetsDenied(t *testing.T) {
|
||||
var sent []string
|
||||
router := NewRouter(
|
||||
mloggerfactory.NewLogger(false),
|
||||
fakeService{},
|
||||
func(_ context.Context, _ string, text string) error {
|
||||
sent = append(sent, text)
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
map[string]string{"123": "acct-1"},
|
||||
)
|
||||
handled := router.HandleUpdate(context.Background(), &model.TelegramWebhookUpdate{
|
||||
Message: &model.TelegramMessage{
|
||||
ChatID: "777",
|
||||
FromUserID: "999",
|
||||
Text: "/start",
|
||||
},
|
||||
})
|
||||
if !handled {
|
||||
t.Fatalf("expected update to be handled")
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected one message, got %d", len(sent))
|
||||
}
|
||||
if sent[0] != unauthorizedMessage {
|
||||
t.Fatalf("unexpected message: %q", sent[0])
|
||||
}
|
||||
}
|
||||
19
api/gateway/tgsettle/internal/service/treasury/config.go
Normal file
19
api/gateway/tgsettle/internal/service/treasury/config.go
Normal 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
|
||||
}
|
||||
287
api/gateway/tgsettle/internal/service/treasury/ledger/client.go
Normal file
287
api/gateway/tgsettle/internal/service/treasury/ledger/client.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
const ledgerConnectorID = "ledger"
|
||||
|
||||
type Config struct {
|
||||
Endpoint string
|
||||
Timeout time.Duration
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
AccountID string
|
||||
Currency string
|
||||
OrganizationRef string
|
||||
}
|
||||
|
||||
type Balance struct {
|
||||
AccountID string
|
||||
Amount string
|
||||
Currency string
|
||||
}
|
||||
|
||||
type PostRequest struct {
|
||||
AccountID string
|
||||
OrganizationRef string
|
||||
Amount string
|
||||
Currency string
|
||||
Reference string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type OperationResult struct {
|
||||
Reference string
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
GetAccount(ctx context.Context, accountID string) (*Account, error)
|
||||
GetBalance(ctx context.Context, accountID string) (*Balance, error)
|
||||
ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error)
|
||||
ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type grpcConnectorClient interface {
|
||||
GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error)
|
||||
GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error)
|
||||
SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
|
||||
}
|
||||
|
||||
type connectorClient struct {
|
||||
cfg Config
|
||||
conn *grpc.ClientConn
|
||||
client grpcConnectorClient
|
||||
}
|
||||
|
||||
func New(cfg Config) (Client, error) {
|
||||
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
|
||||
if cfg.Endpoint == "" {
|
||||
return nil, merrors.InvalidArgument("ledger endpoint is required", "ledger.endpoint")
|
||||
}
|
||||
if normalized, insecure := normalizeEndpoint(cfg.Endpoint); normalized != "" {
|
||||
cfg.Endpoint = normalized
|
||||
if insecure {
|
||||
cfg.Insecure = true
|
||||
}
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 5 * time.Second
|
||||
}
|
||||
dialOpts := []grpc.DialOption{}
|
||||
if cfg.Insecure {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
} else {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||
}
|
||||
conn, err := grpc.NewClient(cfg.Endpoint, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, fmt.Sprintf("ledger: dial %s", cfg.Endpoint))
|
||||
}
|
||||
return &connectorClient{
|
||||
cfg: cfg,
|
||||
conn: conn,
|
||||
client: connectorv1.NewConnectorServiceClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *connectorClient) Close() error {
|
||||
if c == nil || c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *connectorClient) GetAccount(ctx context.Context, accountID string) (*Account, error) {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" {
|
||||
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
|
||||
}
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.GetAccount(ctx, &connectorv1.GetAccountRequest{
|
||||
AccountRef: &connectorv1.AccountRef{
|
||||
ConnectorId: ledgerConnectorID,
|
||||
AccountId: accountID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account := resp.GetAccount()
|
||||
if account == nil {
|
||||
return nil, merrors.NoData("ledger account not found")
|
||||
}
|
||||
organizationRef := strings.TrimSpace(account.GetOwnerRef())
|
||||
if organizationRef == "" && account.GetProviderDetails() != nil {
|
||||
if value, ok := account.GetProviderDetails().AsMap()["organization_ref"]; ok {
|
||||
organizationRef = strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
}
|
||||
return &Account{
|
||||
AccountID: accountID,
|
||||
Currency: strings.ToUpper(strings.TrimSpace(account.GetAsset())),
|
||||
OrganizationRef: organizationRef,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *connectorClient) GetBalance(ctx context.Context, accountID string) (*Balance, error) {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" {
|
||||
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
|
||||
}
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{
|
||||
AccountRef: &connectorv1.AccountRef{
|
||||
ConnectorId: ledgerConnectorID,
|
||||
AccountId: accountID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
balance := resp.GetBalance()
|
||||
if balance == nil || balance.GetAvailable() == nil {
|
||||
return nil, merrors.Internal("ledger balance is unavailable")
|
||||
}
|
||||
return &Balance{
|
||||
AccountID: accountID,
|
||||
Amount: strings.TrimSpace(balance.GetAvailable().GetAmount()),
|
||||
Currency: strings.ToUpper(strings.TrimSpace(balance.GetAvailable().GetCurrency())),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *connectorClient) ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) {
|
||||
return c.submitExternalOperation(ctx, connectorv1.OperationType_CREDIT, discovery.OperationExternalCredit, req)
|
||||
}
|
||||
|
||||
func (c *connectorClient) ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) {
|
||||
return c.submitExternalOperation(ctx, connectorv1.OperationType_DEBIT, discovery.OperationExternalDebit, req)
|
||||
}
|
||||
|
||||
func (c *connectorClient) submitExternalOperation(ctx context.Context, opType connectorv1.OperationType, operation string, req PostRequest) (*OperationResult, error) {
|
||||
req.AccountID = strings.TrimSpace(req.AccountID)
|
||||
req.OrganizationRef = strings.TrimSpace(req.OrganizationRef)
|
||||
req.Amount = strings.TrimSpace(req.Amount)
|
||||
req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||
req.Reference = strings.TrimSpace(req.Reference)
|
||||
req.IdempotencyKey = strings.TrimSpace(req.IdempotencyKey)
|
||||
|
||||
if req.AccountID == "" {
|
||||
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
|
||||
}
|
||||
if req.OrganizationRef == "" {
|
||||
return nil, merrors.InvalidArgument("ledger organization_ref is required", "organization_ref")
|
||||
}
|
||||
if req.Amount == "" || req.Currency == "" {
|
||||
return nil, merrors.InvalidArgument("ledger amount is required", "amount")
|
||||
}
|
||||
if req.IdempotencyKey == "" {
|
||||
return nil, merrors.InvalidArgument("ledger idempotency_key is required", "idempotency_key")
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"organization_ref": req.OrganizationRef,
|
||||
"operation": operation,
|
||||
"description": "tgsettle treasury operation",
|
||||
"metadata": map[string]any{
|
||||
"reference": req.Reference,
|
||||
},
|
||||
}
|
||||
operationReq := &connectorv1.Operation{
|
||||
Type: opType,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Money: &moneyv1.Money{
|
||||
Amount: req.Amount,
|
||||
Currency: req.Currency,
|
||||
},
|
||||
Params: structFromMap(params),
|
||||
}
|
||||
account := &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: req.AccountID}
|
||||
switch opType {
|
||||
case connectorv1.OperationType_CREDIT:
|
||||
operationReq.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}}
|
||||
case connectorv1.OperationType_DEBIT:
|
||||
operationReq.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}}
|
||||
}
|
||||
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operationReq})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.GetReceipt() == nil {
|
||||
return nil, merrors.Internal("ledger receipt is unavailable")
|
||||
}
|
||||
if receiptErr := resp.GetReceipt().GetError(); receiptErr != nil {
|
||||
message := strings.TrimSpace(receiptErr.GetMessage())
|
||||
if message == "" {
|
||||
message = "ledger operation failed"
|
||||
}
|
||||
return nil, merrors.InvalidArgument(message)
|
||||
}
|
||||
reference := strings.TrimSpace(resp.GetReceipt().GetOperationId())
|
||||
if reference == "" {
|
||||
reference = req.Reference
|
||||
}
|
||||
return &OperationResult{Reference: reference}, nil
|
||||
}
|
||||
|
||||
func (c *connectorClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithTimeout(ctx, c.cfg.Timeout)
|
||||
}
|
||||
|
||||
func structFromMap(values map[string]any) *structpb.Struct {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result, err := structpb.NewStruct(values)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeEndpoint(raw string) (string, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return raw, false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
|
||||
case "http", "grpc":
|
||||
return parsed.Host, true
|
||||
case "https", "grpcs":
|
||||
return parsed.Host, false
|
||||
default:
|
||||
return raw, false
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
148
api/gateway/tgsettle/internal/service/treasury/module.go
Normal file
148
api/gateway/tgsettle/internal/service/treasury/module.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package treasury
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/tgsettle/internal/service/treasury/bot"
|
||||
"github.com/tech/sendico/gateway/tgsettle/internal/service/treasury/ledger"
|
||||
"github.com/tech/sendico/gateway/tgsettle/storage"
|
||||
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
logger mlogger.Logger
|
||||
|
||||
service *Service
|
||||
router *bot.Router
|
||||
scheduler *Scheduler
|
||||
ledger ledger.Client
|
||||
}
|
||||
|
||||
func NewModule(
|
||||
logger mlogger.Logger,
|
||||
repo storage.TreasuryRequestsStore,
|
||||
ledgerClient ledger.Client,
|
||||
cfg Config,
|
||||
send bot.SendTextFunc,
|
||||
) (*Module, error) {
|
||||
if logger != nil {
|
||||
logger = logger.Named("treasury")
|
||||
}
|
||||
service, err := NewService(
|
||||
logger,
|
||||
repo,
|
||||
ledgerClient,
|
||||
cfg.ExecutionDelay,
|
||||
cfg.MaxAmountPerOperation,
|
||||
cfg.MaxDailyAmount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users := map[string]string{}
|
||||
for _, binding := range cfg.Users {
|
||||
userID := strings.TrimSpace(binding.TelegramUserID)
|
||||
accountID := strings.TrimSpace(binding.LedgerAccount)
|
||||
if userID == "" || accountID == "" {
|
||||
continue
|
||||
}
|
||||
users[userID] = accountID
|
||||
}
|
||||
|
||||
module := &Module{
|
||||
logger: logger,
|
||||
service: service,
|
||||
ledger: ledgerClient,
|
||||
}
|
||||
module.scheduler = NewScheduler(logger, service, NotifyFunc(send), cfg.PollInterval)
|
||||
module.router = bot.NewRouter(logger, &botServiceAdapter{svc: service}, send, module.scheduler, cfg.AllowedChats, users)
|
||||
return module, nil
|
||||
}
|
||||
|
||||
func (m *Module) Enabled() bool {
|
||||
return m != nil && m.router != nil && m.router.Enabled() && m.scheduler != nil
|
||||
}
|
||||
|
||||
func (m *Module) Start() {
|
||||
if m == nil || m.scheduler == nil {
|
||||
return
|
||||
}
|
||||
m.scheduler.Start()
|
||||
}
|
||||
|
||||
func (m *Module) Shutdown() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
if m.scheduler != nil {
|
||||
m.scheduler.Shutdown()
|
||||
}
|
||||
if m.ledger != nil {
|
||||
_ = m.ledger.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) HandleUpdate(ctx context.Context, update *model.TelegramWebhookUpdate) bool {
|
||||
if m == nil || m.router == nil {
|
||||
return false
|
||||
}
|
||||
return m.router.HandleUpdate(ctx, update)
|
||||
}
|
||||
|
||||
type botServiceAdapter struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func (a *botServiceAdapter) ExecutionDelay() (delay time.Duration) {
|
||||
if a == nil || a.svc == nil {
|
||||
return 0
|
||||
}
|
||||
return a.svc.ExecutionDelay()
|
||||
}
|
||||
|
||||
func (a *botServiceAdapter) MaxPerOperationLimit() string {
|
||||
if a == nil || a.svc == nil {
|
||||
return ""
|
||||
}
|
||||
return a.svc.MaxPerOperationLimit()
|
||||
}
|
||||
|
||||
func (a *botServiceAdapter) GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) {
|
||||
if a == nil || a.svc == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return a.svc.GetActiveRequestForAccount(ctx, ledgerAccountID)
|
||||
}
|
||||
|
||||
func (a *botServiceAdapter) CreateRequest(ctx context.Context, input bot.CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
|
||||
if a == nil || a.svc == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return a.svc.CreateRequest(ctx, CreateRequestInput{
|
||||
OperationType: input.OperationType,
|
||||
TelegramUserID: input.TelegramUserID,
|
||||
LedgerAccountID: input.LedgerAccountID,
|
||||
ChatID: input.ChatID,
|
||||
Amount: input.Amount,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *botServiceAdapter) ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
|
||||
if a == nil || a.svc == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return a.svc.ConfirmRequest(ctx, requestID, telegramUserID)
|
||||
}
|
||||
|
||||
func (a *botServiceAdapter) CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
|
||||
if a == nil || a.svc == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return a.svc.CancelRequest(ctx, requestID, telegramUserID)
|
||||
}
|
||||
261
api/gateway/tgsettle/internal/service/treasury/scheduler.go
Normal file
261
api/gateway/tgsettle/internal/service/treasury/scheduler.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package treasury
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type NotifyFunc func(ctx context.Context, chatID string, text string) error
|
||||
|
||||
type Scheduler struct {
|
||||
logger mlogger.Logger
|
||||
service *Service
|
||||
notify NotifyFunc
|
||||
safetySweepInterval time.Duration
|
||||
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
|
||||
timersMu sync.Mutex
|
||||
timers map[string]*time.Timer
|
||||
}
|
||||
|
||||
func NewScheduler(logger mlogger.Logger, service *Service, notify NotifyFunc, safetySweepInterval time.Duration) *Scheduler {
|
||||
if logger != nil {
|
||||
logger = logger.Named("treasury_scheduler")
|
||||
}
|
||||
if safetySweepInterval <= 0 {
|
||||
safetySweepInterval = 30 * time.Second
|
||||
}
|
||||
return &Scheduler{
|
||||
logger: logger,
|
||||
service: service,
|
||||
notify: notify,
|
||||
safetySweepInterval: safetySweepInterval,
|
||||
timers: map[string]*time.Timer{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() {
|
||||
if s == nil || s.service == nil || s.cancel != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
|
||||
// Rebuild in-memory timers from DB on startup.
|
||||
s.hydrateTimers(ctx)
|
||||
// Safety pass for overdue items at startup.
|
||||
s.sweep(ctx)
|
||||
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
ticker := time.NewTicker(s.safetySweepInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.sweep(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Shutdown() {
|
||||
if s == nil || s.cancel == nil {
|
||||
return
|
||||
}
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
s.timersMu.Lock()
|
||||
for requestID, timer := range s.timers {
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
delete(s.timers, requestID)
|
||||
}
|
||||
s.timersMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scheduler) TrackScheduled(record *storagemodel.TreasuryRequest) {
|
||||
if s == nil || s.service == nil || record == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(record.RequestID) == "" {
|
||||
return
|
||||
}
|
||||
if record.Status != storagemodel.TreasuryRequestStatusScheduled {
|
||||
return
|
||||
}
|
||||
requestID := strings.TrimSpace(record.RequestID)
|
||||
when := record.ScheduledAt
|
||||
if when.IsZero() {
|
||||
when = time.Now()
|
||||
}
|
||||
delay := time.Until(when)
|
||||
if delay <= 0 {
|
||||
s.Untrack(requestID)
|
||||
go s.executeAndNotifyByID(context.Background(), requestID)
|
||||
return
|
||||
}
|
||||
|
||||
s.timersMu.Lock()
|
||||
if existing := s.timers[requestID]; existing != nil {
|
||||
existing.Stop()
|
||||
}
|
||||
s.timers[requestID] = time.AfterFunc(delay, func() {
|
||||
s.Untrack(requestID)
|
||||
s.executeAndNotifyByID(context.Background(), requestID)
|
||||
})
|
||||
s.timersMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Untrack(requestID string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
s.timersMu.Lock()
|
||||
if timer := s.timers[requestID]; timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
delete(s.timers, requestID)
|
||||
s.timersMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scheduler) hydrateTimers(ctx context.Context) {
|
||||
if s == nil || s.service == nil {
|
||||
return
|
||||
}
|
||||
scheduled, err := s.service.ScheduledRequests(ctx, 1000)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to hydrate scheduled treasury requests", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, record := range scheduled {
|
||||
s.TrackScheduled(record)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) sweep(ctx context.Context) {
|
||||
if s == nil || s.service == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
confirmed, err := s.service.DueRequests(ctx, []storagemodel.TreasuryRequestStatus{
|
||||
storagemodel.TreasuryRequestStatusConfirmed,
|
||||
}, now, 100)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to list confirmed treasury requests", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, request := range confirmed {
|
||||
s.executeAndNotifyByID(ctx, strings.TrimSpace(request.RequestID))
|
||||
}
|
||||
|
||||
scheduled, err := s.service.DueRequests(ctx, []storagemodel.TreasuryRequestStatus{
|
||||
storagemodel.TreasuryRequestStatusScheduled,
|
||||
}, now, 100)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to list scheduled treasury requests", zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, request := range scheduled {
|
||||
s.Untrack(strings.TrimSpace(request.RequestID))
|
||||
s.executeAndNotifyByID(ctx, strings.TrimSpace(request.RequestID))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) executeAndNotifyByID(ctx context.Context, requestID string) {
|
||||
if s == nil || s.service == nil {
|
||||
return
|
||||
}
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
runCtx := ctx
|
||||
if runCtx == nil {
|
||||
runCtx = context.Background()
|
||||
}
|
||||
withTimeout, cancel := context.WithTimeout(runCtx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := s.service.ExecuteRequest(withTimeout, requestID)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to execute treasury request", zap.Error(err), zap.String("request_id", requestID))
|
||||
return
|
||||
}
|
||||
if result == nil || result.Request == nil || s.notify == nil {
|
||||
return
|
||||
}
|
||||
|
||||
text := executionMessage(result)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return
|
||||
}
|
||||
if err := s.notify(ctx, strings.TrimSpace(result.Request.ChatID), text); err != nil {
|
||||
s.logger.Warn("Failed to notify treasury execution result", zap.Error(err), zap.String("request_id", strings.TrimSpace(result.Request.RequestID)))
|
||||
}
|
||||
}
|
||||
|
||||
func executionMessage(result *ExecutionResult) string {
|
||||
if result == nil || result.Request == nil {
|
||||
return ""
|
||||
}
|
||||
request := result.Request
|
||||
switch request.Status {
|
||||
case storagemodel.TreasuryRequestStatusExecuted:
|
||||
op := "Funding"
|
||||
sign := "+"
|
||||
if request.OperationType == storagemodel.TreasuryOperationWithdraw {
|
||||
op = "Withdrawal"
|
||||
sign = "-"
|
||||
}
|
||||
balanceAmount := "unavailable"
|
||||
balanceCurrency := strings.TrimSpace(request.Currency)
|
||||
if result.NewBalance != nil {
|
||||
if strings.TrimSpace(result.NewBalance.Amount) != "" {
|
||||
balanceAmount = strings.TrimSpace(result.NewBalance.Amount)
|
||||
}
|
||||
if strings.TrimSpace(result.NewBalance.Currency) != "" {
|
||||
balanceCurrency = strings.TrimSpace(result.NewBalance.Currency)
|
||||
}
|
||||
}
|
||||
return op + " completed.\n\n" +
|
||||
"Account: " + strings.TrimSpace(request.LedgerAccountID) + "\n" +
|
||||
"Amount: " + sign + strings.TrimSpace(request.Amount) + " " + strings.TrimSpace(request.Currency) + "\n" +
|
||||
"New balance: " + balanceAmount + " " + balanceCurrency + "\n\n" +
|
||||
"Reference: " + strings.TrimSpace(request.RequestID)
|
||||
case storagemodel.TreasuryRequestStatusFailed:
|
||||
reason := strings.TrimSpace(request.ErrorMessage)
|
||||
if reason == "" && result.ExecutionError != nil {
|
||||
reason = strings.TrimSpace(result.ExecutionError.Error())
|
||||
}
|
||||
if reason == "" {
|
||||
reason = "Unknown error."
|
||||
}
|
||||
return "Execution failed.\n\n" +
|
||||
"Account: " + strings.TrimSpace(request.LedgerAccountID) + "\n" +
|
||||
"Amount: " + strings.TrimSpace(request.Amount) + " " + strings.TrimSpace(request.Currency) + "\n" +
|
||||
"Status: FAILED\n\n" +
|
||||
"Reason:\n" + reason + "\n\n" +
|
||||
"Request ID: " + strings.TrimSpace(request.RequestID)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
411
api/gateway/tgsettle/internal/service/treasury/service.go
Normal file
411
api/gateway/tgsettle/internal/service/treasury/service.go
Normal file
@@ -0,0 +1,411 @@
|
||||
package treasury
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/tgsettle/internal/service/treasury/ledger"
|
||||
"github.com/tech/sendico/gateway/tgsettle/storage"
|
||||
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var ErrActiveTreasuryRequest = errors.New("active treasury request exists")
|
||||
|
||||
type CreateRequestInput struct {
|
||||
OperationType storagemodel.TreasuryOperationType
|
||||
TelegramUserID string
|
||||
LedgerAccountID string
|
||||
ChatID string
|
||||
Amount string
|
||||
}
|
||||
|
||||
type ExecutionResult struct {
|
||||
Request *storagemodel.TreasuryRequest
|
||||
NewBalance *ledger.Balance
|
||||
ExecutionError error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
logger mlogger.Logger
|
||||
repo storage.TreasuryRequestsStore
|
||||
ledger ledger.Client
|
||||
|
||||
validator *Validator
|
||||
executionDelay time.Duration
|
||||
}
|
||||
|
||||
func NewService(
|
||||
logger mlogger.Logger,
|
||||
repo storage.TreasuryRequestsStore,
|
||||
ledgerClient ledger.Client,
|
||||
executionDelay time.Duration,
|
||||
maxPerOperation string,
|
||||
maxDaily string,
|
||||
) (*Service, error) {
|
||||
if logger == nil {
|
||||
return nil, merrors.InvalidArgument("logger is required", "logger")
|
||||
}
|
||||
if repo == nil {
|
||||
return nil, merrors.InvalidArgument("treasury repository is required", "repo")
|
||||
}
|
||||
if ledgerClient == nil {
|
||||
return nil, merrors.InvalidArgument("ledger client is required", "ledger_client")
|
||||
}
|
||||
if executionDelay <= 0 {
|
||||
executionDelay = 30 * time.Second
|
||||
}
|
||||
validator, err := NewValidator(repo, maxPerOperation, maxDaily)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Service{
|
||||
logger: logger.Named("treasury_service"),
|
||||
repo: repo,
|
||||
ledger: ledgerClient,
|
||||
validator: validator,
|
||||
executionDelay: executionDelay,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExecutionDelay() time.Duration {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return s.executionDelay
|
||||
}
|
||||
|
||||
func (s *Service) MaxPerOperationLimit() string {
|
||||
if s == nil || s.validator == nil {
|
||||
return ""
|
||||
}
|
||||
return s.validator.MaxPerOperation()
|
||||
}
|
||||
|
||||
func (s *Service) GetActiveRequestForAccount(ctx context.Context, ledgerAccountID string) (*storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return s.repo.FindActiveByLedgerAccountID(ctx, ledgerAccountID)
|
||||
}
|
||||
|
||||
func (s *Service) GetRequest(ctx context.Context, requestID string) (*storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return s.repo.FindByRequestID(ctx, requestID)
|
||||
}
|
||||
|
||||
func (s *Service) CreateRequest(ctx context.Context, input CreateRequestInput) (*storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil || s.ledger == nil || s.validator == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
input.TelegramUserID = strings.TrimSpace(input.TelegramUserID)
|
||||
input.LedgerAccountID = strings.TrimSpace(input.LedgerAccountID)
|
||||
input.ChatID = strings.TrimSpace(input.ChatID)
|
||||
input.Amount = strings.TrimSpace(input.Amount)
|
||||
|
||||
switch input.OperationType {
|
||||
case storagemodel.TreasuryOperationFund, storagemodel.TreasuryOperationWithdraw:
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("treasury operation is invalid", "operation_type")
|
||||
}
|
||||
if input.TelegramUserID == "" {
|
||||
return nil, merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id")
|
||||
}
|
||||
if input.LedgerAccountID == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
|
||||
}
|
||||
if input.ChatID == "" {
|
||||
return nil, merrors.InvalidArgument("chat_id is required", "chat_id")
|
||||
}
|
||||
|
||||
active, err := s.repo.FindActiveByLedgerAccountID(ctx, input.LedgerAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if active != nil {
|
||||
return active, ErrActiveTreasuryRequest
|
||||
}
|
||||
|
||||
amountRat, normalizedAmount, err := s.validator.ValidateAmount(input.Amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validator.ValidateDailyLimit(ctx, input.LedgerAccountID, amountRat, time.Now()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
account, err := s.ledger.GetAccount(ctx, input.LedgerAccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if account == nil || strings.TrimSpace(account.Currency) == "" {
|
||||
return nil, merrors.Internal("ledger account currency is unavailable")
|
||||
}
|
||||
if strings.TrimSpace(account.OrganizationRef) == "" {
|
||||
return nil, merrors.Internal("ledger account organization is unavailable")
|
||||
}
|
||||
|
||||
requestID := newRequestID()
|
||||
record := &storagemodel.TreasuryRequest{
|
||||
RequestID: requestID,
|
||||
OperationType: input.OperationType,
|
||||
TelegramUserID: input.TelegramUserID,
|
||||
LedgerAccountID: input.LedgerAccountID,
|
||||
OrganizationRef: account.OrganizationRef,
|
||||
ChatID: input.ChatID,
|
||||
Amount: normalizedAmount,
|
||||
Currency: strings.ToUpper(strings.TrimSpace(account.Currency)),
|
||||
Status: storagemodel.TreasuryRequestStatusCreated,
|
||||
IdempotencyKey: fmt.Sprintf("tgsettle:%s", requestID),
|
||||
Active: true,
|
||||
}
|
||||
if err := s.repo.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicate) {
|
||||
active, fetchErr := s.repo.FindActiveByLedgerAccountID(ctx, input.LedgerAccountID)
|
||||
if fetchErr != nil {
|
||||
return nil, fetchErr
|
||||
}
|
||||
if active != nil {
|
||||
return active, ErrActiveTreasuryRequest
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logRequest(record, "created", nil)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) ConfirmRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
||||
if requestID == "" {
|
||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
||||
}
|
||||
record, err := s.repo.FindByRequestID(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, merrors.NoData("treasury request not found")
|
||||
}
|
||||
if telegramUserID != "" && record.TelegramUserID != telegramUserID {
|
||||
return nil, merrors.Unauthorized("treasury request ownership mismatch")
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case storagemodel.TreasuryRequestStatusScheduled:
|
||||
return record, nil
|
||||
case storagemodel.TreasuryRequestStatusCreated, storagemodel.TreasuryRequestStatusConfirmed:
|
||||
now := time.Now()
|
||||
record.ConfirmedAt = now
|
||||
record.ScheduledAt = now.Add(s.executionDelay)
|
||||
record.Status = storagemodel.TreasuryRequestStatusScheduled
|
||||
record.Active = true
|
||||
record.ErrorMessage = ""
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("treasury request cannot be confirmed in current status", "status")
|
||||
}
|
||||
if err := s.repo.Update(ctx, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logRequest(record, "scheduled", nil)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) CancelRequest(ctx context.Context, requestID string, telegramUserID string) (*storagemodel.TreasuryRequest, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
telegramUserID = strings.TrimSpace(telegramUserID)
|
||||
if requestID == "" {
|
||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
||||
}
|
||||
record, err := s.repo.FindByRequestID(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, merrors.NoData("treasury request not found")
|
||||
}
|
||||
if telegramUserID != "" && record.TelegramUserID != telegramUserID {
|
||||
return nil, merrors.Unauthorized("treasury request ownership mismatch")
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case storagemodel.TreasuryRequestStatusCancelled:
|
||||
return record, nil
|
||||
case storagemodel.TreasuryRequestStatusCreated, storagemodel.TreasuryRequestStatusConfirmed, storagemodel.TreasuryRequestStatusScheduled:
|
||||
record.Status = storagemodel.TreasuryRequestStatusCancelled
|
||||
record.CancelledAt = time.Now()
|
||||
record.Active = false
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("treasury request cannot be cancelled in current status", "status")
|
||||
}
|
||||
|
||||
if err := s.repo.Update(ctx, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logRequest(record, "cancelled", nil)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExecuteRequest(ctx context.Context, requestID string) (*ExecutionResult, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
||||
}
|
||||
record, err := s.repo.FindByRequestID(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case storagemodel.TreasuryRequestStatusExecuted,
|
||||
storagemodel.TreasuryRequestStatusCancelled,
|
||||
storagemodel.TreasuryRequestStatusFailed:
|
||||
return nil, nil
|
||||
case storagemodel.TreasuryRequestStatusScheduled:
|
||||
claimed, err := s.repo.ClaimScheduled(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !claimed {
|
||||
return nil, nil
|
||||
}
|
||||
record, err = s.repo.FindByRequestID(ctx, requestID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
if record.Status != storagemodel.TreasuryRequestStatusConfirmed {
|
||||
return nil, nil
|
||||
}
|
||||
return s.executeClaimed(ctx, record)
|
||||
}
|
||||
|
||||
func (s *Service) executeClaimed(ctx context.Context, record *storagemodel.TreasuryRequest) (*ExecutionResult, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("treasury request is required", "request")
|
||||
}
|
||||
postReq := ledger.PostRequest{
|
||||
AccountID: record.LedgerAccountID,
|
||||
OrganizationRef: record.OrganizationRef,
|
||||
Amount: record.Amount,
|
||||
Currency: record.Currency,
|
||||
Reference: record.RequestID,
|
||||
IdempotencyKey: record.IdempotencyKey,
|
||||
}
|
||||
|
||||
var (
|
||||
opResult *ledger.OperationResult
|
||||
err error
|
||||
)
|
||||
switch record.OperationType {
|
||||
case storagemodel.TreasuryOperationFund:
|
||||
opResult, err = s.ledger.ExternalCredit(ctx, postReq)
|
||||
case storagemodel.TreasuryOperationWithdraw:
|
||||
opResult, err = s.ledger.ExternalDebit(ctx, postReq)
|
||||
default:
|
||||
err = merrors.InvalidArgument("treasury operation is invalid", "operation_type")
|
||||
}
|
||||
now := time.Now()
|
||||
if err != nil {
|
||||
record.Status = storagemodel.TreasuryRequestStatusFailed
|
||||
record.Active = false
|
||||
record.ExecutedAt = now
|
||||
record.ErrorMessage = strings.TrimSpace(err.Error())
|
||||
if saveErr := s.repo.Update(ctx, record); saveErr != nil {
|
||||
return nil, saveErr
|
||||
}
|
||||
s.logRequest(record, "failed", err)
|
||||
return &ExecutionResult{
|
||||
Request: record,
|
||||
ExecutionError: err,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if opResult != nil {
|
||||
record.LedgerReference = strings.TrimSpace(opResult.Reference)
|
||||
}
|
||||
record.Status = storagemodel.TreasuryRequestStatusExecuted
|
||||
record.Active = false
|
||||
record.ExecutedAt = now
|
||||
record.ErrorMessage = ""
|
||||
|
||||
balance, balanceErr := s.ledger.GetBalance(ctx, record.LedgerAccountID)
|
||||
if balanceErr != nil {
|
||||
record.ErrorMessage = strings.TrimSpace(balanceErr.Error())
|
||||
}
|
||||
|
||||
if saveErr := s.repo.Update(ctx, record); saveErr != nil {
|
||||
return nil, saveErr
|
||||
}
|
||||
s.logRequest(record, "executed", nil)
|
||||
return &ExecutionResult{
|
||||
Request: record,
|
||||
NewBalance: balance,
|
||||
ExecutionError: balanceErr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DueRequests(ctx context.Context, statuses []storagemodel.TreasuryRequestStatus, now time.Time, limit int64) ([]*storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return s.repo.FindDueByStatus(ctx, statuses, now, limit)
|
||||
}
|
||||
|
||||
func (s *Service) ScheduledRequests(ctx context.Context, limit int64) ([]*storagemodel.TreasuryRequest, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, merrors.Internal("treasury service unavailable")
|
||||
}
|
||||
return s.repo.FindDueByStatus(
|
||||
ctx,
|
||||
[]storagemodel.TreasuryRequestStatus{storagemodel.TreasuryRequestStatusScheduled},
|
||||
time.Now().Add(10*365*24*time.Hour),
|
||||
limit,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) ParseAmount(value string) (*big.Rat, error) {
|
||||
return parseAmountRat(value)
|
||||
}
|
||||
|
||||
func (s *Service) logRequest(record *storagemodel.TreasuryRequest, status string, err error) {
|
||||
if s == nil || s.logger == nil || record == nil {
|
||||
return
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("request_id", strings.TrimSpace(record.RequestID)),
|
||||
zap.String("telegram_user_id", strings.TrimSpace(record.TelegramUserID)),
|
||||
zap.String("ledger_account_id", strings.TrimSpace(record.LedgerAccountID)),
|
||||
zap.String("operation_type", strings.TrimSpace(string(record.OperationType))),
|
||||
zap.String("amount", strings.TrimSpace(record.Amount)),
|
||||
zap.String("currency", strings.TrimSpace(record.Currency)),
|
||||
zap.String("status", status),
|
||||
}
|
||||
if err != nil {
|
||||
fields = append(fields, zap.Error(err))
|
||||
}
|
||||
s.logger.Info("treasury_request", fields...)
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
return "TGSETTLE-" + strings.ToUpper(bson.NewObjectID().Hex()[:8])
|
||||
}
|
||||
181
api/gateway/tgsettle/internal/service/treasury/validator.go
Normal file
181
api/gateway/tgsettle/internal/service/treasury/validator.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package treasury
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/tgsettle/storage"
|
||||
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
var treasuryAmountPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`)
|
||||
|
||||
type LimitKind string
|
||||
|
||||
const (
|
||||
LimitKindPerOperation LimitKind = "per_operation"
|
||||
LimitKindDaily LimitKind = "daily"
|
||||
)
|
||||
|
||||
type LimitError struct {
|
||||
Kind LimitKind
|
||||
Max string
|
||||
}
|
||||
|
||||
func (e *LimitError) Error() string {
|
||||
if e == nil {
|
||||
return "limit exceeded"
|
||||
}
|
||||
switch e.Kind {
|
||||
case LimitKindPerOperation:
|
||||
return "max amount per operation exceeded"
|
||||
case LimitKindDaily:
|
||||
return "max daily amount exceeded"
|
||||
default:
|
||||
return "limit exceeded"
|
||||
}
|
||||
}
|
||||
|
||||
func (e *LimitError) LimitKind() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return string(e.Kind)
|
||||
}
|
||||
|
||||
func (e *LimitError) LimitMax() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Max
|
||||
}
|
||||
|
||||
type Validator struct {
|
||||
repo storage.TreasuryRequestsStore
|
||||
|
||||
maxPerOperation *big.Rat
|
||||
maxDaily *big.Rat
|
||||
|
||||
maxPerOperationRaw string
|
||||
maxDailyRaw string
|
||||
}
|
||||
|
||||
func NewValidator(repo storage.TreasuryRequestsStore, maxPerOperation string, maxDaily string) (*Validator, error) {
|
||||
validator := &Validator{
|
||||
repo: repo,
|
||||
maxPerOperationRaw: strings.TrimSpace(maxPerOperation),
|
||||
maxDailyRaw: strings.TrimSpace(maxDaily),
|
||||
}
|
||||
if validator.maxPerOperationRaw != "" {
|
||||
value, err := parseAmountRat(validator.maxPerOperationRaw)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("treasury max_amount_per_operation is invalid", "treasury.limits.max_amount_per_operation")
|
||||
}
|
||||
validator.maxPerOperation = value
|
||||
}
|
||||
if validator.maxDailyRaw != "" {
|
||||
value, err := parseAmountRat(validator.maxDailyRaw)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("treasury max_daily_amount is invalid", "treasury.limits.max_daily_amount")
|
||||
}
|
||||
validator.maxDaily = value
|
||||
}
|
||||
return validator, nil
|
||||
}
|
||||
|
||||
func (v *Validator) MaxPerOperation() string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.maxPerOperationRaw
|
||||
}
|
||||
|
||||
func (v *Validator) MaxDaily() string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.maxDailyRaw
|
||||
}
|
||||
|
||||
func (v *Validator) ValidateAmount(amount string) (*big.Rat, string, error) {
|
||||
amount = strings.TrimSpace(amount)
|
||||
value, err := parseAmountRat(amount)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if v != nil && v.maxPerOperation != nil && value.Cmp(v.maxPerOperation) > 0 {
|
||||
return nil, "", &LimitError{
|
||||
Kind: LimitKindPerOperation,
|
||||
Max: v.maxPerOperationRaw,
|
||||
}
|
||||
}
|
||||
return value, amount, nil
|
||||
}
|
||||
|
||||
func (v *Validator) ValidateDailyLimit(ctx context.Context, ledgerAccountID string, amount *big.Rat, now time.Time) error {
|
||||
if v == nil || v.maxDaily == nil || v.repo == nil {
|
||||
return nil
|
||||
}
|
||||
if amount == nil {
|
||||
return merrors.InvalidArgument("amount is required", "amount")
|
||||
}
|
||||
dayStart := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC)
|
||||
dayEnd := dayStart.Add(24 * time.Hour)
|
||||
|
||||
records, err := v.repo.ListByAccountAndStatuses(
|
||||
ctx,
|
||||
ledgerAccountID,
|
||||
[]storagemodel.TreasuryRequestStatus{
|
||||
storagemodel.TreasuryRequestStatusCreated,
|
||||
storagemodel.TreasuryRequestStatusConfirmed,
|
||||
storagemodel.TreasuryRequestStatusScheduled,
|
||||
storagemodel.TreasuryRequestStatusExecuted,
|
||||
},
|
||||
dayStart,
|
||||
dayEnd,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total := new(big.Rat)
|
||||
for _, record := range records {
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
next, err := parseAmountRat(record.Amount)
|
||||
if err != nil {
|
||||
return merrors.Internal("treasury request amount is invalid")
|
||||
}
|
||||
total.Add(total, next)
|
||||
}
|
||||
total.Add(total, amount)
|
||||
if total.Cmp(v.maxDaily) > 0 {
|
||||
return &LimitError{
|
||||
Kind: LimitKindDaily,
|
||||
Max: v.maxDailyRaw,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAmountRat(value string) (*big.Rat, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, merrors.InvalidArgument("amount is required", "amount")
|
||||
}
|
||||
if !treasuryAmountPattern.MatchString(value) {
|
||||
return nil, merrors.InvalidArgument("amount format is invalid", "amount")
|
||||
}
|
||||
amount := new(big.Rat)
|
||||
if _, ok := amount.SetString(value); !ok {
|
||||
return nil, merrors.InvalidArgument("amount format is invalid", "amount")
|
||||
}
|
||||
if amount.Sign() <= 0 {
|
||||
return nil, merrors.InvalidArgument("amount must be positive", "amount")
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const (
|
||||
paymentsCollection = "payments"
|
||||
telegramConfirmationsCollection = "telegram_confirmations"
|
||||
pendingConfirmationsCollection = "pending_confirmations"
|
||||
treasuryRequestsCollection = "treasury_requests"
|
||||
)
|
||||
|
||||
func (*PaymentRecord) Collection() string {
|
||||
@@ -17,3 +18,7 @@ func (*TelegramConfirmation) Collection() string {
|
||||
func (*PendingConfirmation) Collection() string {
|
||||
return pendingConfirmationsCollection
|
||||
}
|
||||
|
||||
func (*TreasuryRequest) Collection() string {
|
||||
return treasuryRequestsCollection
|
||||
}
|
||||
|
||||
50
api/gateway/tgsettle/storage/model/treasury.go
Normal file
50
api/gateway/tgsettle/storage/model/treasury.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
)
|
||||
|
||||
type TreasuryOperationType string
|
||||
|
||||
const (
|
||||
TreasuryOperationFund TreasuryOperationType = "fund"
|
||||
TreasuryOperationWithdraw TreasuryOperationType = "withdraw"
|
||||
)
|
||||
|
||||
type TreasuryRequestStatus string
|
||||
|
||||
const (
|
||||
TreasuryRequestStatusCreated TreasuryRequestStatus = "created"
|
||||
TreasuryRequestStatusConfirmed TreasuryRequestStatus = "confirmed"
|
||||
TreasuryRequestStatusScheduled TreasuryRequestStatus = "scheduled"
|
||||
TreasuryRequestStatusExecuted TreasuryRequestStatus = "executed"
|
||||
TreasuryRequestStatusCancelled TreasuryRequestStatus = "cancelled"
|
||||
TreasuryRequestStatusFailed TreasuryRequestStatus = "failed"
|
||||
)
|
||||
|
||||
type TreasuryRequest struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
|
||||
RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"`
|
||||
OperationType TreasuryOperationType `bson:"operationType,omitempty" json:"operation_type,omitempty"`
|
||||
TelegramUserID string `bson:"telegramUserId,omitempty" json:"telegram_user_id,omitempty"`
|
||||
LedgerAccountID string `bson:"ledgerAccountId,omitempty" json:"ledger_account_id,omitempty"`
|
||||
OrganizationRef string `bson:"organizationRef,omitempty" json:"organization_ref,omitempty"`
|
||||
ChatID string `bson:"chatId,omitempty" json:"chat_id,omitempty"`
|
||||
Amount string `bson:"amount,omitempty" json:"amount,omitempty"`
|
||||
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
||||
Status TreasuryRequestStatus `bson:"status,omitempty" json:"status,omitempty"`
|
||||
|
||||
ConfirmedAt time.Time `bson:"confirmedAt,omitempty" json:"confirmed_at,omitempty"`
|
||||
ScheduledAt time.Time `bson:"scheduledAt,omitempty" json:"scheduled_at,omitempty"`
|
||||
ExecutedAt time.Time `bson:"executedAt,omitempty" json:"executed_at,omitempty"`
|
||||
CancelledAt time.Time `bson:"cancelledAt,omitempty" json:"cancelled_at,omitempty"`
|
||||
|
||||
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"`
|
||||
LedgerReference string `bson:"ledgerReference,omitempty" json:"ledger_reference,omitempty"`
|
||||
ErrorMessage string `bson:"errorMessage,omitempty" json:"error_message,omitempty"`
|
||||
|
||||
Active bool `bson:"active,omitempty" json:"active,omitempty"`
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type Repository struct {
|
||||
payments storage.PaymentsStore
|
||||
tg storage.TelegramConfirmationsStore
|
||||
pending storage.PendingConfirmationsStore
|
||||
treasury storage.TreasuryRequestsStore
|
||||
outbox gatewayoutbox.Store
|
||||
}
|
||||
|
||||
@@ -74,6 +75,11 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
|
||||
result.logger.Error("Failed to initialise pending confirmations store", zap.Error(err), zap.String("store", "pending_confirmations"))
|
||||
return nil, err
|
||||
}
|
||||
treasuryStore, err := store.NewTreasuryRequests(result.logger, result.db)
|
||||
if err != nil {
|
||||
result.logger.Error("Failed to initialise treasury requests store", zap.Error(err), zap.String("store", "treasury_requests"))
|
||||
return nil, err
|
||||
}
|
||||
outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db)
|
||||
if err != nil {
|
||||
result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox"))
|
||||
@@ -82,6 +88,7 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
|
||||
result.payments = paymentsStore
|
||||
result.tg = tgStore
|
||||
result.pending = pendingStore
|
||||
result.treasury = treasuryStore
|
||||
result.outbox = outboxStore
|
||||
result.logger.Info("Payment gateway MongoDB storage initialised")
|
||||
return result, nil
|
||||
@@ -99,6 +106,10 @@ func (r *Repository) PendingConfirmations() storage.PendingConfirmationsStore {
|
||||
return r.pending
|
||||
}
|
||||
|
||||
func (r *Repository) TreasuryRequests() storage.TreasuryRequestsStore {
|
||||
return r.treasury
|
||||
}
|
||||
|
||||
func (r *Repository) Outbox() gatewayoutbox.Store {
|
||||
return r.outbox
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
const (
|
||||
paymentsCollection = "payments"
|
||||
fieldIdempotencyKey = "idempotencyKey"
|
||||
fieldOperationRef = "operationRef"
|
||||
)
|
||||
|
||||
type Payments struct {
|
||||
@@ -44,6 +45,14 @@ func NewPayments(logger mlogger.Logger, db *mongo.Database) (*Payments, error) {
|
||||
logger.Error("Failed to create payments idempotency index", zap.Error(err), zap.String("index_field", fieldIdempotencyKey))
|
||||
return nil, err
|
||||
}
|
||||
if err := repo.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: fieldOperationRef, Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
Sparse: true,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to create payments operation index", zap.Error(err), zap.String("index_field", fieldOperationRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := &Payments{
|
||||
logger: logger,
|
||||
@@ -72,6 +81,25 @@ func (p *Payments) FindByIdempotencyKey(ctx context.Context, key string) (*model
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (p *Payments) FindByOperationRef(ctx context.Context, key string) (*model.PaymentRecord, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, merrors.InvalidArgument("operation reference is required", "operation_ref")
|
||||
}
|
||||
var result model.PaymentRecord
|
||||
err := p.repo.FindOneByFilter(ctx, repository.Filter(fieldOperationRef, key), &result)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
||||
p.logger.Warn("Payment record lookup by operation ref failed", zap.String("operation_ref", key), zap.Error(err))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) error {
|
||||
if record == nil {
|
||||
return merrors.InvalidArgument("payment record is nil", "record")
|
||||
@@ -82,6 +110,7 @@ func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) erro
|
||||
record.OutgoingLeg = strings.TrimSpace(record.OutgoingLeg)
|
||||
record.TargetChatID = strings.TrimSpace(record.TargetChatID)
|
||||
record.IntentRef = strings.TrimSpace(record.IntentRef)
|
||||
record.OperationRef = strings.TrimSpace(record.OperationRef)
|
||||
if record.PaymentIntentID == "" {
|
||||
return merrors.InvalidArgument("intention reference is required", "payment_intent_ref")
|
||||
}
|
||||
|
||||
311
api/gateway/tgsettle/storage/mongo/store/treasury_requests.go
Normal file
311
api/gateway/tgsettle/storage/mongo/store/treasury_requests.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/tgsettle/storage"
|
||||
"github.com/tech/sendico/gateway/tgsettle/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
treasuryRequestsCollection = "treasury_requests"
|
||||
|
||||
fieldTreasuryRequestID = "requestId"
|
||||
fieldTreasuryLedgerAccount = "ledgerAccountId"
|
||||
fieldTreasuryIdempotencyKey = "idempotencyKey"
|
||||
fieldTreasuryStatus = "status"
|
||||
fieldTreasuryScheduledAt = "scheduledAt"
|
||||
fieldTreasuryCreatedAt = "createdAt"
|
||||
fieldTreasuryActive = "active"
|
||||
)
|
||||
|
||||
type TreasuryRequests struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
func NewTreasuryRequests(logger mlogger.Logger, db *mongo.Database) (*TreasuryRequests, error) {
|
||||
if db == nil {
|
||||
return nil, merrors.InvalidArgument("mongo database is nil")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
logger = logger.Named("treasury_requests").With(zap.String("collection", treasuryRequestsCollection))
|
||||
|
||||
repo := repository.CreateMongoRepository(db, treasuryRequestsCollection)
|
||||
if err := repo.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: fieldTreasuryRequestID, Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to create treasury requests request_id index", zap.Error(err), zap.String("index_field", fieldTreasuryRequestID))
|
||||
return nil, err
|
||||
}
|
||||
if err := repo.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: fieldTreasuryIdempotencyKey, Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
}); err != nil {
|
||||
logger.Error("Failed to create treasury requests idempotency index", zap.Error(err), zap.String("index_field", fieldTreasuryIdempotencyKey))
|
||||
return nil, err
|
||||
}
|
||||
if err := repo.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: fieldTreasuryLedgerAccount, Sort: ri.Asc},
|
||||
{Field: fieldTreasuryActive, Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
PartialFilter: repository.Filter(fieldTreasuryActive, true),
|
||||
}); err != nil {
|
||||
logger.Error("Failed to create treasury requests active-account index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if err := repo.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: fieldTreasuryStatus, Sort: ri.Asc},
|
||||
{Field: fieldTreasuryScheduledAt, Sort: ri.Asc},
|
||||
},
|
||||
}); err != nil {
|
||||
logger.Error("Failed to create treasury requests execution index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if err := repo.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: fieldTreasuryLedgerAccount, Sort: ri.Asc},
|
||||
{Field: fieldTreasuryCreatedAt, Sort: ri.Asc},
|
||||
},
|
||||
}); err != nil {
|
||||
logger.Error("Failed to create treasury requests daily-amount index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := &TreasuryRequests{
|
||||
logger: logger,
|
||||
repo: repo,
|
||||
}
|
||||
t.logger.Debug("Treasury requests store initialised")
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (t *TreasuryRequests) Create(ctx context.Context, record *model.TreasuryRequest) error {
|
||||
if record == nil {
|
||||
return merrors.InvalidArgument("treasury request is nil", "record")
|
||||
}
|
||||
record.RequestID = strings.TrimSpace(record.RequestID)
|
||||
record.TelegramUserID = strings.TrimSpace(record.TelegramUserID)
|
||||
record.LedgerAccountID = strings.TrimSpace(record.LedgerAccountID)
|
||||
record.OrganizationRef = strings.TrimSpace(record.OrganizationRef)
|
||||
record.ChatID = strings.TrimSpace(record.ChatID)
|
||||
record.Amount = strings.TrimSpace(record.Amount)
|
||||
record.Currency = strings.ToUpper(strings.TrimSpace(record.Currency))
|
||||
record.IdempotencyKey = strings.TrimSpace(record.IdempotencyKey)
|
||||
record.LedgerReference = strings.TrimSpace(record.LedgerReference)
|
||||
record.ErrorMessage = strings.TrimSpace(record.ErrorMessage)
|
||||
|
||||
if record.RequestID == "" {
|
||||
return merrors.InvalidArgument("request_id is required", "request_id")
|
||||
}
|
||||
if record.TelegramUserID == "" {
|
||||
return merrors.InvalidArgument("telegram_user_id is required", "telegram_user_id")
|
||||
}
|
||||
if record.LedgerAccountID == "" {
|
||||
return merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
|
||||
}
|
||||
if record.Amount == "" {
|
||||
return merrors.InvalidArgument("amount is required", "amount")
|
||||
}
|
||||
if record.Currency == "" {
|
||||
return merrors.InvalidArgument("currency is required", "currency")
|
||||
}
|
||||
if record.IdempotencyKey == "" {
|
||||
return merrors.InvalidArgument("idempotency_key is required", "idempotency_key")
|
||||
}
|
||||
if record.Status == "" {
|
||||
return merrors.InvalidArgument("status is required", "status")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if record.CreatedAt.IsZero() {
|
||||
record.CreatedAt = now
|
||||
}
|
||||
record.UpdatedAt = now
|
||||
record.ID = bson.NilObjectID
|
||||
|
||||
err := t.repo.Insert(ctx, record, repository.Filter(fieldTreasuryRequestID, record.RequestID))
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
return storage.ErrDuplicate
|
||||
}
|
||||
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.logger.Warn("Failed to create treasury request", zap.Error(err), zap.String("request_id", record.RequestID))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *TreasuryRequests) FindByRequestID(ctx context.Context, requestID string) (*model.TreasuryRequest, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return nil, merrors.InvalidArgument("request_id is required", "request_id")
|
||||
}
|
||||
var result model.TreasuryRequest
|
||||
err := t.repo.FindOneByFilter(ctx, repository.Filter(fieldTreasuryRequestID, requestID), &result)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (t *TreasuryRequests) FindActiveByLedgerAccountID(ctx context.Context, ledgerAccountID string) (*model.TreasuryRequest, error) {
|
||||
ledgerAccountID = strings.TrimSpace(ledgerAccountID)
|
||||
if ledgerAccountID == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
|
||||
}
|
||||
var result model.TreasuryRequest
|
||||
query := repository.Query().
|
||||
Filter(repository.Field(fieldTreasuryLedgerAccount), ledgerAccountID).
|
||||
Filter(repository.Field(fieldTreasuryActive), true)
|
||||
err := t.repo.FindOneByFilter(ctx, query, &result)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (t *TreasuryRequests) FindDueByStatus(ctx context.Context, statuses []model.TreasuryRequestStatus, now time.Time, limit int64) ([]*model.TreasuryRequest, error) {
|
||||
if len(statuses) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
statusValues := make([]any, 0, len(statuses))
|
||||
for _, status := range statuses {
|
||||
next := strings.TrimSpace(string(status))
|
||||
if next == "" {
|
||||
continue
|
||||
}
|
||||
statusValues = append(statusValues, next)
|
||||
}
|
||||
if len(statusValues) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
query := repository.Query().
|
||||
In(repository.Field(fieldTreasuryStatus), statusValues...).
|
||||
Comparison(repository.Field(fieldTreasuryScheduledAt), builder.Lte, now).
|
||||
Sort(repository.Field(fieldTreasuryScheduledAt), true).
|
||||
Limit(&limit)
|
||||
|
||||
result := make([]*model.TreasuryRequest, 0)
|
||||
err := t.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
|
||||
next := &model.TreasuryRequest{}
|
||||
if err := cur.Decode(next); err != nil {
|
||||
return err
|
||||
}
|
||||
result = append(result, next)
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (t *TreasuryRequests) ClaimScheduled(ctx context.Context, requestID string) (bool, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if requestID == "" {
|
||||
return false, merrors.InvalidArgument("request_id is required", "request_id")
|
||||
}
|
||||
patch := repository.Patch().
|
||||
Set(repository.Field(fieldTreasuryStatus), string(model.TreasuryRequestStatusConfirmed)).
|
||||
Set(repository.Field("updatedAt"), time.Now())
|
||||
updated, err := t.repo.PatchMany(ctx, repository.Filter(fieldTreasuryRequestID, requestID).And(
|
||||
repository.Filter(fieldTreasuryStatus, string(model.TreasuryRequestStatusScheduled)),
|
||||
), patch)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return updated > 0, nil
|
||||
}
|
||||
|
||||
func (t *TreasuryRequests) Update(ctx context.Context, record *model.TreasuryRequest) error {
|
||||
if record == nil {
|
||||
return merrors.InvalidArgument("treasury request is nil", "record")
|
||||
}
|
||||
record.RequestID = strings.TrimSpace(record.RequestID)
|
||||
if record.RequestID == "" {
|
||||
return merrors.InvalidArgument("request_id is required", "request_id")
|
||||
}
|
||||
existing, err := t.FindByRequestID(ctx, record.RequestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
return merrors.NoData("treasury request not found")
|
||||
}
|
||||
record.ID = existing.ID
|
||||
if record.CreatedAt.IsZero() {
|
||||
record.CreatedAt = existing.CreatedAt
|
||||
}
|
||||
record.UpdatedAt = time.Now()
|
||||
if err := t.repo.Update(ctx, record); err != nil {
|
||||
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.logger.Warn("Failed to update treasury request", zap.Error(err), zap.String("request_id", record.RequestID))
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TreasuryRequests) ListByAccountAndStatuses(ctx context.Context, ledgerAccountID string, statuses []model.TreasuryRequestStatus, dayStart, dayEnd time.Time) ([]*model.TreasuryRequest, error) {
|
||||
ledgerAccountID = strings.TrimSpace(ledgerAccountID)
|
||||
if ledgerAccountID == "" {
|
||||
return nil, merrors.InvalidArgument("ledger_account_id is required", "ledger_account_id")
|
||||
}
|
||||
statusValues := make([]any, 0, len(statuses))
|
||||
for _, status := range statuses {
|
||||
next := strings.TrimSpace(string(status))
|
||||
if next == "" {
|
||||
continue
|
||||
}
|
||||
statusValues = append(statusValues, next)
|
||||
}
|
||||
if len(statusValues) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
query := repository.Query().
|
||||
Filter(repository.Field(fieldTreasuryLedgerAccount), ledgerAccountID).
|
||||
In(repository.Field(fieldTreasuryStatus), statusValues...).
|
||||
Comparison(repository.Field(fieldTreasuryCreatedAt), builder.Gte, dayStart).
|
||||
Comparison(repository.Field(fieldTreasuryCreatedAt), builder.Lt, dayEnd)
|
||||
|
||||
result := make([]*model.TreasuryRequest, 0)
|
||||
err := t.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
|
||||
next := &model.TreasuryRequest{}
|
||||
if err := cur.Decode(next); err != nil {
|
||||
return err
|
||||
}
|
||||
result = append(result, next)
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var _ storage.TreasuryRequestsStore = (*TreasuryRequests)(nil)
|
||||
@@ -14,10 +14,12 @@ type Repository interface {
|
||||
Payments() PaymentsStore
|
||||
TelegramConfirmations() TelegramConfirmationsStore
|
||||
PendingConfirmations() PendingConfirmationsStore
|
||||
TreasuryRequests() TreasuryRequestsStore
|
||||
}
|
||||
|
||||
type PaymentsStore interface {
|
||||
FindByIdempotencyKey(ctx context.Context, key string) (*model.PaymentRecord, error)
|
||||
FindByOperationRef(ctx context.Context, key string) (*model.PaymentRecord, error)
|
||||
Upsert(ctx context.Context, record *model.PaymentRecord) error
|
||||
}
|
||||
|
||||
@@ -34,3 +36,13 @@ type PendingConfirmationsStore interface {
|
||||
DeleteByRequestID(ctx context.Context, requestID string) error
|
||||
ListExpired(ctx context.Context, now time.Time, limit int64) ([]*model.PendingConfirmation, error)
|
||||
}
|
||||
|
||||
type TreasuryRequestsStore interface {
|
||||
Create(ctx context.Context, record *model.TreasuryRequest) error
|
||||
FindByRequestID(ctx context.Context, requestID string) (*model.TreasuryRequest, error)
|
||||
FindActiveByLedgerAccountID(ctx context.Context, ledgerAccountID string) (*model.TreasuryRequest, error)
|
||||
FindDueByStatus(ctx context.Context, statuses []model.TreasuryRequestStatus, now time.Time, limit int64) ([]*model.TreasuryRequest, error)
|
||||
ClaimScheduled(ctx context.Context, requestID string) (bool, error)
|
||||
Update(ctx context.Context, record *model.TreasuryRequest) error
|
||||
ListByAccountAndStatuses(ctx context.Context, ledgerAccountID string, statuses []model.TreasuryRequestStatus, dayStart, dayEnd time.Time) ([]*model.TreasuryRequest, error)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/gateway/tron/internal/appversion"
|
||||
"github.com/tech/sendico/gateway/tron/shared"
|
||||
tronstoragemodel "github.com/tech/sendico/gateway/tron/storage/model"
|
||||
chainasset "github.com/tech/sendico/pkg/chain"
|
||||
"github.com/tech/sendico/pkg/connector/params"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
const chainConnectorID = "chain"
|
||||
@@ -293,11 +295,21 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio
|
||||
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
|
||||
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
|
||||
}
|
||||
resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())})
|
||||
|
||||
operationRef := strings.TrimSpace(req.GetOperationId())
|
||||
if s.storage == nil || s.storage.Transfers() == nil {
|
||||
return nil, merrors.Internal("get_operation: storage is not configured")
|
||||
}
|
||||
|
||||
transfer, err := s.storage.Transfers().FindByOperationRef(ctx, "", operationRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(resp.GetTransfer())}, nil
|
||||
if transfer == nil {
|
||||
return nil, merrors.NoData("transfer not found")
|
||||
}
|
||||
|
||||
return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(storageTransferToProto(transfer))}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListOperations(ctx context.Context, req *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
|
||||
@@ -493,6 +505,61 @@ func feeEstimateResult(resp *chainv1.EstimateTransferFeeResponse) *structpb.Stru
|
||||
return result
|
||||
}
|
||||
|
||||
func storageTransferToProto(transfer *tronstoragemodel.Transfer) *chainv1.Transfer {
|
||||
if transfer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
destination := &chainv1.TransferDestination{Memo: strings.TrimSpace(transfer.Destination.Memo)}
|
||||
if managedWalletRef := strings.TrimSpace(transfer.Destination.ManagedWalletRef); managedWalletRef != "" {
|
||||
destination.Destination = &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: managedWalletRef}
|
||||
} else if externalAddress := strings.TrimSpace(transfer.Destination.ExternalAddress); externalAddress != "" {
|
||||
destination.Destination = &chainv1.TransferDestination_ExternalAddress{ExternalAddress: externalAddress}
|
||||
}
|
||||
|
||||
fees := make([]*chainv1.ServiceFeeBreakdown, 0, len(transfer.Fees))
|
||||
for _, fee := range transfer.Fees {
|
||||
fees = append(fees, &chainv1.ServiceFeeBreakdown{
|
||||
FeeCode: strings.TrimSpace(fee.FeeCode),
|
||||
Amount: fee.Amount,
|
||||
Description: strings.TrimSpace(fee.Description),
|
||||
})
|
||||
}
|
||||
|
||||
asset := &chainv1.Asset{
|
||||
Chain: shared.ChainEnumFromName(transfer.Network),
|
||||
TokenSymbol: strings.TrimSpace(transfer.TokenSymbol),
|
||||
ContractAddress: strings.TrimSpace(transfer.ContractAddress),
|
||||
}
|
||||
|
||||
protoTransfer := &chainv1.Transfer{
|
||||
TransferRef: strings.TrimSpace(transfer.TransferRef),
|
||||
IdempotencyKey: strings.TrimSpace(transfer.IdempotencyKey),
|
||||
IntentRef: strings.TrimSpace(transfer.IntentRef),
|
||||
OperationRef: strings.TrimSpace(transfer.OperationRef),
|
||||
OrganizationRef: strings.TrimSpace(transfer.OrganizationRef),
|
||||
SourceWalletRef: strings.TrimSpace(transfer.SourceWalletRef),
|
||||
Destination: destination,
|
||||
Asset: asset,
|
||||
RequestedAmount: shared.MonenyToProto(transfer.RequestedAmount),
|
||||
NetAmount: shared.MonenyToProto(transfer.NetAmount),
|
||||
Fees: fees,
|
||||
Status: shared.TransferStatusToProto(transfer.Status),
|
||||
TransactionHash: strings.TrimSpace(transfer.TxHash),
|
||||
FailureReason: strings.TrimSpace(transfer.FailureReason),
|
||||
PaymentRef: strings.TrimSpace(transfer.PaymentRef),
|
||||
}
|
||||
|
||||
if !transfer.CreatedAt.IsZero() {
|
||||
protoTransfer.CreatedAt = timestamppb.New(transfer.CreatedAt.UTC())
|
||||
}
|
||||
if !transfer.UpdatedAt.IsZero() {
|
||||
protoTransfer.UpdatedAt = timestamppb.New(transfer.UpdatedAt.UTC())
|
||||
}
|
||||
|
||||
return protoTransfer
|
||||
}
|
||||
|
||||
func gasTopUpResult(amount *moneyv1.Money, capHit bool, transferRef string) *structpb.Struct {
|
||||
payload := map[string]interface{}{
|
||||
"cap_hit": capHit,
|
||||
@@ -518,18 +585,33 @@ func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation
|
||||
return nil
|
||||
}
|
||||
op := &connectorv1.Operation{
|
||||
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
|
||||
Type: connectorv1.OperationType_TRANSFER,
|
||||
Status: chainTransferStatusToOperation(transfer.GetStatus()),
|
||||
Money: transfer.GetRequestedAmount(),
|
||||
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
|
||||
CreatedAt: transfer.GetCreatedAt(),
|
||||
UpdatedAt: transfer.GetUpdatedAt(),
|
||||
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
|
||||
Type: connectorv1.OperationType_TRANSFER,
|
||||
Status: chainTransferStatusToOperation(transfer.GetStatus()),
|
||||
Money: transfer.GetRequestedAmount(),
|
||||
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
|
||||
IntentRef: strings.TrimSpace(transfer.GetIntentRef()),
|
||||
OperationRef: strings.TrimSpace(transfer.GetOperationRef()),
|
||||
CreatedAt: transfer.GetCreatedAt(),
|
||||
UpdatedAt: transfer.GetUpdatedAt(),
|
||||
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
|
||||
ConnectorId: chainConnectorID,
|
||||
AccountId: strings.TrimSpace(transfer.GetSourceWalletRef()),
|
||||
}}},
|
||||
}
|
||||
params := map[string]interface{}{}
|
||||
if paymentRef := strings.TrimSpace(transfer.GetPaymentRef()); paymentRef != "" {
|
||||
params["payment_ref"] = paymentRef
|
||||
}
|
||||
if organizationRef := strings.TrimSpace(transfer.GetOrganizationRef()); organizationRef != "" {
|
||||
params["organization_ref"] = organizationRef
|
||||
}
|
||||
if failureReason := strings.TrimSpace(transfer.GetFailureReason()); failureReason != "" {
|
||||
params["failure_reason"] = failureReason
|
||||
}
|
||||
if len(params) > 0 {
|
||||
op.Params = structFromMap(params)
|
||||
}
|
||||
if dest := transfer.GetDestination(); dest != nil {
|
||||
switch d := dest.GetDestination().(type) {
|
||||
case *chainv1.TransferDestination_ManagedWalletRef:
|
||||
@@ -629,6 +711,17 @@ func operationAccountID(party *connectorv1.OperationParty) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func structFromMap(values map[string]interface{}) *structpb.Struct {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result, err := structpb.NewStruct(values)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
|
||||
err := &connectorv1.ConnectorError{
|
||||
Code: code,
|
||||
|
||||
@@ -554,6 +554,32 @@ func (t *inMemoryTransfers) Get(ctx context.Context, transferRef string) (*model
|
||||
return transfer, nil
|
||||
}
|
||||
|
||||
func (t *inMemoryTransfers) FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
org := strings.TrimSpace(organizationRef)
|
||||
opRef := strings.TrimSpace(operationRef)
|
||||
if opRef == "" {
|
||||
return nil, merrors.InvalidArgument("transfersStore: empty operationRef")
|
||||
}
|
||||
|
||||
for _, transfer := range t.items {
|
||||
if transfer == nil {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(transfer.OperationRef), opRef) {
|
||||
continue
|
||||
}
|
||||
if org != "" && !strings.EqualFold(strings.TrimSpace(transfer.OrganizationRef), org) {
|
||||
continue
|
||||
}
|
||||
return transfer, nil
|
||||
}
|
||||
|
||||
return nil, merrors.NoData("transfer not found")
|
||||
}
|
||||
|
||||
func (t *inMemoryTransfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
@@ -40,6 +40,9 @@ func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error)
|
||||
Keys: []ri.Key{{Field: "transferRef", Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}, {Field: "operationRef", Sort: ri.Asc}},
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
@@ -110,6 +113,25 @@ func (t *Transfers) Get(ctx context.Context, transferRef string) (*model.Transfe
|
||||
return transfer, nil
|
||||
}
|
||||
|
||||
func (t *Transfers) FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) {
|
||||
operationRef = strings.TrimSpace(operationRef)
|
||||
if operationRef == "" {
|
||||
return nil, merrors.InvalidArgument("transfersStore: empty operationRef")
|
||||
}
|
||||
|
||||
query := repository.Query().Filter(repository.Field("operationRef"), operationRef)
|
||||
if org := strings.TrimSpace(organizationRef); org != "" {
|
||||
query = query.Filter(repository.Field("organizationRef"), org)
|
||||
}
|
||||
|
||||
transfer := &model.Transfer{}
|
||||
if err := t.repo.FindOneByFilter(ctx, query, transfer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return transfer, nil
|
||||
}
|
||||
|
||||
func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
|
||||
query := repository.Query()
|
||||
if src := strings.TrimSpace(filter.SourceWalletRef); src != "" {
|
||||
|
||||
@@ -42,6 +42,7 @@ type WalletsStore interface {
|
||||
type TransfersStore interface {
|
||||
Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error)
|
||||
Get(ctx context.Context, transferRef string) (*model.Transfer, error)
|
||||
FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error)
|
||||
List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error)
|
||||
UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ type createAccountParams struct {
|
||||
modelRole account_role.AccountRole
|
||||
}
|
||||
|
||||
const defaultLedgerAccountName = "Ledger account"
|
||||
|
||||
// validateCreateAccountInput validates and normalizes all fields from the request.
|
||||
func validateCreateAccountInput(req *ledgerv1.CreateAccountRequest) (createAccountParams, error) {
|
||||
if req == nil {
|
||||
@@ -88,7 +90,17 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Topology roles resolve to existing system accounts.
|
||||
// Operating accounts are user-facing and can coexist with topology accounts.
|
||||
// Ensure topology exists first, then create a dedicated account.
|
||||
if p.modelRole == account_role.AccountRoleOperating {
|
||||
if err := s.ensureLedgerTopology(ctx, p.orgRef, p.currency); err != nil {
|
||||
recordAccountOperation("create", "error")
|
||||
return nil, err
|
||||
}
|
||||
return s.persistNewAccount(ctx, p, req)
|
||||
}
|
||||
|
||||
// Other topology roles resolve to existing system accounts.
|
||||
if isRequiredTopologyRole(p.modelRole) {
|
||||
return s.resolveTopologyAccount(ctx, p.orgRef, p.currency, p.modelRole)
|
||||
}
|
||||
@@ -139,7 +151,7 @@ func (s *Service) persistNewAccount(ctx context.Context, p createAccountParams,
|
||||
if len(metadata) == 0 {
|
||||
metadata = nil
|
||||
}
|
||||
describable := describableFromProto(req.GetDescribable())
|
||||
describable := ensureDefaultLedgerAccountName(describableFromProto(req.GetDescribable()))
|
||||
|
||||
const maxCreateAttempts = 3
|
||||
for attempt := 0; attempt < maxCreateAttempts; attempt++ {
|
||||
@@ -157,15 +169,8 @@ func (s *Service) persistNewAccount(ctx context.Context, p createAccountParams,
|
||||
return &ledgerv1.CreateAccountResponse{Account: toProtoAccount(account)}, nil
|
||||
}
|
||||
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
existing, lookupErr := s.storage.Accounts().GetByRole(ctx, p.orgRef, p.currency, p.modelRole)
|
||||
if lookupErr == nil && existing != nil {
|
||||
recordAccountOperation("create", "success")
|
||||
return &ledgerv1.CreateAccountResponse{Account: toProtoAccount(existing)}, nil
|
||||
}
|
||||
if attempt < maxCreateAttempts-1 {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, merrors.ErrDataConflict) && attempt < maxCreateAttempts-1 {
|
||||
continue
|
||||
}
|
||||
|
||||
recordAccountOperation("create", "error")
|
||||
@@ -396,6 +401,18 @@ func describableFromProto(desc *describablev1.Describable) *pmodel.Describable {
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDefaultLedgerAccountName(desc *pmodel.Describable) *pmodel.Describable {
|
||||
if desc == nil {
|
||||
return &pmodel.Describable{Name: defaultLedgerAccountName}
|
||||
}
|
||||
if strings.TrimSpace(desc.Name) != "" {
|
||||
return desc
|
||||
}
|
||||
copy := *desc
|
||||
copy.Name = defaultLedgerAccountName
|
||||
return ©
|
||||
}
|
||||
|
||||
func describableToProto(desc pmodel.Describable) *describablev1.Describable {
|
||||
name := strings.TrimSpace(desc.Name)
|
||||
var description *string
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
)
|
||||
|
||||
@@ -184,12 +185,15 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
|
||||
// default role
|
||||
require.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, resp.Account.Role)
|
||||
require.Equal(t, "USD", resp.Account.Currency)
|
||||
require.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, resp.Account.AccountType)
|
||||
require.Equal(t, defaultLedgerAccountName, resp.Account.GetDescribable().GetName())
|
||||
|
||||
// Expect: required roles + settlement
|
||||
require.Len(t, accountStore.created, 5)
|
||||
// Expect: required topology roles + dedicated operating account
|
||||
require.Len(t, accountStore.created, 6)
|
||||
|
||||
var settlement *pmodel.LedgerAccount
|
||||
var operating *pmodel.LedgerAccount
|
||||
var operatingCount int
|
||||
|
||||
roles := make(map[account_role.AccountRole]bool)
|
||||
for _, acc := range accountStore.created {
|
||||
@@ -199,6 +203,7 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
|
||||
settlement = acc
|
||||
}
|
||||
if acc.Role == account_role.AccountRoleOperating {
|
||||
operatingCount++
|
||||
operating = acc
|
||||
}
|
||||
|
||||
@@ -212,12 +217,13 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
|
||||
|
||||
require.NotNil(t, settlement)
|
||||
require.NotNil(t, operating)
|
||||
require.Equal(t, 2, operatingCount)
|
||||
|
||||
for _, role := range RequiredRolesV1 {
|
||||
require.True(t, roles[role])
|
||||
}
|
||||
|
||||
// Responder must return the operating account it created/resolved.
|
||||
// Responder returns the dedicated operating account created for this request.
|
||||
require.Equal(t, operating.AccountCode, resp.Account.AccountCode)
|
||||
require.Equal(t, operating.GetID().Hex(), resp.Account.LedgerAccountRef)
|
||||
|
||||
@@ -235,6 +241,38 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) {
|
||||
require.Equal(t, "true", settlement.Metadata["system"])
|
||||
}
|
||||
|
||||
func TestCreateAccountResponder_OperatingPreservesProvidedNameAndType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
orgRef := bson.NewObjectID()
|
||||
accountStore := &accountStoreStub{}
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
storage: &repositoryStub{accounts: accountStore},
|
||||
}
|
||||
|
||||
req := &ledgerv1.CreateAccountRequest{
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE,
|
||||
Currency: "usd",
|
||||
Role: ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING,
|
||||
Describable: &describablev1.Describable{
|
||||
Name: "Incoming revenue",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := svc.createAccountResponder(context.Background(), req)(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
require.NotNil(t, resp.Account)
|
||||
|
||||
require.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE, resp.Account.AccountType)
|
||||
require.Equal(t, "Incoming revenue", resp.Account.GetDescribable().GetName())
|
||||
|
||||
// Topology accounts + dedicated operating account.
|
||||
require.Len(t, accountStore.created, 6)
|
||||
}
|
||||
|
||||
func TestCreateAccountResponder_RetriesOnConflict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -70,9 +70,15 @@ func (s *Service) ensureRoleAccount(ctx context.Context, orgRef bson.ObjectID, c
|
||||
|
||||
account, err := s.storage.Accounts().GetByRole(ctx, orgRef, normalizedCurrency, role)
|
||||
if err == nil {
|
||||
return account, nil
|
||||
if isSystemTaggedAccount(account) {
|
||||
return account, nil
|
||||
}
|
||||
s.logger.Info("Found non-system account for topology role; creating missing system account",
|
||||
mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("currency", normalizedCurrency),
|
||||
zap.String("role", string(role)))
|
||||
}
|
||||
if !errors.Is(err, storage.ErrAccountNotFound) {
|
||||
if err != nil && !errors.Is(err, storage.ErrAccountNotFound) {
|
||||
s.logger.Warn("Failed to resolve ledger account by role", zap.Error(err),
|
||||
mzap.ObjRef("organization_ref", orgRef), zap.String("currency", normalizedCurrency),
|
||||
zap.String("role", string(role)))
|
||||
@@ -105,6 +111,13 @@ func (s *Service) ensureRoleAccount(ctx context.Context, orgRef bson.ObjectID, c
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func isSystemTaggedAccount(account *pmodel.LedgerAccount) bool {
|
||||
if account == nil || account.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(account.Metadata["system"]), "true")
|
||||
}
|
||||
|
||||
func newSystemAccount(orgRef bson.ObjectID, currency string, role account_role.AccountRole) *pmodel.LedgerAccount {
|
||||
ref := bson.NewObjectID()
|
||||
account := &pmodel.LedgerAccount{
|
||||
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/ledger/storage"
|
||||
@@ -24,6 +25,28 @@ type accountsStore struct {
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
const (
|
||||
orgCurrencyRoleNonOperatingPrefix = "org_currency_role_non_operating_unique"
|
||||
orgCurrencyRoleSystemOperatingName = "org_currency_role_system_operating_unique"
|
||||
)
|
||||
|
||||
var nonOperatingUniqueRoles = []account_role.AccountRole{
|
||||
account_role.AccountRoleHold,
|
||||
account_role.AccountRoleTransit,
|
||||
account_role.AccountRoleSettlement,
|
||||
account_role.AccountRoleClearing,
|
||||
account_role.AccountRolePending,
|
||||
account_role.AccountRoleReserve,
|
||||
account_role.AccountRoleLiquidity,
|
||||
account_role.AccountRoleFee,
|
||||
account_role.AccountRoleChargeback,
|
||||
account_role.AccountRoleAdjustment,
|
||||
}
|
||||
|
||||
func nonOperatingRoleIndexName(role account_role.AccountRole) string {
|
||||
return fmt.Sprintf("%s_%s", orgCurrencyRoleNonOperatingPrefix, role)
|
||||
}
|
||||
|
||||
func NewAccounts(logger mlogger.Logger, db *mongo.Database) (storage.AccountsStore, error) {
|
||||
repo := repository.CreateMongoRepository(db, mservice.LedgerAccounts)
|
||||
|
||||
@@ -41,21 +64,45 @@ func NewAccounts(logger mlogger.Logger, db *mongo.Database) (storage.AccountsSto
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create compound index on organizationRef + currency + role (unique)
|
||||
roleIndex := &ri.Definition{
|
||||
// Keep role uniqueness for non-operating organization accounts.
|
||||
// Some Mongo-compatible backends reject partial filters that use negation ($ne/$not).
|
||||
// Build one equality-based partial index per non-operating role for compatibility.
|
||||
for _, role := range nonOperatingUniqueRoles {
|
||||
roleIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "organizationRef", Sort: ri.Asc},
|
||||
{Field: "currency", Sort: ri.Asc},
|
||||
{Field: "role", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
Name: nonOperatingRoleIndexName(role),
|
||||
PartialFilter: repository.Query().
|
||||
Filter(repository.Field("scope"), pkm.LedgerAccountScopeOrganization).
|
||||
Filter(repository.Field("role"), role),
|
||||
}
|
||||
if err := repo.CreateIndex(roleIndex); err != nil {
|
||||
logger.Error("Failed to ensure accounts role index", zap.String("role", string(role)), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure only one system-tagged operating role per organization/currency.
|
||||
systemOperatingRoleIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "organizationRef", Sort: ri.Asc},
|
||||
{Field: "currency", Sort: ri.Asc},
|
||||
{Field: "role", Sort: ri.Asc},
|
||||
{Field: "metadata.system", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
PartialFilter: repository.Filter(
|
||||
"scope",
|
||||
pkm.LedgerAccountScopeOrganization,
|
||||
),
|
||||
Name: orgCurrencyRoleSystemOperatingName,
|
||||
PartialFilter: repository.Query().
|
||||
Filter(repository.Field("scope"), pkm.LedgerAccountScopeOrganization).
|
||||
Filter(repository.Field("role"), account_role.AccountRoleOperating).
|
||||
Filter(repository.Field("metadata.system"), "true"),
|
||||
}
|
||||
if err := repo.CreateIndex(roleIndex); err != nil {
|
||||
logger.Error("Failed to ensure accounts role index", zap.Error(err))
|
||||
if err := repo.CreateIndex(systemOperatingRoleIndex); err != nil {
|
||||
logger.Error("Failed to ensure system operating role index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -182,14 +229,34 @@ func (a *accountsStore) GetByRole(ctx context.Context, orgRef bson.ObjectID, cur
|
||||
return nil, merrors.InvalidArgument("accountsStore: empty role")
|
||||
}
|
||||
|
||||
result := &pkm.LedgerAccount{}
|
||||
limit := int64(1)
|
||||
|
||||
// Prefer topology/system-tagged account when present.
|
||||
systemQuery := repository.Query().
|
||||
Filter(repository.Field("organizationRef"), orgRef).
|
||||
Filter(repository.Field("currency"), currency).
|
||||
Filter(repository.Field("role"), role).
|
||||
Filter(repository.Field("scope"), pkm.LedgerAccountScopeOrganization).
|
||||
Filter(repository.Field("metadata.system"), "true").
|
||||
Limit(&limit)
|
||||
if err := a.repo.FindOneByFilter(ctx, systemQuery, result); err == nil {
|
||||
a.logger.Debug("System account loaded by role", mzap.ObjRef("accountRef", *result.GetID()),
|
||||
zap.String("currency", currency), zap.String("role", string(role)))
|
||||
return result, nil
|
||||
} else if !errors.Is(err, merrors.ErrNoData) {
|
||||
a.logger.Warn("Failed to get account by role", zap.Error(err), mzap.ObjRef("organization_ref", orgRef),
|
||||
zap.String("currency", currency), zap.String("role", string(role)))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fallback to any organization account with the role.
|
||||
query := repository.Query().
|
||||
Filter(repository.Field("organizationRef"), orgRef).
|
||||
Filter(repository.Field("currency"), currency).
|
||||
Filter(repository.Field("role"), role).
|
||||
Filter(repository.Field("scope"), pkm.LedgerAccountScopeOrganization).
|
||||
Limit(&limit)
|
||||
|
||||
result := &pkm.LedgerAccount{}
|
||||
if err := a.repo.FindOneByFilter(ctx, query, result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
a.logger.Debug("Account not found by role", zap.String("currency", currency),
|
||||
|
||||
@@ -2,6 +2,9 @@ package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/shopspring/decimal"
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"strings"
|
||||
|
||||
@@ -48,7 +51,7 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
amount, err := sourceAmount(req.Payment)
|
||||
amount, err := sourceAmount(req.Payment, action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -90,6 +93,12 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
|
||||
return nil, refsErr
|
||||
}
|
||||
step.ExternalRefs = refs
|
||||
|
||||
if action == discovery.RailOperationSend {
|
||||
if err := e.submitWalletFeeTransfer(ctx, req, client, gateway, sourceWalletRef, operationRef, idempotencyKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
step.State = agg.StepStateCompleted
|
||||
step.FailureCode = ""
|
||||
step.FailureMsg = ""
|
||||
@@ -161,11 +170,24 @@ func sourceManagedWalletRef(payment *agg.Payment) (string, error) {
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func sourceAmount(payment *agg.Payment) (*moneyv1.Money, error) {
|
||||
func sourceAmount(payment *agg.Payment, action model.RailOperation) (*moneyv1.Money, error) {
|
||||
if payment == nil {
|
||||
return nil, merrors.InvalidArgument("crypto send: payment is required")
|
||||
}
|
||||
money := effectiveSourceAmount(payment)
|
||||
var money *paymenttypes.Money
|
||||
switch action {
|
||||
case discovery.RailOperationFee:
|
||||
resolved, ok, err := walletFeeAmount(payment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("crypto send: wallet fee amount is required")
|
||||
}
|
||||
money = resolved
|
||||
default:
|
||||
money = effectiveSourceAmount(payment)
|
||||
}
|
||||
if money == nil {
|
||||
return nil, merrors.InvalidArgument("crypto send: source amount is required")
|
||||
}
|
||||
@@ -180,6 +202,64 @@ func sourceAmount(payment *agg.Payment) (*moneyv1.Money, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *gatewayCryptoExecutor) submitWalletFeeTransfer(
|
||||
ctx context.Context,
|
||||
req sexec.StepRequest,
|
||||
client chainclient.Client,
|
||||
gateway *model.GatewayInstanceDescriptor,
|
||||
sourceWalletRef string,
|
||||
operationRef string,
|
||||
idempotencyKey string,
|
||||
) error {
|
||||
if req.Payment == nil {
|
||||
return merrors.InvalidArgument("crypto send: payment is required")
|
||||
}
|
||||
|
||||
feeAmount, ok, err := walletFeeAmount(req.Payment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
destination, err := e.resolveDestination(req.Payment, discovery.RailOperationFee)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
feeMoney := &moneyv1.Money{
|
||||
Amount: strings.TrimSpace(feeAmount.GetAmount()),
|
||||
Currency: strings.TrimSpace(feeAmount.GetCurrency()),
|
||||
}
|
||||
|
||||
resp, err := client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
|
||||
IdempotencyKey: strings.TrimSpace(idempotencyKey) + ":fee",
|
||||
OrganizationRef: req.Payment.OrganizationRef.Hex(),
|
||||
SourceWalletRef: sourceWalletRef,
|
||||
Destination: destination,
|
||||
Amount: feeMoney,
|
||||
OperationRef: strings.TrimSpace(operationRef) + ":fee",
|
||||
IntentRef: strings.TrimSpace(req.Payment.IntentSnapshot.Ref),
|
||||
PaymentRef: strings.TrimSpace(req.Payment.PaymentRef),
|
||||
Metadata: transferMetadata(req.Step),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp == nil || resp.GetTransfer() == nil {
|
||||
return merrors.Internal("crypto send: fee transfer response is missing")
|
||||
}
|
||||
if _, err := transferExternalRefs(resp.GetTransfer(), firstNonEmpty(
|
||||
strings.TrimSpace(req.Step.InstanceID),
|
||||
strings.TrimSpace(gateway.InstanceID),
|
||||
strings.TrimSpace(req.Step.Gateway),
|
||||
strings.TrimSpace(gateway.ID),
|
||||
)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func effectiveSourceAmount(payment *agg.Payment) *paymenttypes.Money {
|
||||
if payment == nil {
|
||||
return nil
|
||||
@@ -190,6 +270,77 @@ func effectiveSourceAmount(payment *agg.Payment) *paymenttypes.Money {
|
||||
return payment.IntentSnapshot.Amount
|
||||
}
|
||||
|
||||
func walletFeeAmount(payment *agg.Payment) (*paymenttypes.Money, bool, error) {
|
||||
if payment == nil || payment.QuoteSnapshot == nil || len(payment.QuoteSnapshot.FeeLines) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
sourceCurrency := ""
|
||||
if source := effectiveSourceAmount(payment); source != nil {
|
||||
sourceCurrency = strings.TrimSpace(source.Currency)
|
||||
}
|
||||
|
||||
total := decimal.Zero
|
||||
currency := ""
|
||||
for i, line := range payment.QuoteSnapshot.FeeLines {
|
||||
if !isWalletDebitFeeLine(line) {
|
||||
continue
|
||||
}
|
||||
money := line.GetMoney()
|
||||
if money == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
lineCurrency := strings.TrimSpace(money.GetCurrency())
|
||||
if lineCurrency == "" {
|
||||
return nil, false, merrors.InvalidArgument(fmt.Sprintf("crypto send: fee_lines[%d].money.currency is required", i))
|
||||
}
|
||||
if sourceCurrency != "" && !strings.EqualFold(sourceCurrency, lineCurrency) {
|
||||
continue
|
||||
}
|
||||
if currency == "" {
|
||||
currency = lineCurrency
|
||||
} else if !strings.EqualFold(currency, lineCurrency) {
|
||||
return nil, false, merrors.InvalidArgument("crypto send: wallet fee currency mismatch")
|
||||
}
|
||||
|
||||
amountRaw := strings.TrimSpace(money.GetAmount())
|
||||
amount, err := decimal.NewFromString(amountRaw)
|
||||
if err != nil {
|
||||
return nil, false, merrors.InvalidArgument(fmt.Sprintf("crypto send: fee_lines[%d].money.amount is invalid", i))
|
||||
}
|
||||
if amount.Sign() < 0 {
|
||||
amount = amount.Neg()
|
||||
}
|
||||
if amount.Sign() == 0 {
|
||||
continue
|
||||
}
|
||||
total = total.Add(amount)
|
||||
}
|
||||
|
||||
if total.Sign() <= 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Amount: total.String(),
|
||||
Currency: strings.ToUpper(strings.TrimSpace(currency)),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func isWalletDebitFeeLine(line *paymenttypes.FeeLine) bool {
|
||||
if line == nil {
|
||||
return false
|
||||
}
|
||||
if line.GetSide() != paymenttypes.EntrySideDebit {
|
||||
return false
|
||||
}
|
||||
meta := line.Meta
|
||||
if len(meta) == 0 {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(meta["fee_target"]), "wallet")
|
||||
}
|
||||
|
||||
func (e *gatewayCryptoExecutor) resolveDestination(payment *agg.Payment, action model.RailOperation) (*chainv1.TransferDestination, error) {
|
||||
if payment == nil {
|
||||
return nil, merrors.InvalidArgument("crypto send: payment is required")
|
||||
|
||||
@@ -195,6 +195,245 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_MissingCardRoute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsWalletFeeTransferOnSend(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
submitRequests := make([]*chainv1.SubmitTransferRequest, 0, 2)
|
||||
client := &chainclient.Fake{
|
||||
SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
|
||||
submitRequests = append(submitRequests, req)
|
||||
switch len(submitRequests) {
|
||||
case 1:
|
||||
return &chainv1.SubmitTransferResponse{
|
||||
Transfer: &chainv1.Transfer{
|
||||
TransferRef: "trf-principal",
|
||||
OperationRef: "op-principal",
|
||||
},
|
||||
}, nil
|
||||
case 2:
|
||||
return &chainv1.SubmitTransferResponse{
|
||||
Transfer: &chainv1.Transfer{
|
||||
TransferRef: "trf-fee",
|
||||
OperationRef: "op-fee",
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected transfer submission call %d", len(submitRequests))
|
||||
return nil, nil
|
||||
}
|
||||
},
|
||||
}
|
||||
resolver := &fakeGatewayInvokeResolver{client: client}
|
||||
registry := &fakeGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
Rail: discovery.RailCrypto,
|
||||
InvokeURI: "grpc://crypto-gateway",
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
executor := &gatewayCryptoExecutor{
|
||||
gatewayInvokeResolver: resolver,
|
||||
gatewayRegistry: registry,
|
||||
cardGatewayRoutes: map[string]CardGatewayRoute{
|
||||
paymenttypes.DefaultCardsGatewayID: {FundingAddress: "TUA_DEST", FeeAddress: "TUA_FEE"},
|
||||
},
|
||||
}
|
||||
|
||||
req := sexec.StepRequest{
|
||||
Payment: &agg.Payment{
|
||||
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
|
||||
PaymentRef: "payment-1",
|
||||
IdempotencyKey: "idem-1",
|
||||
IntentSnapshot: model.PaymentIntent{
|
||||
Ref: "intent-1",
|
||||
Source: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeManagedWallet,
|
||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-src",
|
||||
},
|
||||
},
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeCard,
|
||||
Card: &model.CardEndpoint{Pan: "4111111111111111"},
|
||||
},
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
||||
},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
DebitAmount: &paymenttypes.Money{Amount: "10.000000", Currency: "USDT"},
|
||||
FeeLines: []*paymenttypes.FeeLine{
|
||||
{
|
||||
Money: &paymenttypes.Money{Amount: "0.70", Currency: "USDT"},
|
||||
LineType: paymenttypes.PostingLineTypeFee,
|
||||
Side: paymenttypes.EntrySideDebit,
|
||||
Meta: map[string]string{"fee_target": "wallet"},
|
||||
},
|
||||
},
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 1, Rail: "CRYPTO", Gateway: "crypto_rail_gateway_arbitrum_sepolia", InstanceID: "crypto_rail_gateway_arbitrum_sepolia", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 4, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Step: xplan.Step{
|
||||
StepRef: "hop_1_crypto_send",
|
||||
StepCode: "hop.1.crypto.send",
|
||||
Action: discovery.RailOperationSend,
|
||||
Rail: discovery.RailCrypto,
|
||||
Gateway: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
},
|
||||
StepExecution: agg.StepExecution{
|
||||
StepRef: "hop_1_crypto_send",
|
||||
StepCode: "hop.1.crypto.send",
|
||||
Attempt: 1,
|
||||
},
|
||||
}
|
||||
|
||||
out, err := executor.ExecuteCrypto(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteCrypto returned error: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("expected output")
|
||||
}
|
||||
if got, want := len(submitRequests), 2; got != want {
|
||||
t.Fatalf("submit transfer calls mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
|
||||
principalReq := submitRequests[0]
|
||||
if got, want := principalReq.GetAmount().GetAmount(), "10.000000"; got != want {
|
||||
t.Fatalf("principal amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := principalReq.GetDestination().GetExternalAddress(), "TUA_DEST"; got != want {
|
||||
t.Fatalf("principal destination mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
|
||||
feeReq := submitRequests[1]
|
||||
if got, want := feeReq.GetAmount().GetAmount(), "0.7"; got != want {
|
||||
t.Fatalf("fee amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := feeReq.GetAmount().GetCurrency(), "USDT"; got != want {
|
||||
t.Fatalf("fee currency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := feeReq.GetDestination().GetExternalAddress(), "TUA_FEE"; got != want {
|
||||
t.Fatalf("fee destination mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := feeReq.GetOperationRef(), "payment-1:hop_1_crypto_send:fee"; got != want {
|
||||
t.Fatalf("fee operation_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := feeReq.GetIdempotencyKey(), "idem-1:hop_1_crypto_send:fee"; got != want {
|
||||
t.Fatalf("fee idempotency_key mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayCryptoExecutor_ExecuteCrypto_FeeActionUsesWalletFeeAmount(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
var submitReq *chainv1.SubmitTransferRequest
|
||||
client := &chainclient.Fake{
|
||||
SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
|
||||
submitReq = req
|
||||
return &chainv1.SubmitTransferResponse{
|
||||
Transfer: &chainv1.Transfer{
|
||||
TransferRef: "trf-fee",
|
||||
OperationRef: "op-fee",
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
resolver := &fakeGatewayInvokeResolver{client: client}
|
||||
registry := &fakeGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
Rail: discovery.RailCrypto,
|
||||
InvokeURI: "grpc://crypto-gateway",
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
executor := &gatewayCryptoExecutor{
|
||||
gatewayInvokeResolver: resolver,
|
||||
gatewayRegistry: registry,
|
||||
cardGatewayRoutes: map[string]CardGatewayRoute{
|
||||
paymenttypes.DefaultCardsGatewayID: {FundingAddress: "TUA_DEST", FeeAddress: "TUA_FEE"},
|
||||
},
|
||||
}
|
||||
|
||||
req := sexec.StepRequest{
|
||||
Payment: &agg.Payment{
|
||||
OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID},
|
||||
PaymentRef: "payment-1",
|
||||
IdempotencyKey: "idem-1",
|
||||
IntentSnapshot: model.PaymentIntent{
|
||||
Ref: "intent-1",
|
||||
Source: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeManagedWallet,
|
||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-src",
|
||||
},
|
||||
},
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeCard,
|
||||
Card: &model.CardEndpoint{Pan: "4111111111111111"},
|
||||
},
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
||||
},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
DebitAmount: &paymenttypes.Money{Amount: "10.000000", Currency: "USDT"},
|
||||
FeeLines: []*paymenttypes.FeeLine{
|
||||
{
|
||||
Money: &paymenttypes.Money{Amount: "0.70", Currency: "USDT"},
|
||||
LineType: paymenttypes.PostingLineTypeFee,
|
||||
Side: paymenttypes.EntrySideDebit,
|
||||
Meta: map[string]string{"fee_target": "wallet"},
|
||||
},
|
||||
},
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 1, Rail: "CRYPTO", Gateway: "crypto_rail_gateway_arbitrum_sepolia", InstanceID: "crypto_rail_gateway_arbitrum_sepolia", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 4, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Step: xplan.Step{
|
||||
StepRef: "hop_1_crypto_fee",
|
||||
StepCode: "hop.1.crypto.fee",
|
||||
Action: discovery.RailOperationFee,
|
||||
Rail: discovery.RailCrypto,
|
||||
Gateway: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
InstanceID: "crypto_rail_gateway_arbitrum_sepolia",
|
||||
},
|
||||
StepExecution: agg.StepExecution{
|
||||
StepRef: "hop_1_crypto_fee",
|
||||
StepCode: "hop.1.crypto.fee",
|
||||
Attempt: 1,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := executor.ExecuteCrypto(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteCrypto returned error: %v", err)
|
||||
}
|
||||
if submitReq == nil {
|
||||
t.Fatal("expected transfer submission")
|
||||
}
|
||||
if got, want := submitReq.GetAmount().GetAmount(), "0.7"; got != want {
|
||||
t.Fatalf("fee amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := submitReq.GetDestination().GetExternalAddress(), "TUA_FEE"; got != want {
|
||||
t.Fatalf("fee destination mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeGatewayInvokeResolver struct {
|
||||
lastInvokeURI string
|
||||
client chainclient.Client
|
||||
|
||||
@@ -43,6 +43,11 @@ service DocumentService {
|
||||
// generates it lazily, stores it, and returns it.
|
||||
rpc GetDocument(GetDocumentRequest)
|
||||
returns (GetDocumentResponse);
|
||||
|
||||
// GetOperationDocument returns a generated PDF file for
|
||||
// a gateway operation snapshot provided by the caller.
|
||||
rpc GetOperationDocument(GetOperationDocumentRequest)
|
||||
returns (GetDocumentResponse);
|
||||
}
|
||||
|
||||
|
||||
@@ -99,3 +104,24 @@ message GetDocumentResponse {
|
||||
// MIME type, typically "application/pdf"
|
||||
string mime_type = 3;
|
||||
}
|
||||
|
||||
// GetOperationDocumentRequest requests a document for a
|
||||
// single gateway operation.
|
||||
message GetOperationDocumentRequest {
|
||||
string organization_ref = 1;
|
||||
string gateway_service = 2;
|
||||
string operation_ref = 3;
|
||||
|
||||
string payment_ref = 4;
|
||||
string operation_code = 5;
|
||||
string operation_label = 6;
|
||||
string operation_state = 7;
|
||||
string failure_code = 8;
|
||||
string failure_reason = 9;
|
||||
|
||||
string amount = 10;
|
||||
string currency = 11;
|
||||
|
||||
int64 started_at_unix_ms = 12;
|
||||
int64 completed_at_unix_ms = 13;
|
||||
}
|
||||
|
||||
34
frontend/pshared/lib/data/dto/payment/operation.dart
Normal file
34
frontend/pshared/lib/data/dto/payment/operation.dart
Normal 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);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'package:pshared/data/dto/payment/operation.dart';
|
||||
import 'package:pshared/data/dto/payment/payment_quote.dart';
|
||||
|
||||
part 'payment.g.dart';
|
||||
@@ -12,6 +13,7 @@ class PaymentDTO {
|
||||
final String? state;
|
||||
final String? failureCode;
|
||||
final String? failureReason;
|
||||
final List<PaymentOperationDTO> operations;
|
||||
final PaymentQuoteDTO? lastQuote;
|
||||
final Map<String, String>? metadata;
|
||||
final String? createdAt;
|
||||
@@ -22,6 +24,7 @@ class PaymentDTO {
|
||||
this.state,
|
||||
this.failureCode,
|
||||
this.failureReason,
|
||||
this.operations = const <PaymentOperationDTO>[],
|
||||
this.lastQuote,
|
||||
this.metadata,
|
||||
this.createdAt,
|
||||
|
||||
@@ -9,22 +9,33 @@ import 'package:pshared/models/ledger/account.dart';
|
||||
|
||||
|
||||
extension LedgerAccountDTOMapper on LedgerAccountDTO {
|
||||
LedgerAccount toDomain() => LedgerAccount(
|
||||
ledgerAccountRef: ledgerAccountRef,
|
||||
organizationRef: organizationRef,
|
||||
ownerRef: ownerRef,
|
||||
accountCode: accountCode,
|
||||
accountType: accountType.toDomain(),
|
||||
currency: currency,
|
||||
status: status.toDomain(),
|
||||
allowNegative: allowNegative,
|
||||
role: role.toDomain(),
|
||||
metadata: metadata,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
describable: describable?.toDomain() ?? newDescribable(name: '', description: null),
|
||||
balance: balance?.toDomain(),
|
||||
);
|
||||
LedgerAccount toDomain() {
|
||||
final mappedDescribable = describable?.toDomain();
|
||||
final fallbackName = metadata?['name']?.trim() ?? '';
|
||||
final name = mappedDescribable?.name.trim().isNotEmpty == true
|
||||
? mappedDescribable!.name
|
||||
: fallbackName;
|
||||
|
||||
return LedgerAccount(
|
||||
ledgerAccountRef: ledgerAccountRef,
|
||||
organizationRef: organizationRef,
|
||||
ownerRef: ownerRef,
|
||||
accountCode: accountCode,
|
||||
accountType: accountType.toDomain(),
|
||||
currency: currency,
|
||||
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 {
|
||||
|
||||
39
frontend/pshared/lib/data/mapper/payment/operation.dart
Normal file
39
frontend/pshared/lib/data/mapper/payment/operation.dart
Normal 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);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
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/models/payment/payment.dart';
|
||||
import 'package:pshared/models/payment/state.dart';
|
||||
|
||||
|
||||
extension PaymentDTOMapper on PaymentDTO {
|
||||
Payment toDomain() => Payment(
|
||||
paymentRef: paymentRef,
|
||||
@@ -11,6 +13,7 @@ extension PaymentDTOMapper on PaymentDTO {
|
||||
orchestrationState: paymentOrchestrationStateFromValue(state),
|
||||
failureCode: failureCode,
|
||||
failureReason: failureReason,
|
||||
operations: operations.map((item) => item.toDomain()).toList(),
|
||||
lastQuote: lastQuote?.toDomain(),
|
||||
metadata: metadata,
|
||||
createdAt: createdAt == null ? null : DateTime.tryParse(createdAt!),
|
||||
@@ -24,6 +27,7 @@ extension PaymentMapper on Payment {
|
||||
state: state ?? paymentOrchestrationStateToValue(orchestrationState),
|
||||
failureCode: failureCode,
|
||||
failureReason: failureReason,
|
||||
operations: operations.map((item) => item.toDTO()).toList(),
|
||||
lastQuote: lastQuote?.toDTO(),
|
||||
metadata: metadata,
|
||||
createdAt: createdAt?.toUtc().toIso8601String(),
|
||||
|
||||
@@ -10,11 +10,31 @@
|
||||
"@operationStatusProcessing": {
|
||||
"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": {
|
||||
"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": {
|
||||
|
||||
@@ -10,11 +10,31 @@
|
||||
"@operationStatusProcessing": {
|
||||
"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": {
|
||||
"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": {
|
||||
|
||||
25
frontend/pshared/lib/models/payment/execution_operation.dart
Normal file
25
frontend/pshared/lib/models/payment/execution_operation.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:pshared/models/payment/methods/type.dart';
|
||||
import 'package:pshared/models/payment/status.dart';
|
||||
|
||||
|
||||
class OperationItem {
|
||||
final OperationStatus status;
|
||||
final String? fileName;
|
||||
@@ -11,6 +10,8 @@ class OperationItem {
|
||||
final String toCurrency;
|
||||
final String payId;
|
||||
final String? paymentRef;
|
||||
final String? operationRef;
|
||||
final String? gatewayService;
|
||||
final String? cardNumber;
|
||||
final PaymentMethod? paymentMethod;
|
||||
final String name;
|
||||
@@ -26,6 +27,8 @@ class OperationItem {
|
||||
required this.toCurrency,
|
||||
required this.payId,
|
||||
this.paymentRef,
|
||||
this.operationRef,
|
||||
this.gatewayService,
|
||||
this.cardNumber,
|
||||
this.paymentMethod,
|
||||
required this.name,
|
||||
|
||||
@@ -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/state.dart';
|
||||
|
||||
@@ -8,6 +9,7 @@ class Payment {
|
||||
final PaymentOrchestrationState orchestrationState;
|
||||
final String? failureCode;
|
||||
final String? failureReason;
|
||||
final List<PaymentExecutionOperation> operations;
|
||||
final PaymentQuote? lastQuote;
|
||||
final Map<String, String>? metadata;
|
||||
final DateTime? createdAt;
|
||||
@@ -19,6 +21,7 @@ class Payment {
|
||||
required this.orchestrationState,
|
||||
required this.failureCode,
|
||||
required this.failureReason,
|
||||
required this.operations,
|
||||
required this.lastQuote,
|
||||
required this.metadata,
|
||||
required this.createdAt,
|
||||
|
||||
@@ -2,24 +2,35 @@ import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:pshared/generated/i18n/ps_localizations.dart';
|
||||
|
||||
|
||||
enum OperationStatus {
|
||||
pending,
|
||||
processing,
|
||||
retrying,
|
||||
success,
|
||||
skipped,
|
||||
cancelled,
|
||||
needsAttention,
|
||||
error,
|
||||
}
|
||||
|
||||
|
||||
extension OperationStatusX on OperationStatus {
|
||||
/// Returns the localized string for this status,
|
||||
/// e.g. “Processing”, “Success”, “Error”.
|
||||
String localized(BuildContext context) {
|
||||
final loc = PSLocalizations.of(context)!;
|
||||
switch (this) {
|
||||
case OperationStatus.pending:
|
||||
return loc.operationStatusPending;
|
||||
case OperationStatus.processing:
|
||||
return loc.operationStatusProcessing;
|
||||
case OperationStatus.retrying:
|
||||
return loc.operationStatusRetrying;
|
||||
case OperationStatus.success:
|
||||
return loc.operationStatusSuccess;
|
||||
case OperationStatus.skipped:
|
||||
return loc.operationStatusSkipped;
|
||||
case OperationStatus.cancelled:
|
||||
return loc.operationStatusCancelled;
|
||||
case OperationStatus.needsAttention:
|
||||
return loc.operationStatusNeedsAttention;
|
||||
case OperationStatus.error:
|
||||
return loc.operationStatusError;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,27 @@ class PaymentDocumentsService {
|
||||
static final _logger = Logger('service.payment_documents');
|
||||
static const String _objectType = Services.payments;
|
||||
|
||||
static Future<DownloadedFile> getAct(String organizationRef, String paymentRef) async {
|
||||
final encodedRef = Uri.encodeQueryComponent(paymentRef);
|
||||
final url = '/documents/act/$organizationRef?payment_ref=$encodedRef';
|
||||
_logger.fine('Downloading act document for payment $paymentRef');
|
||||
final response = await AuthorizationService.getGETBinaryResponse(_objectType, url);
|
||||
final filename = _filenameFromDisposition(response.header('content-disposition')) ??
|
||||
'act_$paymentRef.pdf';
|
||||
static Future<DownloadedFile> getOperationDocument(
|
||||
String organizationRef,
|
||||
String gatewayService,
|
||||
String operationRef,
|
||||
) async {
|
||||
final query = <String, String>{
|
||||
'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';
|
||||
return DownloadedFile(
|
||||
bytes: response.bytes,
|
||||
|
||||
@@ -69,6 +69,7 @@ void main() {
|
||||
orchestrationState: PaymentOrchestrationState.created,
|
||||
failureCode: null,
|
||||
failureReason: null,
|
||||
operations: [],
|
||||
lastQuote: null,
|
||||
metadata: null,
|
||||
createdAt: null,
|
||||
@@ -80,6 +81,7 @@ void main() {
|
||||
orchestrationState: PaymentOrchestrationState.settled,
|
||||
failureCode: null,
|
||||
failureReason: null,
|
||||
operations: [],
|
||||
lastQuote: null,
|
||||
metadata: null,
|
||||
createdAt: null,
|
||||
@@ -99,6 +101,7 @@ void main() {
|
||||
orchestrationState: PaymentOrchestrationState.executing,
|
||||
failureCode: 'failure_ledger',
|
||||
failureReason: 'ledger failed',
|
||||
operations: [],
|
||||
lastQuote: null,
|
||||
metadata: null,
|
||||
createdAt: null,
|
||||
@@ -110,6 +113,7 @@ void main() {
|
||||
orchestrationState: PaymentOrchestrationState.failed,
|
||||
failureCode: null,
|
||||
failureReason: null,
|
||||
operations: [],
|
||||
lastQuote: null,
|
||||
metadata: null,
|
||||
createdAt: null,
|
||||
|
||||
@@ -205,13 +205,13 @@ RouteBase payoutShellRoute() => ShellRoute(
|
||||
),
|
||||
ChangeNotifierProxyProvider2<
|
||||
MultiplePayoutsProvider,
|
||||
WalletsController,
|
||||
PaymentSourceController,
|
||||
MultiplePayoutsController
|
||||
>(
|
||||
create: (_) =>
|
||||
MultiplePayoutsController(csvInput: WebCsvInputService()),
|
||||
update: (context, provider, wallets, controller) =>
|
||||
controller!..update(provider, wallets),
|
||||
update: (context, provider, sourceController, controller) =>
|
||||
controller!..update(provider, sourceController),
|
||||
),
|
||||
],
|
||||
child: PageSelector(child: child, routerState: state),
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:pshared/models/payment/status.dart';
|
||||
import 'package:pshared/provider/payment/payments.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';
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
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/status.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';
|
||||
|
||||
|
||||
class PaymentDetailsController extends ChangeNotifier {
|
||||
PaymentDetailsController({required String paymentId})
|
||||
: _paymentId = paymentId;
|
||||
: _paymentId = paymentId;
|
||||
|
||||
PaymentsProvider? _payments;
|
||||
String _paymentId;
|
||||
@@ -23,12 +25,44 @@ class PaymentDetailsController extends ChangeNotifier {
|
||||
bool get canDownload {
|
||||
final current = _payment;
|
||||
if (current == null) return false;
|
||||
final status = statusFromPayment(current);
|
||||
final paymentRef = current.paymentRef ?? '';
|
||||
return status == OperationStatus.success &&
|
||||
paymentRef.trim().isNotEmpty;
|
||||
if (statusFromPayment(current) != OperationStatus.success) return false;
|
||||
return primaryOperationDocumentRequest != null;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (_paymentId != paymentId) {
|
||||
_paymentId = paymentId;
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:pshared/models/payment/operation.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';
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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/payment/payment.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 {
|
||||
final CsvInputService _csvInput;
|
||||
MultiplePayoutsProvider? _provider;
|
||||
WalletsController? _wallets;
|
||||
PaymentSourceController? _sourceController;
|
||||
_PickState _pickState = _PickState.idle;
|
||||
Exception? _uiError;
|
||||
|
||||
MultiplePayoutsController({
|
||||
required CsvInputService csvInput,
|
||||
}) : _csvInput = csvInput;
|
||||
MultiplePayoutsController({required CsvInputService csvInput})
|
||||
: _csvInput = csvInput;
|
||||
|
||||
void update(MultiplePayoutsProvider provider, WalletsController wallets) {
|
||||
void update(
|
||||
MultiplePayoutsProvider provider,
|
||||
PaymentSourceController sourceController,
|
||||
) {
|
||||
var shouldNotify = false;
|
||||
if (!identical(_provider, provider)) {
|
||||
_provider?.removeListener(_onProviderChanged);
|
||||
@@ -31,10 +33,10 @@ class MultiplePayoutsController extends ChangeNotifier {
|
||||
_provider?.addListener(_onProviderChanged);
|
||||
shouldNotify = true;
|
||||
}
|
||||
if (!identical(_wallets, wallets)) {
|
||||
_wallets?.removeListener(_onWalletsChanged);
|
||||
_wallets = wallets;
|
||||
_wallets?.addListener(_onWalletsChanged);
|
||||
if (!identical(_sourceController, sourceController)) {
|
||||
_sourceController?.removeListener(_onSourceChanged);
|
||||
_sourceController = sourceController;
|
||||
_sourceController?.addListener(_onSourceChanged);
|
||||
shouldNotify = true;
|
||||
}
|
||||
if (shouldNotify) {
|
||||
@@ -58,7 +60,7 @@ class MultiplePayoutsController extends ChangeNotifier {
|
||||
_provider?.quoteStatusType ?? QuoteStatusType.missing;
|
||||
Duration? get quoteTimeLeft => _provider?.quoteTimeLeft;
|
||||
|
||||
bool get canSend => _provider?.canSend ?? false;
|
||||
bool get canSend => (_provider?.canSend ?? false) && _selectedWallet != null;
|
||||
Money? get aggregateDebitAmount =>
|
||||
_provider?.aggregateDebitAmountFor(_selectedWallet);
|
||||
Money? get requestedSentAmount => _provider?.requestedSentAmount;
|
||||
@@ -128,11 +130,11 @@ class MultiplePayoutsController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onWalletsChanged() {
|
||||
void _onSourceChanged() {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Wallet? get _selectedWallet => _wallets?.selectedWallet;
|
||||
Wallet? get _selectedWallet => _sourceController?.selectedWallet;
|
||||
|
||||
void _setUiError(Object error) {
|
||||
_uiError = error is Exception ? error : Exception(error.toString());
|
||||
@@ -150,7 +152,7 @@ class MultiplePayoutsController extends ChangeNotifier {
|
||||
@override
|
||||
void dispose() {
|
||||
_provider?.removeListener(_onProviderChanged);
|
||||
_wallets?.removeListener(_onWalletsChanged);
|
||||
_sourceController?.removeListener(_onSourceChanged);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +403,34 @@
|
||||
"idempotencyKeyLabel": "Idempotency key",
|
||||
"quoteIdLabel": "Quote ID",
|
||||
"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",
|
||||
"debitSettlementAmountLabel": "Debit settlement amount",
|
||||
"expectedSettlementAmountLabel": "Recipient gets",
|
||||
|
||||
@@ -403,6 +403,34 @@
|
||||
"idempotencyKeyLabel": "Ключ идемпотентности",
|
||||
"quoteIdLabel": "ID котировки",
|
||||
"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": "Вы платите",
|
||||
"debitSettlementAmountLabel": "Списано к зачислению",
|
||||
"expectedSettlementAmountLabel": "Получателю поступит",
|
||||
|
||||
9
frontend/pweb/lib/models/documents/operation.dart
Normal file
9
frontend/pweb/lib/models/documents/operation.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
class OperationDocumentRequestModel {
|
||||
final String gatewayService;
|
||||
final String operationRef;
|
||||
|
||||
const OperationDocumentRequestModel({
|
||||
required this.gatewayService,
|
||||
required this.operationRef,
|
||||
});
|
||||
}
|
||||
6
frontend/pweb/lib/models/payment/source_funds.dart
Normal file
6
frontend/pweb/lib/models/payment/source_funds.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
enum SourceOfFundsVisibleState {
|
||||
headerAction,
|
||||
summary,
|
||||
quoteStatus,
|
||||
sendAction,
|
||||
}
|
||||
9
frontend/pweb/lib/models/report/operation/document.dart
Normal file
9
frontend/pweb/lib/models/report/operation/document.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
class OperationDocumentInfo {
|
||||
final String operationRef;
|
||||
final String gatewayService;
|
||||
|
||||
const OperationDocumentInfo({
|
||||
required this.operationRef,
|
||||
required this.gatewayService,
|
||||
});
|
||||
}
|
||||
@@ -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/widgets/refresh_balance/wallet.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class WalletCard extends StatelessWidget {
|
||||
final Wallet wallet;
|
||||
@@ -30,7 +28,6 @@ class WalletCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final networkLabel = (wallet.network == null || wallet.network == ChainNetwork.unspecified)
|
||||
? null
|
||||
: wallet.network!.localizedName(context);
|
||||
@@ -53,11 +50,12 @@ class WalletCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BalanceHeader(
|
||||
title: loc.paymentTypeCryptoWallet,
|
||||
title: wallet.name,
|
||||
subtitle: networkLabel,
|
||||
badge: (symbol == null || symbol.isEmpty) ? null : symbol,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
BalanceAmount(
|
||||
wallet: wallet,
|
||||
@@ -65,12 +63,16 @@ class WalletCard extends StatelessWidget {
|
||||
context.read<WalletsController>().toggleBalanceMask(wallet.id);
|
||||
},
|
||||
),
|
||||
WalletBalanceRefreshButton(
|
||||
walletRef: wallet.id,
|
||||
Column(
|
||||
children: [
|
||||
WalletBalanceRefreshButton(
|
||||
walletRef: wallet.id,
|
||||
),
|
||||
BalanceAddFunds(onTopUp: onTopUp),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
BalanceAddFunds(onTopUp: onTopUp),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -17,10 +17,7 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
class LedgerAccountCard extends StatelessWidget {
|
||||
final LedgerAccount account;
|
||||
|
||||
const LedgerAccountCard({
|
||||
super.key,
|
||||
required this.account,
|
||||
});
|
||||
const LedgerAccountCard({super.key, required this.account});
|
||||
|
||||
String _formatBalance() {
|
||||
final money = account.balance?.balance;
|
||||
@@ -62,8 +59,13 @@ class LedgerAccountCard extends StatelessWidget {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final subtitle = account.name.isNotEmpty ? account.name : account.accountCode;
|
||||
final badge = account.currency.trim().isEmpty ? null : account.currency.toUpperCase();
|
||||
final accountName = account.name.trim();
|
||||
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(
|
||||
color: colorScheme.onSecondary,
|
||||
@@ -76,16 +78,14 @@ class LedgerAccountCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
BalanceHeader(
|
||||
title: loc.paymentTypeLedger,
|
||||
subtitle: subtitle.isNotEmpty ? subtitle : null,
|
||||
badge: badge,
|
||||
),
|
||||
BalanceHeader(title: title, subtitle: subtitle, badge: badge),
|
||||
Row(
|
||||
children: [
|
||||
Consumer<LedgerBalanceMaskController>(
|
||||
builder: (context, controller, _) {
|
||||
final isMasked = controller.isBalanceMasked(account.ledgerAccountRef);
|
||||
final isMasked = controller.isBalanceMasked(
|
||||
account.ledgerAccountRef,
|
||||
);
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
@@ -97,7 +97,9 @@ class LedgerAccountCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
GestureDetector(
|
||||
onTap: () => controller.toggleBalanceMask(account.ledgerAccountRef),
|
||||
onTap: () => controller.toggleBalanceMask(
|
||||
account.ledgerAccountRef,
|
||||
),
|
||||
child: Icon(
|
||||
isMasked ? Icons.visibility_off : Icons.visibility,
|
||||
size: 24,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,93 +2,133 @@ import 'package:flutter/material.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:pweb/controllers/payouts/multiple_payouts.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/panels/source_quote/header.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/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/refresh_balance/wallet.dart';
|
||||
import 'package:pweb/models/state/control_state.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
class SourceQuotePanel extends StatelessWidget {
|
||||
const SourceQuotePanel({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.walletsController,
|
||||
});
|
||||
const SourceQuotePanel({super.key, required this.controller});
|
||||
|
||||
final MultiplePayoutsController controller;
|
||||
final WalletsController walletsController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final verificationController =
|
||||
context.watch<PayoutVerificationController>();
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final sourceController = context.watch<PaymentSourceController>();
|
||||
final verificationController = context
|
||||
.watch<PayoutVerificationController>();
|
||||
final quotationProvider = context.watch<MultiQuotationProvider>();
|
||||
final verificationContextKey = quotationProvider.quotation?.quoteRef ??
|
||||
final verificationContextKey =
|
||||
quotationProvider.quotation?.quoteRef ??
|
||||
quotationProvider.quotation?.idempotencyKey;
|
||||
final isCooldownActive = verificationController.isCooldownActiveFor(
|
||||
verificationContextKey,
|
||||
);
|
||||
final canSend = controller.canSend && !isCooldownActive;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
return SourceOfFundsPanel(
|
||||
title: l10n.sourceOfFunds,
|
||||
sourceSelector: SourceWalletSelector(
|
||||
sourceController: sourceController,
|
||||
isBusy: controller.isBusy,
|
||||
),
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SourceQuotePanelHeader(),
|
||||
const SizedBox(height: 8),
|
||||
SourceWalletSelector(
|
||||
walletsController: walletsController,
|
||||
isBusy: controller.isBusy,
|
||||
SendButton(
|
||||
onPressed: () => handleMultiplePayoutSend(context, controller),
|
||||
state: controller.isSending
|
||||
? ControlState.loading
|
||||
: canSend
|
||||
? ControlState.enabled
|
||||
: ControlState.disabled,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1),
|
||||
const SizedBox(height: 12),
|
||||
SourceQuoteSummary(controller: controller, spacing: 12),
|
||||
const SizedBox(height: 12),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
if (isCooldownActive) ...[
|
||||
const SizedBox(height: 8),
|
||||
CooldownHint(
|
||||
seconds: verificationController.cooldownRemainingSecondsFor(
|
||||
verificationContextKey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/controllers/balance_mask/wallets.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/upload_panel/widget.dart';
|
||||
@@ -9,14 +7,9 @@ import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/panel_
|
||||
|
||||
|
||||
class UploadCsvLayout extends StatelessWidget {
|
||||
const UploadCsvLayout({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.walletsController,
|
||||
});
|
||||
const UploadCsvLayout({super.key, required this.controller});
|
||||
|
||||
final MultiplePayoutsController controller;
|
||||
final WalletsController walletsController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -27,28 +20,17 @@ class UploadCsvLayout extends StatelessWidget {
|
||||
if (!useHorizontal) {
|
||||
return Column(
|
||||
children: [
|
||||
PanelCard(
|
||||
child: UploadPanel(
|
||||
controller: controller,
|
||||
),
|
||||
),
|
||||
PanelCard(child: UploadPanel(controller: controller)),
|
||||
if (hasFile) ...[
|
||||
const SizedBox(height: 12),
|
||||
SourceQuotePanel(
|
||||
controller: controller,
|
||||
walletsController: walletsController,
|
||||
),
|
||||
SourceQuotePanel(controller: controller),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasFile) {
|
||||
return PanelCard(
|
||||
child: UploadPanel(
|
||||
controller: controller,
|
||||
),
|
||||
);
|
||||
return PanelCard(child: UploadPanel(controller: controller));
|
||||
}
|
||||
|
||||
return IntrinsicHeight(
|
||||
@@ -57,19 +39,12 @@ class UploadCsvLayout extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: PanelCard(
|
||||
child: UploadPanel(
|
||||
controller: controller,
|
||||
),
|
||||
),
|
||||
child: PanelCard(child: UploadPanel(controller: controller)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: SourceQuotePanel(
|
||||
controller: controller,
|
||||
walletsController: walletsController,
|
||||
),
|
||||
child: SourceQuotePanel(controller: controller),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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/layout.dart';
|
||||
|
||||
|
||||
class UploadCSVSection extends StatelessWidget {
|
||||
const UploadCSVSection({super.key});
|
||||
|
||||
@@ -22,10 +21,7 @@ class UploadCSVSection extends StatelessWidget {
|
||||
children: [
|
||||
UploadCsvHeader(theme: theme),
|
||||
const SizedBox(height: _verticalSpacing),
|
||||
UploadCsvLayout(
|
||||
controller: controller,
|
||||
walletsController: context.watch(),
|
||||
),
|
||||
UploadCsvLayout(controller: controller),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:provider/provider.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 {
|
||||
const PaymentMethodSelector({super.key});
|
||||
|
||||
@@ -4,13 +4,14 @@ import 'package:provider/provider.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/section/title.dart';
|
||||
import 'package:pweb/pages/payout_page/send/widgets/section/card.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/wallet.dart';
|
||||
|
||||
|
||||
class PaymentSourceOfFundsCard extends StatelessWidget {
|
||||
final AppDimensions dimensions;
|
||||
final String title;
|
||||
@@ -23,38 +24,33 @@ class PaymentSourceOfFundsCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PaymentSectionCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: SectionTitle(title)),
|
||||
Consumer<PaymentSourceController>(
|
||||
builder: (context, provider, _) {
|
||||
final selectedWallet = provider.selectedWallet;
|
||||
if (selectedWallet != null) {
|
||||
return WalletBalanceRefreshButton(
|
||||
walletRef: selectedWallet.id,
|
||||
);
|
||||
}
|
||||
return SourceOfFundsPanel(
|
||||
title: title,
|
||||
selectorSpacing: dimensions.paddingSmall,
|
||||
sourceSelector: const PaymentMethodSelector(),
|
||||
visibleStates: const <SourceOfFundsVisibleState>{
|
||||
SourceOfFundsVisibleState.headerAction,
|
||||
},
|
||||
stateWidgets: <SourceOfFundsVisibleState, Widget>{
|
||||
SourceOfFundsVisibleState
|
||||
.headerAction: Consumer<PaymentSourceController>(
|
||||
builder: (context, provider, _) {
|
||||
final selectedWallet = provider.selectedWallet;
|
||||
if (selectedWallet != null) {
|
||||
return WalletBalanceRefreshButton(walletRef: selectedWallet.id);
|
||||
}
|
||||
|
||||
final selectedLedger = provider.selectedLedgerAccount;
|
||||
if (selectedLedger != null) {
|
||||
return LedgerBalanceRefreshButton(
|
||||
ledgerAccountRef: selectedLedger.ledgerAccountRef,
|
||||
);
|
||||
}
|
||||
final selectedLedger = provider.selectedLedgerAccount;
|
||||
if (selectedLedger != null) {
|
||||
return LedgerBalanceRefreshButton(
|
||||
ledgerAccountRef: selectedLedger.ledgerAccountRef,
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: dimensions.paddingSmall),
|
||||
const PaymentMethodSelector(),
|
||||
],
|
||||
),
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,9 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/operation.dart';
|
||||
|
||||
|
||||
bool shouldShowToAmount(OperationItem operation) {
|
||||
if (operation.toCurrency.trim().isEmpty) return false;
|
||||
if (operation.currency.trim().isEmpty) return true;
|
||||
if (operation.currency != operation.toCurrency) return true;
|
||||
return (operation.toAmount - operation.amount).abs() > 0.0001;
|
||||
return true;
|
||||
}
|
||||
|
||||
String formatOperationTime(BuildContext context, DateTime date) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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/header.dart';
|
||||
@@ -13,12 +14,17 @@ class PaymentDetailsContent extends StatelessWidget {
|
||||
final Payment payment;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback? onDownloadAct;
|
||||
final bool Function(PaymentExecutionOperation operation)?
|
||||
canDownloadOperationDocument;
|
||||
final ValueChanged<PaymentExecutionOperation>? onDownloadOperationDocument;
|
||||
|
||||
const PaymentDetailsContent({
|
||||
super.key,
|
||||
required this.payment,
|
||||
required this.onBack,
|
||||
this.onDownloadAct,
|
||||
this.canDownloadOperationDocument,
|
||||
this.onDownloadOperationDocument,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -29,17 +35,15 @@ class PaymentDetailsContent extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
PaymentDetailsHeader(
|
||||
title: loc.paymentInfo,
|
||||
onBack: onBack,
|
||||
),
|
||||
PaymentDetailsHeader(title: loc.paymentInfo, onBack: onBack),
|
||||
const SizedBox(height: 16),
|
||||
PaymentSummaryCard(
|
||||
PaymentSummaryCard(payment: payment, onDownloadAct: onDownloadAct),
|
||||
const SizedBox(height: 16),
|
||||
PaymentDetailsSections(
|
||||
payment: payment,
|
||||
onDownloadAct: onDownloadAct,
|
||||
canDownloadOperationDocument: canDownloadOperationDocument,
|
||||
onDownloadOperationDocument: onDownloadOperationDocument,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PaymentDetailsSections(payment: payment),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -19,17 +19,17 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
class PaymentDetailsPage extends StatelessWidget {
|
||||
final String paymentId;
|
||||
|
||||
const PaymentDetailsPage({
|
||||
super.key,
|
||||
required this.paymentId,
|
||||
});
|
||||
const PaymentDetailsPage({super.key, required this.paymentId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProxyProvider<PaymentsProvider, PaymentDetailsController>(
|
||||
return ChangeNotifierProxyProvider<
|
||||
PaymentsProvider,
|
||||
PaymentDetailsController
|
||||
>(
|
||||
create: (_) => PaymentDetailsController(paymentId: paymentId),
|
||||
update: (_, payments, controller) => controller!
|
||||
..update(payments, paymentId),
|
||||
update: (_, payments, controller) =>
|
||||
controller!..update(payments, paymentId),
|
||||
child: const _PaymentDetailsView(),
|
||||
);
|
||||
}
|
||||
@@ -65,8 +65,27 @@ class _PaymentDetailsView extends StatelessWidget {
|
||||
payment: payment,
|
||||
onBack: () => _handleBack(context),
|
||||
onDownloadAct: controller.canDownload
|
||||
? () => downloadPaymentAct(context, payment.paymentRef ?? '')
|
||||
? () {
|
||||
final request = controller.primaryOperationDocumentRequest;
|
||||
if (request == null) return;
|
||||
downloadPaymentAct(
|
||||
context,
|
||||
gatewayService: request.gatewayService,
|
||||
operationRef: request.operationRef,
|
||||
);
|
||||
}
|
||||
: null,
|
||||
canDownloadOperationDocument:
|
||||
controller.canDownloadOperationDocument,
|
||||
onDownloadOperationDocument: (operation) {
|
||||
final request = controller.operationDocumentRequest(operation);
|
||||
if (request == null) return;
|
||||
downloadPaymentAct(
|
||||
context,
|
||||
gatewayService: request.gatewayService,
|
||||
operationRef: request.operationRef,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,36 +1,48 @@
|
||||
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/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 {
|
||||
final Payment payment;
|
||||
final bool Function(PaymentExecutionOperation operation)?
|
||||
canDownloadOperationDocument;
|
||||
final ValueChanged<PaymentExecutionOperation>? onDownloadOperationDocument;
|
||||
|
||||
const PaymentDetailsSections({
|
||||
super.key,
|
||||
required this.payment,
|
||||
this.canDownloadOperationDocument,
|
||||
this.onDownloadOperationDocument,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasFx = _hasFxQuote(payment);
|
||||
if (!hasFx) {
|
||||
return PaymentMetadataSection(payment: payment);
|
||||
}
|
||||
final hasOperations = payment.operations.isNotEmpty;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: PaymentFxSection(payment: payment)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: PaymentMetadataSection(payment: payment)),
|
||||
if (hasFx) ...[
|
||||
PaymentFxSection(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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,9 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
final feeLabel = formatMoney(fee);
|
||||
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 amountParts = splitAmount(amountLabel);
|
||||
|
||||
@@ -85,10 +87,10 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
icon: Icons.south_east,
|
||||
text: loc.recipientWillReceive(toAmountLabel),
|
||||
),
|
||||
if (feeLabel != '-')
|
||||
if (showFee)
|
||||
InfoLine(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
text: loc.fee(feeLabel),
|
||||
text: feeText,
|
||||
muted: true,
|
||||
),
|
||||
if (onDownloadAct != null) ...[
|
||||
|
||||
@@ -14,37 +14,34 @@ class OperationStatusBadge extends StatelessWidget {
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final label = status.localized(context);
|
||||
final bg = _badgeColor(context);
|
||||
final fg = _textColor(bg);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final view = operationStatusView(
|
||||
l10n,
|
||||
Theme.of(context).colorScheme,
|
||||
status,
|
||||
);
|
||||
final label = view.label;
|
||||
final bg = view.backgroundColor;
|
||||
final fg = view.foregroundColor;
|
||||
|
||||
return badges.Badge(
|
||||
badgeStyle: badges.BadgeStyle(
|
||||
shape: badges.BadgeShape.square,
|
||||
badgeColor: bg,
|
||||
borderRadius: BorderRadius.circular(12), // fully rounded
|
||||
borderRadius: BorderRadius.circular(12), // fully rounded
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2 // tighter padding
|
||||
horizontal: 6,
|
||||
vertical: 2, // tighter padding
|
||||
),
|
||||
),
|
||||
badgeContent: Text(
|
||||
label.toUpperCase(), // or keep sentence case
|
||||
label.toUpperCase(), // or keep sentence case
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: 11, // smaller text
|
||||
fontWeight: FontWeight.w500, // medium weight
|
||||
fontSize: 11, // smaller text
|
||||
fontWeight: FontWeight.w500, // medium weight
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -31,9 +31,7 @@ class OperationFilters extends StatelessWidget {
|
||||
: '${dateToLocalFormat(context, selectedRange!.start)} – ${dateToLocalFormat(context, selectedRange!.end)}';
|
||||
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -61,12 +59,12 @@ class OperationFilters extends StatelessWidget {
|
||||
OutlinedButton.icon(
|
||||
onPressed: onPickRange,
|
||||
icon: const Icon(Icons.date_range_outlined, size: 18),
|
||||
label: Text(
|
||||
periodLabel,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
label: Text(periodLabel, overflow: TextOverflow.ellipsis),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
@@ -76,11 +74,7 @@ class OperationFilters extends StatelessWidget {
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 8,
|
||||
children: const [
|
||||
OperationStatus.success,
|
||||
OperationStatus.processing,
|
||||
OperationStatus.error,
|
||||
].map((status) {
|
||||
children: OperationStatus.values.map((status) {
|
||||
final label = status.localized(context);
|
||||
final isSelected = selectedStatuses.contains(status);
|
||||
return FilterChip(
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:pweb/utils/report/download_act.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class OperationRow {
|
||||
static DataRow build(OperationItem op, BuildContext context) {
|
||||
final isUnknownDate = op.date.millisecondsSinceEpoch == 0;
|
||||
@@ -18,29 +17,37 @@ class OperationRow {
|
||||
final dateLabel = isUnknownDate
|
||||
? '-'
|
||||
: '${TimeOfDay.fromDateTime(localDate).format(context)}\n'
|
||||
'${localDate.toIso8601String().split("T").first}';
|
||||
'${localDate.toIso8601String().split("T").first}';
|
||||
|
||||
final canDownload = op.status == OperationStatus.success &&
|
||||
(op.paymentRef ?? '').trim().isNotEmpty;
|
||||
final canDownload =
|
||||
op.status == OperationStatus.success &&
|
||||
(op.operationRef ?? '').trim().isNotEmpty &&
|
||||
(op.gatewayService ?? '').trim().isNotEmpty;
|
||||
|
||||
final documentCell = canDownload
|
||||
? TextButton.icon(
|
||||
onPressed: () => downloadPaymentAct(context, op.paymentRef ?? ''),
|
||||
onPressed: () => downloadPaymentAct(
|
||||
context,
|
||||
gatewayService: op.gatewayService ?? '',
|
||||
operationRef: op.operationRef ?? '',
|
||||
),
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text(loc.downloadAct),
|
||||
)
|
||||
: Text(op.fileName ?? '');
|
||||
|
||||
return DataRow(cells: [
|
||||
DataCell(OperationStatusBadge(status: op.status)),
|
||||
DataCell(documentCell),
|
||||
DataCell(Text('${amountToString(op.amount)} ${op.currency}')),
|
||||
DataCell(Text('${amountToString(op.toAmount)} ${op.toCurrency}')),
|
||||
DataCell(Text(op.payId)),
|
||||
DataCell(Text(op.cardNumber ?? '-')),
|
||||
DataCell(Text(op.name)),
|
||||
DataCell(Text(dateLabel)),
|
||||
DataCell(Text(op.comment)),
|
||||
]);
|
||||
return DataRow(
|
||||
cells: [
|
||||
DataCell(OperationStatusBadge(status: op.status)),
|
||||
DataCell(documentCell),
|
||||
DataCell(Text('${amountToString(op.amount)} ${op.currency}')),
|
||||
DataCell(Text('${amountToString(op.toAmount)} ${op.toCurrency}')),
|
||||
DataCell(Text(op.payId)),
|
||||
DataCell(Text(op.cardNumber ?? '-')),
|
||||
DataCell(Text(op.name)),
|
||||
DataCell(Text(dateLabel)),
|
||||
DataCell(Text(op.comment)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
23
frontend/pweb/lib/utils/payment/operation_code.dart
Normal file
23
frontend/pweb/lib/utils/payment/operation_code.dart
Normal 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;
|
||||
}
|
||||
@@ -7,50 +7,150 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
class StatusView {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
Color get color => backgroundColor;
|
||||
}
|
||||
|
||||
StatusView operationStatusView(
|
||||
AppLocalizations l10n,
|
||||
ColorScheme scheme,
|
||||
OperationStatus status,
|
||||
) {
|
||||
switch (status) {
|
||||
case OperationStatus.success:
|
||||
return statusView(l10n, 'SUCCESS');
|
||||
case OperationStatus.error:
|
||||
return statusView(l10n, 'FAILED');
|
||||
case OperationStatus.processing:
|
||||
return statusView(l10n, 'ACCEPTED');
|
||||
return operationStatusViewFromToken(
|
||||
l10n,
|
||||
scheme,
|
||||
operationStatusTokenFromEnum(status),
|
||||
);
|
||||
}
|
||||
|
||||
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(' ');
|
||||
}
|
||||
|
||||
@@ -10,14 +10,18 @@ import 'package:pweb/utils/error/snackbar.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
Future<void> downloadPaymentAct(BuildContext context, String paymentRef) async {
|
||||
Future<void> downloadPaymentAct(
|
||||
BuildContext context, {
|
||||
required String gatewayService,
|
||||
required String operationRef,
|
||||
}) async {
|
||||
final organizations = context.read<OrganizationsProvider>();
|
||||
if (!organizations.isOrganizationSet) {
|
||||
return;
|
||||
}
|
||||
final trimmed = paymentRef.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
final gateway = gatewayService.trim();
|
||||
final operation = operationRef.trim();
|
||||
if (gateway.isEmpty || operation.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,9 +29,10 @@ Future<void> downloadPaymentAct(BuildContext context, String paymentRef) async {
|
||||
await executeActionWithNotification(
|
||||
context: context,
|
||||
action: () async {
|
||||
final file = await PaymentDocumentsService.getAct(
|
||||
final file = await PaymentDocumentsService.getOperationDocument(
|
||||
organizations.current.id,
|
||||
trimmed,
|
||||
gateway,
|
||||
operation,
|
||||
);
|
||||
await downloadFile(file);
|
||||
},
|
||||
|
||||
14
frontend/pweb/lib/utils/report/operations/document_rule.dart
Normal file
14
frontend/pweb/lib/utils/report/operations/document_rule.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:pweb/utils/payment/operation_code.dart';
|
||||
|
||||
const String _documentOperation = 'card_payout';
|
||||
const String _documentAction = 'send';
|
||||
|
||||
bool isOperationDocumentEligible(String? operationCode) {
|
||||
final pair = parseOperationCodePair(operationCode);
|
||||
if (pair == null) return false;
|
||||
return _isDocumentOperationPair(pair);
|
||||
}
|
||||
|
||||
bool _isDocumentOperationPair(OperationCodePair pair) {
|
||||
return pair.operation == _documentOperation && pair.action == _documentAction;
|
||||
}
|
||||
12
frontend/pweb/lib/utils/report/operations/state_mapper.dart
Normal file
12
frontend/pweb/lib/utils/report/operations/state_mapper.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/utils/payment/status_view.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
StatusView resolveStepStateView(BuildContext context, String? rawState) {
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return operationStatusViewFromToken(loc, scheme, rawState);
|
||||
}
|
||||
15
frontend/pweb/lib/utils/report/operations/time_format.dart
Normal file
15
frontend/pweb/lib/utils/report/operations/time_format.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:pweb/utils/report/format.dart';
|
||||
|
||||
|
||||
String formatCompletedAt(BuildContext context, DateTime? completedAt) {
|
||||
final value = meaningfulDate(completedAt);
|
||||
return formatDateLabel(context, value);
|
||||
}
|
||||
|
||||
DateTime? meaningfulDate(DateTime? value) {
|
||||
if (value == null) return null;
|
||||
if (value.year <= 1) return null;
|
||||
return value;
|
||||
}
|
||||
59
frontend/pweb/lib/utils/report/operations/title_mapper.dart
Normal file
59
frontend/pweb/lib/utils/report/operations/title_mapper.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:pweb/utils/payment/operation_code.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
String resolveOperationTitle(AppLocalizations loc, String? code) {
|
||||
final pair = parseOperationCodePair(code);
|
||||
if (pair == null) return '-';
|
||||
|
||||
final operation = _localizedOperation(loc, pair.operation);
|
||||
final action = _localizedAction(loc, pair.action);
|
||||
return loc.paymentOperationPair(operation, action);
|
||||
}
|
||||
|
||||
String _localizedOperation(AppLocalizations loc, String operation) {
|
||||
switch (operation) {
|
||||
case 'card_payout':
|
||||
return loc.paymentOperationCardPayout;
|
||||
case 'crypto':
|
||||
return loc.paymentOperationCrypto;
|
||||
case 'settlement':
|
||||
return loc.paymentOperationSettlement;
|
||||
case 'ledger':
|
||||
return loc.paymentOperationLedger;
|
||||
default:
|
||||
return _humanizeToken(operation);
|
||||
}
|
||||
}
|
||||
|
||||
String _localizedAction(AppLocalizations loc, String action) {
|
||||
switch (action) {
|
||||
case 'send':
|
||||
return loc.paymentOperationActionSend;
|
||||
case 'observe':
|
||||
return loc.paymentOperationActionObserve;
|
||||
case 'fx_convert':
|
||||
return loc.paymentOperationActionFxConvert;
|
||||
case 'credit':
|
||||
return loc.paymentOperationActionCredit;
|
||||
case 'block':
|
||||
return loc.paymentOperationActionBlock;
|
||||
case 'debit':
|
||||
return loc.paymentOperationActionDebit;
|
||||
case 'release':
|
||||
return loc.paymentOperationActionRelease;
|
||||
case 'move':
|
||||
return loc.paymentOperationActionMove;
|
||||
default:
|
||||
return _humanizeToken(action);
|
||||
}
|
||||
}
|
||||
|
||||
String _humanizeToken(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)}')
|
||||
.join(' ');
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import 'package:pshared/models/payment/state.dart';
|
||||
import 'package:pshared/models/payment/status.dart';
|
||||
import 'package:pshared/utils/money.dart';
|
||||
|
||||
import 'package:pweb/models/report/operation/document.dart';
|
||||
import 'package:pweb/utils/report/operations/document_rule.dart';
|
||||
|
||||
OperationItem mapPaymentToOperation(Payment payment) {
|
||||
final debit = payment.lastQuote?.amounts?.sourceDebitTotal;
|
||||
@@ -33,6 +35,7 @@ OperationItem mapPaymentToOperation(Payment payment) {
|
||||
payment.state,
|
||||
]) ??
|
||||
'';
|
||||
final operationDocument = _resolveOperationDocument(payment);
|
||||
|
||||
return OperationItem(
|
||||
status: statusFromPayment(payment),
|
||||
@@ -43,6 +46,8 @@ OperationItem mapPaymentToOperation(Payment payment) {
|
||||
toCurrency: toCurrency,
|
||||
payId: payId,
|
||||
paymentRef: payment.paymentRef,
|
||||
operationRef: operationDocument?.operationRef,
|
||||
gatewayService: operationDocument?.gatewayService,
|
||||
cardNumber: null,
|
||||
name: name,
|
||||
date: resolvePaymentDate(payment),
|
||||
@@ -50,17 +55,37 @@ OperationItem mapPaymentToOperation(Payment payment) {
|
||||
);
|
||||
}
|
||||
|
||||
OperationDocumentInfo? _resolveOperationDocument(Payment payment) {
|
||||
for (final operation in payment.operations) {
|
||||
final operationRef = operation.operationRef;
|
||||
final gatewayService = operation.gateway;
|
||||
if (operationRef == null || operationRef.isEmpty) continue;
|
||||
if (gatewayService == null || gatewayService.isEmpty) continue;
|
||||
|
||||
if (!isOperationDocumentEligible(operation.code)) continue;
|
||||
|
||||
return OperationDocumentInfo(
|
||||
operationRef: operationRef,
|
||||
gatewayService: gatewayService,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
OperationStatus statusFromPayment(Payment payment) {
|
||||
switch (payment.orchestrationState) {
|
||||
case PaymentOrchestrationState.failed:
|
||||
return OperationStatus.error;
|
||||
case PaymentOrchestrationState.settled:
|
||||
return OperationStatus.success;
|
||||
case PaymentOrchestrationState.created:
|
||||
case PaymentOrchestrationState.executing:
|
||||
case PaymentOrchestrationState.needsAttention:
|
||||
case PaymentOrchestrationState.unspecified:
|
||||
return OperationStatus.needsAttention;
|
||||
case PaymentOrchestrationState.created:
|
||||
return OperationStatus.pending;
|
||||
case PaymentOrchestrationState.executing:
|
||||
return OperationStatus.processing;
|
||||
case PaymentOrchestrationState.unspecified:
|
||||
return OperationStatus.pending;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
83
frontend/pweb/lib/widgets/payment/source_of_funds_panel.dart
Normal file
83
frontend/pweb/lib/widgets/payment/source_of_funds_panel.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/models/payment/source_funds.dart';
|
||||
import 'package:pweb/pages/payout_page/send/widgets/section/card.dart';
|
||||
import 'package:pweb/pages/payout_page/send/widgets/section/title.dart';
|
||||
|
||||
|
||||
class SourceOfFundsPanel extends StatelessWidget {
|
||||
const SourceOfFundsPanel({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.sourceSelector,
|
||||
this.visibleStates = const <SourceOfFundsVisibleState>{},
|
||||
this.stateWidgets = const <SourceOfFundsVisibleState, Widget>{},
|
||||
this.selectorSpacing = 8,
|
||||
this.sectionSpacing = 12,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget sourceSelector;
|
||||
final Set<SourceOfFundsVisibleState> visibleStates;
|
||||
final Map<SourceOfFundsVisibleState, Widget> stateWidgets;
|
||||
final double selectorSpacing;
|
||||
final double sectionSpacing;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final headerAction = _stateWidget(SourceOfFundsVisibleState.headerAction);
|
||||
final headerActions = headerAction == null
|
||||
? const <Widget>[]
|
||||
: <Widget>[headerAction];
|
||||
final bodySections = _buildBodySections();
|
||||
|
||||
return PaymentSectionCard(
|
||||
padding: padding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: SectionTitle(title)),
|
||||
...headerActions,
|
||||
],
|
||||
),
|
||||
SizedBox(height: selectorSpacing),
|
||||
sourceSelector,
|
||||
if (bodySections.isNotEmpty) ...[
|
||||
SizedBox(height: sectionSpacing),
|
||||
const Divider(height: 1),
|
||||
SizedBox(height: sectionSpacing),
|
||||
...bodySections,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildBodySections() {
|
||||
const orderedStates = <SourceOfFundsVisibleState>[
|
||||
SourceOfFundsVisibleState.summary,
|
||||
SourceOfFundsVisibleState.quoteStatus,
|
||||
SourceOfFundsVisibleState.sendAction,
|
||||
];
|
||||
|
||||
final sections = <Widget>[];
|
||||
for (final state in orderedStates) {
|
||||
final section = _stateWidget(state);
|
||||
if (section == null) continue;
|
||||
if (sections.isNotEmpty) {
|
||||
sections.add(SizedBox(height: sectionSpacing));
|
||||
}
|
||||
sections.add(section);
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
Widget? _stateWidget(SourceOfFundsVisibleState state) {
|
||||
if (!visibleStates.contains(state)) return null;
|
||||
return stateWidgets[state];
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/controllers/balance_mask/wallets.dart';
|
||||
import 'package:pshared/controllers/payment/source.dart';
|
||||
import 'package:pshared/models/ledger/account.dart';
|
||||
import 'package:pshared/models/payment/source_type.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
import 'package:pshared/utils/money.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
typedef _SourceOptionKey = ({PaymentSourceType type, String ref});
|
||||
|
||||
class SourceWalletSelector extends StatelessWidget {
|
||||
const SourceWalletSelector({
|
||||
super.key,
|
||||
this.walletsController,
|
||||
this.sourceController,
|
||||
this.isBusy = false,
|
||||
this.onChanged,
|
||||
}) : assert(
|
||||
(walletsController != null) != (sourceController != null),
|
||||
'Provide either walletsController or sourceController',
|
||||
);
|
||||
|
||||
final WalletsController? walletsController;
|
||||
final PaymentSourceController? sourceController;
|
||||
final bool isBusy;
|
||||
final ValueChanged<Wallet>? onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final source = sourceController;
|
||||
if (source != null) {
|
||||
final selectedWallet = source.selectedWallet;
|
||||
final selectedLedger = source.selectedLedgerAccount;
|
||||
final selectedValue = switch (source.selectedType) {
|
||||
PaymentSourceType.wallet =>
|
||||
selectedWallet == null ? null : _walletKey(selectedWallet.id),
|
||||
PaymentSourceType.ledger =>
|
||||
selectedLedger == null
|
||||
? null
|
||||
: _ledgerKey(selectedLedger.ledgerAccountRef),
|
||||
null => null,
|
||||
};
|
||||
|
||||
return _buildSourceSelector(
|
||||
context: context,
|
||||
wallets: source.wallets,
|
||||
ledgerAccounts: source.ledgerAccounts,
|
||||
selectedValue: selectedValue,
|
||||
onChanged: (value) {
|
||||
if (value.type == PaymentSourceType.wallet) {
|
||||
source.selectWalletByRef(value.ref);
|
||||
final selected = source.selectedWallet;
|
||||
if (selected != null) {
|
||||
onChanged?.call(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.type == PaymentSourceType.ledger) {
|
||||
source.selectLedgerByRef(value.ref);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final wallets = walletsController!;
|
||||
return _buildSourceSelector(
|
||||
context: context,
|
||||
wallets: wallets.wallets,
|
||||
ledgerAccounts: const <LedgerAccount>[],
|
||||
selectedValue: wallets.selectedWalletRef == null
|
||||
? null
|
||||
: _walletKey(wallets.selectedWalletRef!),
|
||||
onChanged: (value) {
|
||||
if (value.type != PaymentSourceType.wallet) return;
|
||||
wallets.selectWalletByRef(value.ref);
|
||||
final selected = wallets.selectedWallet;
|
||||
if (selected != null) {
|
||||
onChanged?.call(selected);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSourceSelector({
|
||||
required BuildContext context,
|
||||
required List<Wallet> wallets,
|
||||
required List<LedgerAccount> ledgerAccounts,
|
||||
required _SourceOptionKey? selectedValue,
|
||||
required ValueChanged<_SourceOptionKey> onChanged,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
if (wallets.isEmpty && ledgerAccounts.isEmpty) {
|
||||
return Text(l10n.noWalletsAvailable, style: theme.textTheme.bodySmall);
|
||||
}
|
||||
|
||||
final items = <DropdownMenuItem<_SourceOptionKey>>[
|
||||
...wallets.map((wallet) {
|
||||
return DropdownMenuItem<_SourceOptionKey>(
|
||||
value: _walletKey(wallet.id),
|
||||
child: Text(
|
||||
'${wallet.name} - ${_walletBalance(wallet)}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}),
|
||||
...ledgerAccounts.map((ledger) {
|
||||
return DropdownMenuItem<_SourceOptionKey>(
|
||||
value: _ledgerKey(ledger.ledgerAccountRef),
|
||||
child: Text(
|
||||
'${ledger.name} - ${_ledgerBalance(ledger)}',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}),
|
||||
];
|
||||
|
||||
final knownValues = items
|
||||
.map((item) => item.value)
|
||||
.whereType<_SourceOptionKey>()
|
||||
.toSet();
|
||||
final effectiveValue = knownValues.contains(selectedValue)
|
||||
? selectedValue
|
||||
: null;
|
||||
|
||||
return DropdownButtonFormField<_SourceOptionKey>(
|
||||
initialValue: effectiveValue,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.whereGetMoney,
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
items: items,
|
||||
onChanged: isBusy
|
||||
? null
|
||||
: (value) {
|
||||
if (value == null) return;
|
||||
onChanged(value);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_SourceOptionKey _walletKey(String walletRef) =>
|
||||
(type: PaymentSourceType.wallet, ref: walletRef);
|
||||
|
||||
_SourceOptionKey _ledgerKey(String ledgerAccountRef) =>
|
||||
(type: PaymentSourceType.ledger, ref: ledgerAccountRef);
|
||||
|
||||
String _walletBalance(Wallet wallet) {
|
||||
final symbol = currencyCodeToSymbol(wallet.currency);
|
||||
return '$symbol ${amountToString(wallet.balance)}';
|
||||
}
|
||||
|
||||
String _ledgerBalance(LedgerAccount account) {
|
||||
final money = account.balance?.balance;
|
||||
final rawAmount = money?.amount.trim();
|
||||
final amount = parseMoneyAmount(rawAmount, fallback: double.nan);
|
||||
final amountText = amount.isNaN
|
||||
? (rawAmount == null || rawAmount.isEmpty ? '--' : rawAmount)
|
||||
: amountToString(amount);
|
||||
|
||||
final currencyCode = (money?.currency ?? account.currency)
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
final symbol = currencySymbolFromCode(currencyCode);
|
||||
if (symbol != null && symbol.trim().isNotEmpty) {
|
||||
return '$symbol $amountText';
|
||||
}
|
||||
if (currencyCode.isNotEmpty) {
|
||||
return '$amountText $currencyCode';
|
||||
}
|
||||
return amountText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:pshared/models/ledger/account.dart';
|
||||
import 'package:pshared/models/payment/wallet.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
import 'package:pshared/utils/money.dart';
|
||||
|
||||
|
||||
String walletBalance(Wallet wallet) {
|
||||
final symbol = currencyCodeToSymbol(wallet.currency);
|
||||
return '$symbol ${amountToString(wallet.balance)}';
|
||||
}
|
||||
|
||||
String ledgerBalance(LedgerAccount account) {
|
||||
final money = account.balance?.balance;
|
||||
final rawAmount = money?.amount.trim();
|
||||
final amount = parseMoneyAmount(rawAmount, fallback: double.nan);
|
||||
final amountText = amount.isNaN
|
||||
? (rawAmount == null || rawAmount.isEmpty ? '--' : rawAmount)
|
||||
: amountToString(amount);
|
||||
|
||||
final currencyCode = (money?.currency ?? account.currency)
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
final symbol = currencySymbolFromCode(currencyCode);
|
||||
if (symbol != null && symbol.trim().isNotEmpty) {
|
||||
return '$symbol $amountText';
|
||||
}
|
||||
if (currencyCode.isNotEmpty) {
|
||||
return '$amountText $currencyCode';
|
||||
}
|
||||
return amountText;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user