diff --git a/apps/api/src/utils/geohash.test.ts b/apps/api/src/utils/geohash.test.ts index b41b60db..fb2426f0 100644 --- a/apps/api/src/utils/geohash.test.ts +++ b/apps/api/src/utils/geohash.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { encodeGeohash, decodeGeohash, cellFor, haversineKm } from "./geohash.js"; +import { + encodeGeohash, + decodeGeohash, + cellFor, + haversineKm, + isPointInPolygon, + evaluateGeofenceCompliance, +} from "./geohash.js"; describe("geohash", () => { it("encodes known reference points correctly", () => { @@ -42,4 +49,42 @@ describe("geohash", () => { expect(km).toBeGreaterThan(500); expect(km).toBeLessThan(560); }); + + describe("geofence compliance engine (issue #465)", () => { + const testSanctionZone = { + id: "zone-1", + name: "Restricted Airport Hub", + category: "restricted" as const, + polygon: [ + { lat: 10.0, lon: 10.0 }, + { lat: 10.0, lon: 20.0 }, + { lat: 20.0, lon: 20.0 }, + { lat: 20.0, lon: 10.0 }, + ], + isBlacklisted: true, + }; + + it("correctly identifies point inside polygon", () => { + expect(isPointInPolygon({ lat: 15.0, lon: 15.0 }, testSanctionZone.polygon)).toBe(true); + }); + + it("correctly identifies point outside polygon", () => { + expect(isPointInPolygon({ lat: 25.0, lon: 25.0 }, testSanctionZone.polygon)).toBe(false); + expect(isPointInPolygon({ lat: 5.0, lon: 5.0 }, testSanctionZone.polygon)).toBe(false); + }); + + it("blocks transactions inside blacklisted zones", () => { + const result = evaluateGeofenceCompliance({ lat: 15.0, lon: 15.0 }, [testSanctionZone]); + expect(result.allowed).toBe(false); + expect(result.matchedZone?.id).toBe("zone-1"); + expect(result.reason).toContain("Restricted Airport Hub"); + }); + + it("allows transactions outside blacklisted zones", () => { + const result = evaluateGeofenceCompliance({ lat: 5.0, lon: 5.0 }, [testSanctionZone]); + expect(result.allowed).toBe(true); + expect(result.matchedZone).toBeUndefined(); + }); + }); }); + diff --git a/apps/api/src/utils/geohash.ts b/apps/api/src/utils/geohash.ts index 690d7127..9c6e2a87 100644 --- a/apps/api/src/utils/geohash.ts +++ b/apps/api/src/utils/geohash.ts @@ -143,3 +143,66 @@ export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: numb Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } + +export interface Point { + lat: number; + lon: number; +} + +export interface GeoFenceZone { + id: string; + name: string; + category: "sanctioned" | "high_risk" | "restricted" | "airport"; + polygon: Point[]; + isBlacklisted: boolean; + maxCashLimit?: number; +} + +/** + * Checks if a coordinate point lies inside a polygonal geofence zone using ray-casting. + */ +export function isPointInPolygon(point: Point, polygon: Point[]): boolean { + if (polygon.length < 3) return false; + let inside = false; + const x = point.lon; + const y = point.lat; + + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const xi = polygon[i].lon; + const yi = polygon[i].lat; + const xj = polygon[j].lon; + const yj = polygon[j].lat; + + const intersect = + yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + + return inside; +} + +/** + * Evaluates whether a coordinate falls within any blacklisted or restricted geofence zones. + */ +export function evaluateGeofenceCompliance( + coord: Point, + zones: GeoFenceZone[] +): { + allowed: boolean; + matchedZone?: GeoFenceZone; + reason?: string; +} { + for (const zone of zones) { + if (isPointInPolygon(coord, zone.polygon)) { + if (zone.isBlacklisted) { + return { + allowed: false, + matchedZone: zone, + reason: `Location is inside blacklisted zone: ${zone.name} (${zone.category})`, + }; + } + } + } + return { allowed: true }; +} +