Skip to content

feat: add a GET /api/receipts/bulk endpoint and receipt export as CSV/JSON β€” operators currently must call GET /api/receipts/{id} one at a time to audit payments; there is no way to export all receipts for accounting or dispute resolutionΒ #301

Description

@prince-pokharna

πŸš€ Problem Statement

The MicroAI Paygate receipt system stores signed payment receipts in Redis (receipt:{id} keys). The only way to retrieve a receipt is GET /api/receipts/{id} β€” a single-receipt lookup by ID.

For a payment gateway, operators need to:

  • Export all receipts for a time period for accounting
  • Audit which wallets paid how much over the last month
  • Resolve payment disputes by proving a receipt was issued
  • Build analytics dashboards on payment volume

Currently, there is no batch receipts endpoint and no export functionality. Operators with thousands of receipts have no way to retrieve them at scale.

βœ… Proposed Solution

1. Update gateway/ to add bulk receipts endpoint

// gateway/routes/receipts.go

// GET /api/receipts β€” paginated receipt listing with filters
func (h *ReceiptHandler) ListReceipts(c *gin.Context) {
    // Query parameters
    limit := c.DefaultQuery("limit", "50")
    cursor := c.Query("cursor") // Redis cursor for pagination
    walletFilter := c.Query("wallet") // filter by wallet address
    since := c.Query("since") // ISO timestamp filter
    format := c.DefaultQuery("format", "json") // json | csv
    
    limitInt, err := strconv.Atoi(limit)
    if err != nil || limitInt > 200 {
        limitInt = 50
    }
    
    ctx := c.Request.Context()
    
    var receipts []models.SignedReceipt
    var nextCursor string
    
    switch h.config.ReceiptStore {
    case "redis":
        receipts, nextCursor, err = h.redisStore.ScanReceipts(ctx, cursor, limitInt, walletFilter, since)
    case "memory":
        receipts, nextCursor, err = h.memoryStore.ListReceipts(ctx, limitInt, walletFilter, since)
    }
    
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve receipts"})
        return
    }
    
    // CSV export format
    if format == "csv" {
        c.Header("Content-Type", "text/csv")
        c.Header("Content-Disposition", 
            fmt.Sprintf("attachment; filename=receipts-%s.csv", time.Now().Format("2006-01-02")))
        
        w := csv.NewWriter(c.Writer)
        // Write CSV headers
        w.Write([]string{
            "receipt_id", "payment_context_hash", "signer_address", 
            "signed_at", "chain_id", "amount", "response_hash"
        })
        
        for _, r := range receipts {
            w.Write([]string{
                r.ID,
                r.PaymentContextHash,
                r.SignerAddress,
                strconv.FormatInt(r.SignedAt, 10),
                strconv.Itoa(r.ChainID),
                r.Amount,
                r.ResponseHash,
            })
        }
        w.Flush()
        return
    }
    
    // JSON format (default)
    c.JSON(http.StatusOK, gin.H{
        "receipts":    receipts,
        "count":       len(receipts),
        "nextCursor":  nextCursor,
        "hasMore":     nextCursor != "0" && nextCursor != "",
    })
}

// GET /api/receipts/stats β€” aggregate payment statistics
func (h *ReceiptHandler) GetStats(c *gin.Context) {
    ctx := c.Request.Context()
    
    receipts, _, _ := h.redisStore.ScanReceipts(ctx, "0", 10000, "", "")
    
    type WalletStat struct {
        WalletAddress string `json:"walletAddress"`
        RequestCount  int    `json:"requestCount"`
        TotalAmount   string `json:"totalAmount"`
    }
    
    walletStats := make(map[string]*WalletStat)
    for _, r := range receipts {
        if _, ok := walletStats[r.SignerAddress]; !ok {
            walletStats[r.SignerAddress] = &WalletStat{
                WalletAddress: r.SignerAddress,
            }
        }
        walletStats[r.SignerAddress].RequestCount++
    }
    
    stats := make([]WalletStat, 0, len(walletStats))
    for _, ws := range walletStats {
        stats = append(stats, *ws)
    }
    
    // Sort by request count descending
    sort.Slice(stats, func(i, j int) bool {
        return stats[i].RequestCount > stats[j].RequestCount
    })
    
    c.JSON(http.StatusOK, gin.H{
        "totalReceipts": len(receipts),
        "uniqueWallets": len(walletStats),
        "topWallets":    stats[:min(10, len(stats))],
        "asOf":          time.Now().UTC().Format(time.RFC3339),
    })
}

2. Add Redis scan helper in the receipt store

// gateway/store/redis_receipt_store.go

func (s *RedisReceiptStore) ScanReceipts(
    ctx context.Context,
    cursor string,
    limit int,
    walletFilter string,
    sinceTimestamp string,
) ([]models.SignedReceipt, string, error) {
    
    cursorInt, _ := strconv.ParseUint(cursor, 10, 64)
    
    // Scan Redis for receipt keys
    var keys []string
    var nextCursor uint64
    var err error
    
    for {
        var batch []string
        batch, nextCursor, err = s.client.Scan(ctx, cursorInt, "receipt:*", int64(limit)).Result()
        if err != nil {
            return nil, "", fmt.Errorf("redis scan failed: %w", err)
        }
        keys = append(keys, batch...)
        if nextCursor == 0 || len(keys) >= limit {
            break
        }
        cursorInt = nextCursor
    }
    
    // Fetch receipt data for each key
    var receipts []models.SignedReceipt
    for _, key := range keys[:min(limit, len(keys))] {
        data, err := s.client.Get(ctx, key).Result()
        if err != nil {
            continue
        }
        
        var receipt models.SignedReceipt
        if err := json.Unmarshal([]byte(data), &receipt); err != nil {
            continue
        }
        
        // Apply wallet filter
        if walletFilter != "" && !strings.EqualFold(receipt.SignerAddress, walletFilter) {
            continue
        }
        
        receipts = append(receipts, receipt)
    }
    
    return receipts, strconv.FormatUint(nextCursor, 10), nil
}

3. Register the new endpoints in gateway/main.go

// gateway/main.go
receipts := api.Group("/receipts")
{
    receipts.GET("/:id", receiptHandler.GetReceipt)     // existing
    receipts.GET("", receiptHandler.ListReceipts)        // NEW: bulk listing
    receipts.GET("/stats", receiptHandler.GetStats)      // NEW: aggregate stats
}

4. Update gateway/openapi.yaml

# gateway/openapi.yaml β€” add new endpoints
/api/receipts:
  get:
    summary: List stored receipts with optional filters and export formats
    parameters:
      - name: limit
        in: query
        schema: { type: integer, default: 50, maximum: 200 }
      - name: cursor
        in: query
        schema: { type: string }
        description: Redis cursor for pagination
      - name: wallet
        in: query
        schema: { type: string }
        description: Filter by EVM wallet address
      - name: format
        in: query
        schema: { type: string, enum: [json, csv], default: json }
    responses:
      '200':
        description: List of receipts

/api/receipts/stats:
  get:
    summary: Aggregate payment statistics
    responses:
      '200':
        description: Payment statistics

5. Add Go tests

// gateway/routes/receipts_test.go
func TestListReceipts_DefaultJSON(t *testing.T) {
    // Setup test server with memory receipt store
    // POST a few test receipts
    // Call GET /api/receipts
    // Assert response structure
}

func TestListReceipts_CSVExport(t *testing.T) {
    // Call GET /api/receipts?format=csv
    // Assert Content-Type: text/csv
    // Parse CSV and validate columns
}

func TestGetStats(t *testing.T) {
    // Call GET /api/receipts/stats
    // Assert totalReceipts, uniqueWallets fields
}

πŸ“ Files to Create / Modify

File Change
gateway/routes/receipts.go Add ListReceipts and GetStats handlers
gateway/store/redis_receipt_store.go Add ScanReceipts method with pagination
gateway/store/memory_receipt_store.go Add ListReceipts method for test compatibility
gateway/main.go Register new endpoints
gateway/openapi.yaml Document new endpoints
gateway/routes/receipts_test.go Go tests for new endpoints

Suggested labels: enhancement, backend, go, feature, level: intermediate

I would like to work on this. Could you please assign it to me?

Metadata

Metadata

Assignees

No one assigned

    Labels

    triageNeeds maintainer triage.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions