Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion apps/api/src/utils/geohash.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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();
});
});
});

63 changes: 63 additions & 0 deletions apps/api/src/utils/geohash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}