Skip to content

feat(dymo): Add Dymo fraud detection plugin for subscription validation#117

Open
Samuel-Fikre wants to merge 5 commits intogetpaykit:mainfrom
Samuel-Fikre:feat/dymo-plugin
Open

feat(dymo): Add Dymo fraud detection plugin for subscription validation#117
Samuel-Fikre wants to merge 5 commits intogetpaykit:mainfrom
Samuel-Fikre:feat/dymo-plugin

Conversation

@Samuel-Fikre
Copy link
Copy Markdown

@Samuel-Fikre Samuel-Fikre commented Apr 12, 2026

feat(dymo): Fraud Detection Plugin

Summary

This PR introduces the @paykitjs/dymo plugin to provide real-time fraud detection for subscription signups. To support this, I have also updated the core BeforeSubscribeHookCtx to include customerEmail, ensuring plugins have the necessary context to validate users before they reach the payment step.

Key Features

  • Parallel Validation: Executes email and IP fraud checks simultaneously using Promise.all to minimize latency during the signup flow.
  • Safety First: Implements a strict 5s timeout via AbortController. If the Dymo API hangs, the plugin will move on to ensure the user experience isn't interrupted.
  • Resilience Mode: Includes a "fail-open" configuration. If Dymo is unreachable, the plugin logs the error but allows the subscription to proceed, prioritizing conversion over a broken flow.
  • Data Normalization: Automatically updates the context with Dymo's cleaned/normalized email format (e.g., removing aliases or correcting casing).

File Structure

packages/
├── dymo/
│   ├── src/
│   │   ├── __tests__/      # 8 unit tests (blocking, passing, resilience)
│   │   ├── client.ts       # Optimized Dymo API wrapper
│   │   ├── plugin.ts       # Main plugin logic
│   │   └── schema.ts       # Zod configuration & API types
│   ├── package.json        # Configured for monorepo (tsdown)
│   └── tsconfig.json
└── paykit/
    └── src/
        └── types/          # Updated BeforeSubscribeHookCtx

Usage

import { PayKit } from 'paykitjs';
import { DymoPlugin } from '@paykitjs/dymo';

const paykit = new PayKit({
  plugins: [
    new DymoPlugin({
      apiKey: process.env.DYMO_API_KEY,
      // Only block if Dymo explicitly confirms these fraud types
      blockOn: ["FRAUD", "DISPOSABLE"], 
      resilience: { enabled: true } 
    })
  ]
});

Documentation Catch

I noticed that CONTRIBUTING.md currently instructs contributors to run pnpm changeset. However, after investigating the codebase, I found that the project has migrated to bumpp and changelogithub (as evidenced by bump.config.ts and the root scripts).

I have skipped the changeset file to remain consistent with the repository's actual workflow. The contributing guide likely needs a small update!

Testing

Verified with Vitest. All 8 tests passing:

  • Successful fraud blocking (Email & IP)
  • Email normalization logic
  • Timeout handling (5s limit)
  • Resilience behavior (Fail-open vs. Fail-closed)
  • Config validation via Zod

closes : #106


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Pre-subscription fraud detection: email and IP checks run before subscribing and can block transactions with consolidated reasons.
  * Hooks integrated into the subscription flow with a 5s hard timeout; failures abort the subscription unless resilience allows continuation.

* **Chores**
  * Added a new fraud package published as an ESM package with build/typecheck/test scripts and declared runtime dependency.

* **Tests**
  * Added tests covering validation results, skipping logic, resilience behavior, and config validation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 12, 2026

@Samuel-Fikre is attempting to deploy a commit to the maxktz Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 12, 2026

📝 Walkthrough

Walkthrough

Adds a new @paykitjs/dymo package (client, schema, plugin, tests, build) and integrates a BeforeSubscribe hook into PayKit to run concurrent email/IP fraud checks with configurable resilience and a 5s timeout.

Changes

Cohort / File(s) Summary
Dymo package manifest & build
packages/dymo/package.json, packages/dymo/tsconfig.json, packages/dymo/tsdown.config.ts
New package manifest (ESM), workspace/dev/peer deps, npm scripts; package-scoped tsconfig and tsdown config to emit dist/ and .d.ts.
Dymo client
packages/dymo/src/client.ts
New createDymoClient(config) producing isValidEmail and isValidIP methods that POST to https://api.dymo.ai/v1, include Authorization header, enforce a 5s AbortController timeout, parse & validate JSON with schema.
Dymo schema & types
packages/dymo/src/schema.ts
Zod schemas: dymoResponseSchema and dymoConfigSchema (required non-empty apiKey, optional rules, resilience defaulting to enabled); exports inferred DymoConfig and DymoResponse types.
Dymo plugin & public entry
packages/dymo/src/plugin.ts, packages/dymo/src/index.ts
DymoPlugin(options) factory validates config, creates client, exposes id: "paykit-dymo-fraud" and onBeforeSubscribe that runs email/IP checks in parallel, aggregates reasons, throws on disallow, and handles errors per resilience setting. Public exports added.
Dymo tests
packages/dymo/src/__tests__/plugin.test.ts
Vitest tests covering success/failure flows, aggregated error messages, skipping when email/ip undefined, resilience behavior on API failures, and config validation.
PayKit hook types & re-exports
packages/paykit/src/types/plugin.ts, packages/paykit/src/index.ts
Adds BeforeSubscribeHookCtx type and optional onBeforeSubscribe method on PayKitPlugin; index re-exports the new hook context type.
PayKit subscription flow
packages/paykit/src/subscription/subscription.service.ts
subscribeToPlan now looks up customer email, constructs hookCtx, executes all onBeforeSubscribe hooks concurrently with a 5s timeout, logs plugin errors with "[PayKit Plugin Error]", and rethrows to abort subscription on failures or timeouts.

Sequence Diagram

sequenceDiagram
    participant App as Application
    participant PayKit as PayKit Service
    participant Plugin as Dymo Plugin
    participant Client as Dymo Client
    participant API as Dymo API

    App->>PayKit: subscribeToPlan(customerId, planId)
    PayKit->>PayKit: Fetch customer record (email)
    PayKit->>Plugin: onBeforeSubscribe(hookCtx)
    Plugin->>Client: isValidEmail(customerEmail)
    Plugin->>Client: isValidIP(ip)
    par Email Validation
        Client->>API: POST /validate/email
        API-->>Client: { allow, reasons }
    and IP Validation
        Client->>API: POST /validate/ip
        API-->>Client: { allow, reasons }
    end
    Client-->>Plugin: Results
    alt Both allow true
        Plugin-->>PayKit: resolve
        PayKit->>App: subscription success
    else Any allow false
        Plugin-->>PayKit: throw Error(fraud reasons)
        PayKit-->>App: subscription error
    else API failure & resilience enabled
        Plugin-->>PayKit: warn & resolve
        PayKit->>App: subscription success
    else API failure & resilience disabled
        Plugin-->>PayKit: throw "Fraud check service unavailable."
        PayKit-->>App: subscription error
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰
I hopped in with a tiny API key and a curious nose,
I sniffed emails and IPs where subscription wind blows.
If the checks whisper danger, I raise up the flag,
If networks wobble kindly, resilience waves the flag.
Together we guard each plan as the payment garden grows.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding a Dymo fraud detection plugin for subscription validation. It directly reflects the primary changeset objective.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (9)
packages/paykit/src/subscription/subscription.service.ts (2)

56-60: Consider clearing the timeout timer after hooks complete.

The setTimeout callback will remain scheduled in the event loop until it fires, even if all hooks complete successfully before the timeout. While minor, clearing it avoids unnecessary timer resources.

Proposed fix using AbortController (as mentioned in PR objectives)
     // 1. Define a Timeout (Safety First)
     const TIMEOUT_MS = 5000;
-    const timeout = new Promise<never>((_, reject) =>
-      setTimeout(() => reject(new Error("Plugin execution timed out")), TIMEOUT_MS),
-    );
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
+    const timeout = new Promise<never>((_, reject) => {
+      controller.signal.addEventListener("abort", () => {
+        reject(new Error("Plugin execution timed out"));
+      });
+    });

     // 2. Wrap plugins in a Try-Catch
     try {
       const plugins = ctx.options.plugins ?? [];
       const hooks = plugins
         .filter((p): p is PayKitPlugin & { onBeforeSubscribe: NonNullable<PayKitPlugin["onBeforeSubscribe"]> } => !!p.onBeforeSubscribe)
         .map((p) => p.onBeforeSubscribe(hookCtx));

       await Promise.race([Promise.all(hooks), timeout]);
+      clearTimeout(timeoutId);
     } catch (error) {
+      clearTimeout(timeoutId);
       ctx.logger.error({ error: error instanceof Error ? error.message : error }, "[PayKit Plugin Error]");

       throw error;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/paykit/src/subscription/subscription.service.ts` around lines 56 -
60, The timeout promise created with TIMEOUT_MS and setTimeout leaves a
scheduled timer even when hooks finish early; modify the code that creates
timeout to capture the timer id (e.g., const timer = setTimeout(...)) and ensure
you call clearTimeout(timer) after the hooks complete or error out (where you
currently resolve/reject the race), or implement the proposed AbortController
approach by creating an AbortController, pass its signal into plugin execution,
call controller.abort() when hooks finish, and clear the timeout—update
references to TIMEOUT_MS, timeout, setTimeout, and any hook execution/cleanup
paths in subscription.service.ts accordingly.

71-71: Use ctx.logger instead of console.error for consistency.

The rest of this file uses ctx.logger.info, ctx.logger.warn, etc. Using console.error here breaks observability consistency and bypasses any configured logging transports.

Proposed fix
     } catch (error) {
-      console.error("[PayKit Plugin Error]:", error instanceof Error ? error.message : error);
+      ctx.logger.error({ error: error instanceof Error ? error.message : error }, "[PayKit Plugin Error]");

       throw error;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/paykit/src/subscription/subscription.service.ts` at line 71, Replace
the direct console.error call in subscription.service.ts with the context logger
so logging goes through the app's configured transports: find the line that
calls console.error("[PayKit Plugin Error]:", error instanceof Error ?
error.message : error) inside the subscription handling function and change it
to use ctx.logger.error, passing the same descriptive message and the error (or
error.message) so the log level and formatting remain consistent with other uses
of ctx.logger.info/warn in this file.
packages/dymo/src/__tests__/plugin.test.ts (2)

33-57: Consider using a more precise assertion for void-returning async functions.

resolves.not.toThrow() works but resolves.toBeUndefined() more directly expresses the expectation that onBeforeSubscribe returns nothing on success. This is a minor stylistic point.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/dymo/src/__tests__/plugin.test.ts` around lines 33 - 57, Replace the
generic assertion expecting no throw with a precise check that the async
void-returning method returns undefined: change the test's expectation on
plugin.onBeforeSubscribe(ctx) from await
expect(plugin.onBeforeSubscribe(ctx)).resolves.not.toThrow() to await
expect(plugin.onBeforeSubscribe(ctx)).resolves.toBeUndefined(), keeping the rest
of the test (mockClient, ctx, and subsequent toHaveBeenCalledWith assertions)
unchanged.

28-205: Comprehensive test coverage for the plugin.

The suite covers the key scenarios: valid checks, fraud blocking (email and IP), conditional skipping when fields are undefined, resilience behavior, and config validation. The use of private property access (plugin["client"]) for mocking is a reasonable pattern for testing internal behavior.

Consider adding a test case where both customerEmail and ip are undefined to confirm the plugin gracefully allows the subscription without making any API calls.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/dymo/src/__tests__/plugin.test.ts` around lines 28 - 205, Add a test
to verify behavior when both customerEmail and ip are undefined: instantiate
DymoPlugin and set plugin["client"] to a mock with isValidEmail and isValidIP as
vi.fn(), call onBeforeSubscribe with createMockHookContext({ customerEmail:
undefined, ip: undefined }) and assert it resolves without throwing and that
neither mock (isValidEmail nor isValidIP) was called; reference DymoPlugin,
onBeforeSubscribe, createMockHookContext, and plugin["client"] when locating
where to add the test.
packages/dymo/src/client.ts (2)

43-43: Unsafe type assertion on API response.

response.json() returns Promise<unknown> in strict TypeScript mode. The as DymoResponse cast bypasses runtime validation. Consider using Zod to validate the response shape, which aligns with the project's existing Zod usage for config validation.

🛡️ Proposed fix with runtime validation
+import * as z from "zod";
+
+const dymoResponseSchema = z.object({
+  allow: z.boolean(),
+  reasons: z.array(z.string()),
+  email: z.string().optional(),
+  ip: z.string().optional(),
+});
+
 // In the request function:
-      return (await response.json()) as DymoResponse;
+      const json: unknown = await response.json();
+      return dymoResponseSchema.parse(json);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/dymo/src/client.ts` at line 43, The current unsafe cast "(await
response.json()) as DymoResponse" bypasses runtime validation; replace it with a
Zod schema for the DymoResponse shape (e.g., create a dymoResponseSchema) and
use dymoResponseSchema.parse or safeParse on the value returned from
response.json(), returning the parsed/typed result or throwing a controlled
error if validation fails; update the code around response.json() and the
DymoResponse usage to rely on the parsed output so runtime shape mismatches are
caught early.

20-22: AbortController timeout doesn't differentiate abort errors.

When the timeout fires, controller.abort() causes the fetch to throw an AbortError. This gets caught in the plugin's error handler as a generic error. Consider providing a more descriptive error message for timeout scenarios to aid debugging.

💡 Optional enhancement for timeout clarity
-    const timeoutId = setTimeout(() => controller.abort(), 5000);
+    const timeoutId = setTimeout(() => controller.abort("Request timed out after 5000ms"), 5000);

Note: AbortController.abort() accepts an optional reason argument that becomes the signal.reason.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/dymo/src/client.ts` around lines 20 - 22, The timeout currently
calls controller.abort() which produces an indistinct AbortError; change the
timeout to call controller.abort(new Error('request timeout after 5000ms')) (or
similar descriptive reason) so the signal.reason contains a clear message,
ensure the fetch logic using the controller (the fetch call that uses
controller.signal) clears timeoutId on success/failure, and update the plugin's
error handler to check for AbortError/signal.reason (or error.name ===
'AbortError' and error.signal?.reason) to log/return the descriptive timeout
reason instead of a generic error; reference the AbortController instance named
controller, the timeoutId, and the fetch/error handler surrounding that call.
packages/dymo/src/plugin.ts (3)

6-14: Class usage violates coding guidelines.

The coding guidelines specify "Avoid classes (use functions/objects instead)". Consider refactoring to a factory function that returns an object implementing PayKitPlugin.

This is flagged per guidelines, but given the existing code structure and that PayKitPlugin may expect a class-like interface, this can be addressed in a follow-up. As per coding guidelines: "Avoid classes (use functions/objects instead)".

♻️ Factory function alternative
-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);
-  }
+export const createDymoPlugin = (options: DymoConfig): PayKitPlugin => {
+  const config = dymoConfigSchema.parse(options);
+  const client = createDymoClient(config);
+
+  return {
+    id: "paykit-dymo-fraud",
+    async onBeforeSubscribe(ctx: BeforeSubscribeHookCtx) {
+      // ... implementation
+    },
+  };
+};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/dymo/src/plugin.ts` around lines 6 - 14, The DymoPlugin class should
be refactored into a factory function that returns an object implementing
PayKitPlugin instead of using a class; replace the exported class DymoPlugin
with a function (e.g., createDymoPlugin) that accepts options: DymoConfig, calls
dymoConfigSchema.parse(options), constructs the client via
createDymoClient(thisConfig), and returns an object containing the id
("paykit-dymo-fraud") and the same methods/properties previously on DymoPlugin
so it conforms to PayKitPlugin; ensure private fields client and config are
replaced by local variables closed over by the returned object.

8-9: Add explicit type annotations to private fields.

The private client; and private config; declarations lack explicit types. While TypeScript can infer them from the constructor assignment, explicit annotations improve readability and ensure strict mode compliance.

💡 Proposed fix
-  private client;
-  private config;
+  private client: ReturnType<typeof createDymoClient>;
+  private config: DymoConfig;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/dymo/src/plugin.ts` around lines 8 - 9, The private fields `client`
and `config` in the class lack explicit type annotations; update their
declarations to the exact types used by the constructor parameters (i.e., set
`private client: <ClientType>` and `private config: <ConfigType>` to match the
constructor's parameter types), copying the precise type names from the
constructor signature (or imported types) so TypeScript strict mode and
readability are satisfied; ensure the annotated types align with any interfaces
or classes used elsewhere (e.g., the same types referenced in methods that
access `this.client` and `this.config`).

37-38: Fragile error detection via string matching.

Using error.message.includes("Fraud detection") to identify fraud errors is brittle—if the message text changes, this check breaks silently. Consider using a custom error class or adding a discriminant property.

💡 Proposed approach with custom error
// Define a custom error class or use a discriminant
class FraudDetectionError extends Error {
  readonly isFraudDetection = true;
  constructor(message: string) {
    super(message);
    this.name = "FraudDetectionError";
  }
}

// Then in the catch block:
if (error instanceof FraudDetectionError) {
  throw error;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/dymo/src/plugin.ts` around lines 37 - 38, Replace brittle string
matching on error.message in the catch path with a discriminant-based check:
introduce a custom error class (e.g., FraudDetectionError with name
"FraudDetectionError" or a readonly boolean property like isFraudDetection) and
update the existing check that currently uses `error instanceof Error &&
error.message.includes("Fraud detection")` to `error instanceof
FraudDetectionError` or `error && (error as any).isFraudDetection`. Ensure the
new FraudDetectionError is thrown where fraud is detected so the handler in
plugin.ts (the block checking the error and rethrowing) can reliably identify
and rethrow fraud errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/paykit/src/subscription/subscription.service.ts`:
- Around line 65-67: The filter's type guard is too broad — it asserts all
optional fields of PayKitPlugin exist even though only onBeforeSubscribe is
checked; change the predicate to a narrow type that only guarantees
onBeforeSubscribe is present (e.g., a type like PayKitPlugin & {
onBeforeSubscribe: NonNullable<PayKitPlugin['onBeforeSubscribe']> }) so the
.filter(...) line only narrows onBeforeSubscribe, then keep the .map(p =>
p.onBeforeSubscribe(hookCtx)) as-is; reference symbols: plugins, hooks,
onBeforeSubscribe, PayKitPlugin.

---

Nitpick comments:
In `@packages/dymo/src/__tests__/plugin.test.ts`:
- Around line 33-57: Replace the generic assertion expecting no throw with a
precise check that the async void-returning method returns undefined: change the
test's expectation on plugin.onBeforeSubscribe(ctx) from await
expect(plugin.onBeforeSubscribe(ctx)).resolves.not.toThrow() to await
expect(plugin.onBeforeSubscribe(ctx)).resolves.toBeUndefined(), keeping the rest
of the test (mockClient, ctx, and subsequent toHaveBeenCalledWith assertions)
unchanged.
- Around line 28-205: Add a test to verify behavior when both customerEmail and
ip are undefined: instantiate DymoPlugin and set plugin["client"] to a mock with
isValidEmail and isValidIP as vi.fn(), call onBeforeSubscribe with
createMockHookContext({ customerEmail: undefined, ip: undefined }) and assert it
resolves without throwing and that neither mock (isValidEmail nor isValidIP) was
called; reference DymoPlugin, onBeforeSubscribe, createMockHookContext, and
plugin["client"] when locating where to add the test.

In `@packages/dymo/src/client.ts`:
- Line 43: The current unsafe cast "(await response.json()) as DymoResponse"
bypasses runtime validation; replace it with a Zod schema for the DymoResponse
shape (e.g., create a dymoResponseSchema) and use dymoResponseSchema.parse or
safeParse on the value returned from response.json(), returning the parsed/typed
result or throwing a controlled error if validation fails; update the code
around response.json() and the DymoResponse usage to rely on the parsed output
so runtime shape mismatches are caught early.
- Around line 20-22: The timeout currently calls controller.abort() which
produces an indistinct AbortError; change the timeout to call
controller.abort(new Error('request timeout after 5000ms')) (or similar
descriptive reason) so the signal.reason contains a clear message, ensure the
fetch logic using the controller (the fetch call that uses controller.signal)
clears timeoutId on success/failure, and update the plugin's error handler to
check for AbortError/signal.reason (or error.name === 'AbortError' and
error.signal?.reason) to log/return the descriptive timeout reason instead of a
generic error; reference the AbortController instance named controller, the
timeoutId, and the fetch/error handler surrounding that call.

In `@packages/dymo/src/plugin.ts`:
- Around line 6-14: The DymoPlugin class should be refactored into a factory
function that returns an object implementing PayKitPlugin instead of using a
class; replace the exported class DymoPlugin with a function (e.g.,
createDymoPlugin) that accepts options: DymoConfig, calls
dymoConfigSchema.parse(options), constructs the client via
createDymoClient(thisConfig), and returns an object containing the id
("paykit-dymo-fraud") and the same methods/properties previously on DymoPlugin
so it conforms to PayKitPlugin; ensure private fields client and config are
replaced by local variables closed over by the returned object.
- Around line 8-9: The private fields `client` and `config` in the class lack
explicit type annotations; update their declarations to the exact types used by
the constructor parameters (i.e., set `private client: <ClientType>` and
`private config: <ConfigType>` to match the constructor's parameter types),
copying the precise type names from the constructor signature (or imported
types) so TypeScript strict mode and readability are satisfied; ensure the
annotated types align with any interfaces or classes used elsewhere (e.g., the
same types referenced in methods that access `this.client` and `this.config`).
- Around line 37-38: Replace brittle string matching on error.message in the
catch path with a discriminant-based check: introduce a custom error class
(e.g., FraudDetectionError with name "FraudDetectionError" or a readonly boolean
property like isFraudDetection) and update the existing check that currently
uses `error instanceof Error && error.message.includes("Fraud detection")` to
`error instanceof FraudDetectionError` or `error && (error as
any).isFraudDetection`. Ensure the new FraudDetectionError is thrown where fraud
is detected so the handler in plugin.ts (the block checking the error and
rethrowing) can reliably identify and rethrow fraud errors.

In `@packages/paykit/src/subscription/subscription.service.ts`:
- Around line 56-60: The timeout promise created with TIMEOUT_MS and setTimeout
leaves a scheduled timer even when hooks finish early; modify the code that
creates timeout to capture the timer id (e.g., const timer = setTimeout(...))
and ensure you call clearTimeout(timer) after the hooks complete or error out
(where you currently resolve/reject the race), or implement the proposed
AbortController approach by creating an AbortController, pass its signal into
plugin execution, call controller.abort() when hooks finish, and clear the
timeout—update references to TIMEOUT_MS, timeout, setTimeout, and any hook
execution/cleanup paths in subscription.service.ts accordingly.
- Line 71: Replace the direct console.error call in subscription.service.ts with
the context logger so logging goes through the app's configured transports: find
the line that calls console.error("[PayKit Plugin Error]:", error instanceof
Error ? error.message : error) inside the subscription handling function and
change it to use ctx.logger.error, passing the same descriptive message and the
error (or error.message) so the log level and formatting remain consistent with
other uses of ctx.logger.info/warn in this file.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4c3c93c8-f521-420c-9a92-11dce273166e

📥 Commits

Reviewing files that changed from the base of the PR and between 95d7147 and 1224e51.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • packages/dymo/package.json
  • packages/dymo/src/__tests__/plugin.test.ts
  • packages/dymo/src/client.ts
  • packages/dymo/src/index.ts
  • packages/dymo/src/plugin.ts
  • packages/dymo/src/schema.ts
  • packages/dymo/tsconfig.json
  • packages/dymo/tsdown.config.ts
  • packages/paykit/src/index.ts
  • packages/paykit/src/subscription/subscription.service.ts
  • packages/paykit/src/types/plugin.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/dymo/src/client.ts (1)

1-1: Use repository-standard type import syntax.

Switch to import type { DymoConfig } from "./schema"; to match the enforced TypeScript import style.

♻️ Proposed fix
-import { type DymoConfig } from "./schema";
+import type { DymoConfig } from "./schema";

As per coding guidelines, "Use import type with separated style and Node.js protocol (node:fs, node:path)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/dymo/src/client.ts` at line 1, The import in client.ts should use
the repository-standard TypeScript type-only syntax: replace the current import
of DymoConfig with a type-only import by changing the statement that references
DymoConfig to "import type { DymoConfig } from \"./schema\""; update the import
line where DymoConfig is referenced so it's declared as a type import and no
runtime import is emitted.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/dymo/src/client.ts`:
- Line 43: Add runtime validation for the parsed JSON before casting to
DymoResponse: implement a type guard function (e.g., isValidDymoResponse(obj):
obj is DymoResponse) that checks that allow is a boolean, reasons is an array of
strings (or absent/empty allowed per spec), and any other required fields match
expected types; inside the function that calls response.json(), parse into a
temp variable (e.g., const body = await response.json()), run
isValidDymoResponse(body) and return the body when true, otherwise log/error and
throw or return a safe default to prevent using malformed data in the
fraud-decision logic (reference: DymoResponse and the response.json() usage).

---

Nitpick comments:
In `@packages/dymo/src/client.ts`:
- Line 1: The import in client.ts should use the repository-standard TypeScript
type-only syntax: replace the current import of DymoConfig with a type-only
import by changing the statement that references DymoConfig to "import type {
DymoConfig } from \"./schema\""; update the import line where DymoConfig is
referenced so it's declared as a type import and no runtime import is emitted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 74a4a3f5-132c-44a4-b31b-a3f905a6d052

📥 Commits

Reviewing files that changed from the base of the PR and between 1224e51 and 10becc8.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • packages/dymo/package.json
  • packages/dymo/src/__tests__/plugin.test.ts
  • packages/dymo/src/client.ts
  • packages/dymo/src/index.ts
  • packages/dymo/src/plugin.ts
  • packages/dymo/src/schema.ts
  • packages/dymo/tsconfig.json
  • packages/dymo/tsdown.config.ts
  • packages/paykit/src/index.ts
  • packages/paykit/src/subscription/subscription.service.ts
  • packages/paykit/src/types/plugin.ts
✅ Files skipped from review due to trivial changes (4)
  • packages/dymo/tsconfig.json
  • packages/dymo/src/index.ts
  • packages/paykit/src/index.ts
  • packages/dymo/package.json
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/paykit/src/types/plugin.ts
  • packages/dymo/src/schema.ts
  • packages/paykit/src/subscription/subscription.service.ts
  • packages/dymo/tsdown.config.ts
  • packages/dymo/src/plugin.ts
  • packages/dymo/src/tests/plugin.test.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/dymo/src/plugin.ts (1)

37-40: Consider a more robust error identification pattern.

The current approach of checking error.message.includes("Fraud detection") to identify fraud-blocking errors is fragile. If the error message format changes, this check could silently break and cause fraud detections to be swallowed by resilience mode.

Consider using a custom error class or error code for more reliable identification:

♻️ Suggested approach
+class FraudBlockedError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = "FraudBlockedError";
+  }
+}
+
 // In onBeforeSubscribe:
 if (!emailResult.allow || !ipResult.allow) {
   const reasons = [...(emailResult.reasons || []), ...(ipResult.reasons || [])];
-  throw new Error(
+  throw new FraudBlockedError(
     `Fraud detection blocked subscription for ${customerEmail || "unknown"}: ${reasons.join(", ")}`,
   );
 }
 // ...
-if (error instanceof Error && error.message.includes("Fraud detection")) {
+if (error instanceof FraudBlockedError) {
   throw error;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/dymo/src/plugin.ts` around lines 37 - 40, The catch block in
packages/dymo/src/plugin.ts is using fragile string matching
(error.message.includes("Fraud detection")) to detect fraud errors; replace this
with a robust identity such as a custom error class or error code: introduce a
FraudDetectionError class (or set error.code = "FRAUD_DETECTION") where the
fraud is originally thrown, then in the catch inside the function in plugin.ts
check for error instanceof FraudDetectionError (or error.code ===
"FRAUD_DETECTION") instead of message.includes; update any places that create
the fraud error to throw the new class or set the code so the instanceof/code
check reliably identifies fraud-blocking errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/dymo/src/plugin.ts`:
- Around line 37-40: The catch block in packages/dymo/src/plugin.ts is using
fragile string matching (error.message.includes("Fraud detection")) to detect
fraud errors; replace this with a robust identity such as a custom error class
or error code: introduce a FraudDetectionError class (or set error.code =
"FRAUD_DETECTION") where the fraud is originally thrown, then in the catch
inside the function in plugin.ts check for error instanceof FraudDetectionError
(or error.code === "FRAUD_DETECTION") instead of message.includes; update any
places that create the fraud error to throw the new class or set the code so the
instanceof/code check reliably identifies fraud-blocking errors.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 731b01b9-2048-4b1a-8a0c-d09af3421d15

📥 Commits

Reviewing files that changed from the base of the PR and between 10becc8 and 6353910.

📒 Files selected for processing (6)
  • packages/dymo/src/__tests__/plugin.test.ts
  • packages/dymo/src/client.ts
  • packages/dymo/src/index.ts
  • packages/dymo/src/plugin.ts
  • packages/dymo/src/schema.ts
  • packages/paykit/src/subscription/subscription.service.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/dymo/src/index.ts

@Samuel-Fikre Samuel-Fikre changed the title Feat/dymo plugin Add Dymo fraud detection plugin for subscription validation Apr 13, 2026
@Samuel-Fikre Samuel-Fikre changed the title Add Dymo fraud detection plugin for subscription validation feat(dymo): Add Dymo fraud detection plugin for subscription validation Apr 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(plugins): fraud protection plugin (Dymo)

1 participant