package fraud import ( "context" "errors" "fmt" "log/slog" "time" "github.com/google/uuid" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" pb "github.com/alchemistkay/guestguard/internal/fraudpb" ) type Decision struct { Score int `json:"score"` Risk string `json:"risk"` Reasons []string `json:"reasons"` Used bool `json:"used"` // false means we returned the fallback (engine unavailable / timed out) } type ScoreInput struct { EventID uuid.UUID GuestID uuid.UUID TokenID uuid.UUID AccessLogID uuid.UUID Fingerprint map[string]string IPAddress string UserAgent string Referrer string } type Client struct { conn *grpc.ClientConn stub pb.FraudServiceClient timeout time.Duration logger *slog.Logger } func Dial(ctx context.Context, addr string, timeout time.Duration, logger *slog.Logger) (*Client, error) { if addr == "" { return nil, errors.New("fraud grpc addr is empty") } conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { return nil, fmt.Errorf("dial fraud grpc: %w", err) } return &Client{ conn: conn, stub: pb.NewFraudServiceClient(conn), timeout: timeout, logger: logger, }, nil } func (c *Client) Close() error { if c.conn == nil { return nil } return c.conn.Close() } // Score is a synchronous fraud check. If the engine is unreachable or slow, // it returns a permissive fallback (Used=false) so the API stays available. // The caller can still decide what to do with that signal. func (c *Client) Score(ctx context.Context, in ScoreInput) Decision { callCtx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() resp, err := c.stub.Score(callCtx, &pb.ScoreRequest{ EventId: in.EventID.String(), GuestId: in.GuestID.String(), TokenId: in.TokenID.String(), AccessLogId: in.AccessLogID.String(), Fingerprint: in.Fingerprint, IpAddress: in.IPAddress, UserAgent: in.UserAgent, Referrer: in.Referrer, }) if err != nil { c.logger.Warn("fraud sync score failed, falling back", "err", err, "code", status.Code(err), "guest_id", in.GuestID, ) return Decision{Score: 0, Risk: "low", Reasons: []string{"fraud_engine_unavailable"}, Used: false} } return Decision{ Score: int(resp.Score), Risk: riskString(resp.Risk), Reasons: append([]string{}, resp.Reasons...), Used: true, } } func riskString(r pb.Risk) string { switch r { case pb.Risk_RISK_LOW: return "low" case pb.Risk_RISK_MEDIUM: return "medium" case pb.Risk_RISK_HIGH: return "high" case pb.Risk_RISK_BLOCK: return "block" default: return "unknown" } } // IsBlock is a small helper so callers don't depend on the string contract. func IsBlock(d Decision) bool { return d.Risk == "block" } // IsRetryableErr distinguishes transient gRPC errors. Currently unused but // kept for future retry middleware. func IsRetryableErr(err error) bool { if err == nil { return false } switch status.Code(err) { case codes.Unavailable, codes.DeadlineExceeded, codes.ResourceExhausted: return true } return false }