Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
31 changes: 31 additions & 0 deletions packages/dymo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@paykitjs/dymo",
"version": "0.0.1",
"description": "Dymo AI fraud protection for PayKit",
"files": [
"dist"
],
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsdown",
"typecheck": "tsc --noEmit",
"test": "vitest"
},
"dependencies": {
"zod": "^3.25.76"
},
"devDependencies": {
"paykitjs": "workspace:*",
"tsdown": "^0.21.1",
"vitest": "^4.0.18"
},
"peerDependencies": {
"paykitjs": "workspace:*"
}
}
206 changes: 206 additions & 0 deletions packages/dymo/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import type { BeforeSubscribeHookCtx } from "paykitjs";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { DymoPlugin } from "../plugin";

function createMockHookContext(
overrides: Partial<BeforeSubscribeHookCtx> = {},
): BeforeSubscribeHookCtx {
return {
customerId: "customer_123",
customerEmail: "test@example.com",
plan: {
id: "pro-plan",
name: "Pro Plan",
priceAmount: 2900,
priceInterval: "month",
trialDays: null,
group: "default",
hash: "abc123",
isDefault: false,
includes: [],
},
ip: "192.168.1.1",
...overrides,
};
}

describe("DymoPlugin", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("should allow subscription when email and IP are valid", async () => {
const plugin = new DymoPlugin({
apiKey: "test-key",
resilience: { enabled: false },
});

const mockClient = {
isValidEmail: vi.fn().mockResolvedValue({
allow: true,
reasons: [],
}),
isValidIP: vi.fn().mockResolvedValue({
allow: true,
reasons: [],
}),
};

plugin["client"] = mockClient;

const ctx = createMockHookContext();

await expect(plugin.onBeforeSubscribe(ctx)).resolves.not.toThrow();
expect(mockClient.isValidEmail).toHaveBeenCalledWith("test@example.com");
expect(mockClient.isValidIP).toHaveBeenCalledWith("192.168.1.1");
});

it("should block subscription when email is fraudulent", async () => {
const plugin = new DymoPlugin({
apiKey: "test-key",
resilience: { enabled: false },
});

const mockClient = {
isValidEmail: vi.fn().mockResolvedValue({
allow: false,
reasons: ["FRAUD", "DISPOSABLE"],
}),
isValidIP: vi.fn().mockResolvedValue({
allow: true,
reasons: [],
}),
};

plugin["client"] = mockClient;

const ctx = createMockHookContext();

await expect(plugin.onBeforeSubscribe(ctx)).rejects.toThrow(
"Fraud detection blocked subscription for test@example.com: FRAUD, DISPOSABLE",
);
});

it("should block subscription when IP is fraudulent", async () => {
const plugin = new DymoPlugin({
apiKey: "test-key",
resilience: { enabled: false },
});

const mockClient = {
isValidEmail: vi.fn().mockResolvedValue({
allow: true,
reasons: [],
}),
isValidIP: vi.fn().mockResolvedValue({
allow: false,
reasons: ["VPN", "TOR_NETWORK"],
}),
};

plugin["client"] = mockClient;

const ctx = createMockHookContext();

await expect(plugin.onBeforeSubscribe(ctx)).rejects.toThrow(
"Fraud detection blocked subscription for test@example.com: VPN, TOR_NETWORK",
);
});

it("should skip email check when customerEmail is undefined", async () => {
const plugin = new DymoPlugin({
apiKey: "test-key",
resilience: { enabled: false },
});

const mockClient = {
isValidEmail: vi.fn(),
isValidIP: vi.fn().mockResolvedValue({
allow: true,
reasons: [],
}),
};

plugin["client"] = mockClient;

const ctx = createMockHookContext({ customerEmail: undefined });

await expect(plugin.onBeforeSubscribe(ctx)).resolves.not.toThrow();
expect(mockClient.isValidEmail).not.toHaveBeenCalled();
expect(mockClient.isValidIP).toHaveBeenCalledWith("192.168.1.1");
});

it("should skip IP check when ip is undefined", async () => {
const plugin = new DymoPlugin({
apiKey: "test-key",
resilience: { enabled: false },
});

const mockClient = {
isValidEmail: vi.fn().mockResolvedValue({
allow: true,
reasons: [],
}),
isValidIP: vi.fn(),
};

plugin["client"] = mockClient;

const ctx = createMockHookContext({ ip: undefined });

await expect(plugin.onBeforeSubscribe(ctx)).resolves.not.toThrow();
expect(mockClient.isValidEmail).toHaveBeenCalledWith("test@example.com");
expect(mockClient.isValidIP).not.toHaveBeenCalled();
});

it("should allow subscription when resilience is enabled and API fails", async () => {
const plugin = new DymoPlugin({
apiKey: "test-key",
resilience: { enabled: true },
});

const mockClient = {
isValidEmail: vi.fn().mockRejectedValue(new Error("API error")),
isValidIP: vi.fn().mockRejectedValue(new Error("API error")),
};

plugin["client"] = mockClient;

const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

const ctx = createMockHookContext();

await expect(plugin.onBeforeSubscribe(ctx)).resolves.not.toThrow();
expect(consoleWarnSpy).toHaveBeenCalledWith("[PayKit-Dymo] Resilience active: Skipping check.");

consoleWarnSpy.mockRestore();
});

it("should throw when resilience is disabled and API fails", async () => {
const plugin = new DymoPlugin({
apiKey: "test-key",
resilience: { enabled: false },
});

const mockClient = {
isValidEmail: vi.fn().mockRejectedValue(new Error("API error")),
isValidIP: vi.fn().mockResolvedValue({
allow: true,
reasons: [],
}),
};

plugin["client"] = mockClient;

const ctx = createMockHookContext();

await expect(plugin.onBeforeSubscribe(ctx)).rejects.toThrow("Fraud check service unavailable.");
});

it("should validate config with Zod on initialization", () => {
expect(() => {
new DymoPlugin({ apiKey: "", resilience: { enabled: true } });
}).toThrow("Dymo API Key is required");
});
});
53 changes: 53 additions & 0 deletions packages/dymo/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { type DymoConfig } from "./schema";

export interface DymoResponse {
allow: boolean;
reasons: string[];
email?: string;
ip?: string;
}

export const createDymoClient = (config: DymoConfig) => {
const baseUrl = "https://api.dymo.ai/v1";

/**
* Private request handler
*/
const request = async (
endpoint: string,
data: Record<string, unknown>,
): Promise<DymoResponse> => {
const controller = new AbortController();

const timeoutId = setTimeout(() => controller.abort(), 5000);

try {
const response = await fetch(`${baseUrl}${endpoint}`, {
method: "POST",
headers: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
...data,
// Pass the user's custom deny rules directly to the API
rules: config.rules,
}),
signal: controller.signal,
});

if (!response.ok) {
throw new Error(`Dymo API Error: ${response.status} ${response.statusText}`);
}

return (await response.json()) as DymoResponse;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} finally {
clearTimeout(timeoutId);
}
};

return {
isValidEmail: (email: string) => request("/validate/email", { email }),
isValidIP: (ip: string) => request("/validate/ip", { ip }),
};
};
3 changes: 3 additions & 0 deletions packages/dymo/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { DymoPlugin } from "./plugin";
export type { DymoConfig } from "./schema";
export type { DymoResponse } from "./client";
49 changes: 49 additions & 0 deletions packages/dymo/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { PayKitPlugin, BeforeSubscribeHookCtx } from "paykitjs";

import { createDymoClient, type DymoResponse } from "./client";
import { dymoConfigSchema, type DymoConfig } from "./schema";

export class DymoPlugin implements PayKitPlugin {
id = "paykit-dymo-fraud";
private client;
private config;

constructor(options: DymoConfig) {
this.config = dymoConfigSchema.parse(options);
this.client = createDymoClient(this.config);
}

async onBeforeSubscribe(ctx: BeforeSubscribeHookCtx) {
try {
// DATA FETCH: No more DB calls here! Core provides it.
const { customerEmail, ip } = ctx;

const [emailResult, ipResult] = await Promise.all([
customerEmail
? this.client.isValidEmail(customerEmail)
: Promise.resolve<DymoResponse>({ allow: true, reasons: [] }),
ip
? this.client.isValidIP(ip)
: Promise.resolve<DymoResponse>({ allow: true, reasons: [] }),
]);

if (!emailResult.allow || !ipResult.allow) {
const reasons = [...(emailResult.reasons || []), ...(ipResult.reasons || [])];
throw new Error(
`Fraud detection blocked subscription for ${customerEmail || "unknown"}: ${reasons.join(", ")}`,
);
}
} catch (error: unknown) {
if (error instanceof Error && error.message.includes("Fraud detection")) {
throw error;
}

if (this.config.resilience.enabled) {
console.warn("[PayKit-Dymo] Resilience active: Skipping check.");
return;
}

throw new Error("Fraud check service unavailable.", { cause: error });
}
}
}
35 changes: 35 additions & 0 deletions packages/dymo/src/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as z from "zod";

export const dymoConfigSchema = z.object({
apiKey: z.string().min(1, "Dymo API Key is required"),

/**
* Rules for blocking transactions.
*/
rules: z
.object({
email: z
.object({
deny: z.array(z.string()).default(["FRAUD", "INVALID", "NO_MX_RECORDS"]),
})
.optional(),
ip: z
.object({
deny: z.array(z.string()).default(["FRAUD", "VPN", "TOR_NETWORK"]),
})
.optional(),
})
.optional(),

/**
* Resilience configuration (Fail-Open logic).
* If true, errors calling the Dymo API won't block the subscription.
*/
resilience: z
.object({
enabled: z.boolean().default(true),
})
.default({ enabled: true }),
});

export type DymoConfig = z.infer<typeof dymoConfigSchema>;
7 changes: 7 additions & 0 deletions packages/dymo/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src"
},
"include": ["src"]
}
Loading