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