Skip to content
Open
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
4 changes: 4 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export default defineConfig([
alwaysTryTypes: true,
project: ["src/api/tsconfig.json", "src/ui/tsconfig.json"],
},
node: {
paths: ["src/api", "src/ui"],
extensions: [".js", ".ts", ".jsx", ".tsx"],
},
},
},

Expand Down
255 changes: 255 additions & 0 deletions src/api/functions/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import { isProd } from "api/utils.js";
import { InternalServerError, ValidationError } from "common/errors/index.js";
import { capitalizeFirstLetter } from "common/types/roomRequest.js";
import Stripe from "stripe";
import { createLock, IoredisAdapter, type SimpleLock } from "redlock-universal";
import { Redis } from "api/types.js";
import {
TransactWriteItemsCommand,
QueryCommand,
UpdateItemCommand,
DynamoDBClient,
} from "@aws-sdk/client-dynamodb";
import { genericConfig } from "common/config.js";
import { marshall } from "@aws-sdk/util-dynamodb";
Comment on lines +5 to +14
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Address ESLint import/extensions complaints for new imports

ESLint is flagging the .js extensions on the new imports from "api/types.js" and "common/config.js".

Depending on the project’s convention, either:

  • Drop the extension to satisfy import/extensions (e.g. import { Redis } from "api/types";, import { genericConfig } from "common/config";), or
  • Adjust the ESLint config to allow .js extensions for these path-mapped imports, in line with how other modules in this repo are handled.

Please align this with whatever pattern the rest of the codebase is using so lint passes.


🏁 Script executed:

#!/bin/bash
# Find ESLint configuration files
fd -e "eslintrc*" -e ".eslintignore" -e "eslint.config.*"

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check package.json for eslint config or import/extensions rule
cat package.json | grep -A 20 '"eslintConfig"' 2>/dev/null || echo "No eslintConfig in package.json"

Length of output: 31


🏁 Script executed:

#!/bin/bash
# Sample imports from TypeScript files to understand convention
echo "=== Sampling imports across codebase ==="
rg -n "^import\s+" --type ts --max-count 5 -g 'src/**' | head -40

Length of output: 2800


🏁 Script executed:

#!/bin/bash
# Specifically check if api/types and common/config imports are used elsewhere
echo "=== Imports from api/types ==="
rg -n 'from\s+["\']api/types' --type ts
echo ""
echo "=== Imports from common/config ==="
rg -n 'from\s+["\']common/config' --type ts

Length of output: 99


🏁 Script executed:

#!/bin/bash
# Check the actual tsconfig or module resolution settings
cat tsconfig.json 2>/dev/null | head -40

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find and display actual imports in stripe.ts
echo "=== Current imports in stripe.ts (lines 1-14) ==="
head -14 src/api/functions/stripe.ts

Length of output: 613


🏁 Script executed:

#!/bin/bash
# Search for imports of api/types using simpler pattern
echo "=== Files importing api/types (any variant) ==="
rg -n 'api/types' --type ts -C 2

Length of output: 4665


🏁 Script executed:

#!/bin/bash
# Search for imports of common/config using simpler pattern
echo "=== Files importing common/config (any variant) ==="
rg -n 'common/config' --type ts -C 2

Length of output: 22578


🏁 Script executed:

#!/bin/bash
# Check if these modules exist and have .js extensions
echo "=== Checking if api/types.ts or api/types.js exists ==="
fd -e "types" --path "*/api/*" 2>/dev/null | head -20
echo ""
echo "=== Checking if common/config.ts or common/config.js exists ==="
fd -e "config" --path "*/common/*" 2>/dev/null | head -20

Length of output: 120


🏁 Script executed:

#!/bin/bash
# Look for ESLint config in different formats
echo "=== Searching for eslint mentions in config files ==="
rg -l 'eslint|import/extensions' --type json --type js --type yml --type yaml 2>/dev/null | head -20

Length of output: 53


🏁 Script executed:

#!/bin/bash
# Check how other files in src/api import from api/types and common/config
echo "=== Imports in other api files ==="
rg -n 'from.*["\']' src/api/ --type ts | grep -E '(api/|common/)' | head -20

Length of output: 198


🏁 Script executed:

#!/bin/bash
# Search for any hidden eslint configs, including dotfiles
echo "=== All files with 'eslint' in name (including hidden) ==="
find . -name "*eslint*" -type f 2>/dev/null | head -20

Length of output: 1529


🏁 Script executed:

#!/bin/bash
# Check for pre-commit or husky configs
echo "=== Checking for pre-commit/husky/git hooks ==="
ls -la .husky/ 2>/dev/null || echo "No .husky directory"
ls -la .git/hooks/ 2>/dev/null | head -20 || echo "No git hooks visible"
cat .pre-commit-config.yaml 2>/dev/null || echo "No .pre-commit-config.yaml"

Length of output: 306


🏁 Script executed:

#!/bin/bash
# Verify pattern: count imports WITH .js vs WITHOUT .js for api/* and common/*
echo "=== Imports from api/* WITH .js extension ==="
rg 'from\s+["\x27]api/[^"'\'']*\.js["\x27]' --type ts | wc -l
echo ""
echo "=== Imports from api/* WITHOUT .js extension ==="
rg 'from\s+["\x27]api/[^"'\'']*["\x27](?!\.js)' --type ts | wc -l
echo ""
echo "=== Imports from common/* WITH .js extension ==="
rg 'from\s+["\x27]common/[^"'\'']*\.js["\x27]' --type ts | wc -l
echo ""
echo "=== Imports from common/* WITHOUT .js extension ==="
rg 'from\s+["\x27]common/[^"'\'']*["\x27](?!\.js)' --type ts | wc -l

Length of output: 764


🏁 Script executed:

#!/bin/bash
# Check specific file to verify pattern in one location
echo "=== All imports in src/api/functions/apiKey.ts ==="
head -20 src/api/functions/apiKey.ts | grep -E '^import'

Length of output: 529


🏁 Script executed:

#!/bin/bash
# Inspect the ESLint configuration
echo "=== ESLint config (eslint.config.mjs) ==="
cat eslint.config.mjs

Length of output: 2607


🏁 Script executed:

#!/bin/bash
# Inspect the pre-commit hook
echo "=== Pre-commit hook script ==="
cat .husky/pre-commit

Length of output: 550


🏁 Script executed:

#!/bin/bash
# Check .eslintignore to see what's ignored
echo "=== ESLint ignore file ==="
cat .eslintignore

Length of output: 52


Remove .js extensions from path-mapped imports to comply with ESLint import/extensions rule

The ESLint configuration (eslint.config.mjs) explicitly requires that .js and .ts extensions be omitted from imports: "import/extensions": ["error", "ignorePackages", { js: "never", jsx: "never", ts: "never", tsx: "never" }].

The imports in stripe.ts at lines 6 and 13 violate this rule:

  • Change import { Redis } from "api/types.js"; to import { Redis } from "api/types";
  • Change import { genericConfig } from "common/config.js"; to import { genericConfig } from "common/config";

The pre-commit hook runs yarn lint --fix, which will catch this during commit. Note: the entire codebase currently uses .js extensions for these paths (127+ instances for api/*, 132+ for common/*), so a widespread fix may be needed beyond this file.

🧰 Tools
🪛 ESLint

[error] 6-6: Unexpected use of file extension "js" for "api/types.js"

(import/extensions)


[error] 13-13: Unexpected use of file extension "js" for "common/config.js"

(import/extensions)

🤖 Prompt for AI Agents
In src/api/functions/stripe.ts around lines 5 to 14, two path-mapped imports
include explicit .js extensions which violates the ESLint import/extensions
rule; remove the .js extensions from the imports so they read import { Redis }
from "api/types" and import { genericConfig } from "common/config", then run the
linter/fix (yarn lint --fix) to verify and apply consistent fixes across the
codebase.


export type StripeLinkCreateParams = {
invoiceId: string;
Expand Down Expand Up @@ -325,3 +335,248 @@ export const createStripeCustomer = async ({
);
return customer.id;
};

export type checkCustomerParams = {
acmOrg: string;
emailDomain: string;
redisClient: Redis;
dynamoClient: DynamoDBClient;
customerEmail: string;
customerName: string;
stripeApiKey: string;
};

export type CheckOrCreateResult = {
customerId: string;
needsConfirmation?: boolean;
current?: { name?: string | null; email?: string | null };
incoming?: { name: string; email: string };
};

export const checkOrCreateCustomer = async ({
acmOrg,
emailDomain,
redisClient,
dynamoClient,
customerEmail,
customerName,
stripeApiKey,
}: checkCustomerParams): Promise<CheckOrCreateResult> => {
const normalizedEmail = customerEmail.trim().toLowerCase();
const [, domainPart] = normalizedEmail.split("@");

if (!domainPart) {
throw new Error(`Could not derive email domain for "${customerEmail}".`);
}

const normalizedDomain = domainPart.toLowerCase();

const lock = createLock({
adapter: new IoredisAdapter(redisClient),
key: `stripe:${acmOrg}:${normalizedDomain}`,
retryAttempts: 5,
retryDelay: 300,
}) as SimpleLock;

const pk = `${acmOrg}#${normalizedDomain}`;

return await lock.using(async () => {
const checkCustomer = new QueryCommand({
TableName: genericConfig.StripePaymentsDynamoTableName,
KeyConditionExpression: "primaryKey = :pk AND sortKey = :sk",
ExpressionAttributeValues: {
":pk": { S: pk },
":sk": { S: "CUSTOMER" },
},
ConsistentRead: true,
});

const customerResponse = await dynamoClient.send(checkCustomer);

if (customerResponse.Count === 0) {
const customer = await createStripeCustomer({
email: normalizedEmail,
name: customerName,
stripeApiKey,
});

const createCustomer = new TransactWriteItemsCommand({
TransactItems: [
{
Put: {
TableName: genericConfig.StripePaymentsDynamoTableName,
Item: marshall(
{
primaryKey: pk,
sortKey: "CUSTOMER",
stripeCustomerId: customer,
totalAmount: 0,
createdAt: new Date().toISOString(),
},
{ removeUndefinedValues: true },
),
ConditionExpression:
"attribute_not_exists(primaryKey) AND attribute_not_exists(sortKey)",
},
},
{
Put: {
TableName: genericConfig.StripePaymentsDynamoTableName,
Item: marshall(
{
primaryKey: pk,
sortKey: `EMAIL#${normalizedEmail}`,
stripeCustomerId: customer,
createdAt: new Date().toISOString(),
},
{ removeUndefinedValues: true },
),
ConditionExpression:
"attribute_not_exists(primaryKey) AND attribute_not_exists(sortKey)",
},
},
],
});
await dynamoClient.send(createCustomer);
return { customerId: customer };
}

const existingCustomerId = (customerResponse.Items![0] as any)
.stripeCustomerId.S as string;

const stripeClient = new Stripe(stripeApiKey);
const stripeCustomer =
await stripeClient.customers.retrieve(existingCustomerId);

const liveName =
"name" in stripeCustomer ? (stripeCustomer as any).name : null;
const liveEmail =
"email" in stripeCustomer ? (stripeCustomer as any).email : null;

const needsConfirmation =
(!!liveName && liveName !== customerName) ||
(!!liveEmail && liveEmail.toLowerCase() !== normalizedEmail);

const ensureEmailMap = new TransactWriteItemsCommand({
TransactItems: [
{
Put: {
TableName: genericConfig.StripePaymentsDynamoTableName,
Item: marshall(
{
primaryKey: pk,
sortKey: `EMAIL#${normalizedEmail}`,
stripeCustomerId: existingCustomerId,
createdAt: new Date().toISOString(),
},
{ removeUndefinedValues: true },
),
ConditionExpression:
"attribute_not_exists(primaryKey) AND attribute_not_exists(sortKey)",
},
},
],
});
try {
await dynamoClient.send(ensureEmailMap);
} catch (e) {
// ignore
}
Comment on lines +480 to +484
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Silent error suppression may hide legitimate issues.

Lines 480-484 attempt to create an EMAIL# mapping entry but silently ignore all errors with a bare catch (e) { // ignore }. While this might be intended to handle duplicate writes gracefully, it also suppresses legitimate errors like permission issues, network failures, or DynamoDB service errors that should be logged or handled.

At minimum, log the error for debugging and only suppress the specific ConditionalCheckFailedException:

   try {
     await dynamoClient.send(ensureEmailMap);
   } catch (e) {
-    // ignore
+    // Ignore if the EMAIL# entry already exists (ConditionalCheckFailedException)
+    if (
+      !(e instanceof Error) ||
+      !e.name.includes("ConditionalCheckFailedException")
+    ) {
+      // Log unexpected errors for debugging
+      console.warn(
+        `Failed to create EMAIL# mapping for ${normalizedEmail}:`,
+        e
+      );
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
await dynamoClient.send(ensureEmailMap);
} catch (e) {
// ignore
}
try {
await dynamoClient.send(ensureEmailMap);
} catch (e) {
// Ignore if the EMAIL# entry already exists (ConditionalCheckFailedException)
if (
!(e instanceof Error) ||
!e.name.includes("ConditionalCheckFailedException")
) {
// Log unexpected errors for debugging
console.warn(
`Failed to create EMAIL# mapping for ${normalizedEmail}:`,
e
);
}
}
🤖 Prompt for AI Agents
In src/api/functions/stripe.ts around lines 480-484, the try/catch silently
swallows all errors when creating the EMAIL# mapping; change it to catch the
error, log it (including error message/stack and context), and only suppress the
specific conditional-failure that indicates a duplicate (e.g., check error.name
or error.code for ConditionalCheckFailedException or ConditionalCheckFailed);
for any other errors rethrow or return an error result so permission, network,
or service errors are visible and handled appropriately.


if (needsConfirmation) {
return {
customerId: existingCustomerId,
needsConfirmation: true,
current: { name: liveName ?? null, email: liveEmail ?? null },
incoming: { name: customerName, email: normalizedEmail },
};
}

return { customerId: existingCustomerId };
});
};

export type InvoiceAddParams = {
acmOrg: string;
emailDomain: string;
invoiceId: string;
invoiceAmountUsd: number;
redisClient: Redis;
dynamoClient: DynamoDBClient;
contactEmail: string;
contactName: string;
stripeApiKey: string;
};

export const addInvoice = async ({
contactName,
contactEmail,
acmOrg,
invoiceId,
invoiceAmountUsd,
emailDomain,
redisClient,
dynamoClient,
stripeApiKey,
}: InvoiceAddParams): Promise<CheckOrCreateResult> => {
const normalizedEmail = contactEmail.trim().toLowerCase();
const [, domainPart] = normalizedEmail.split("@");

if (!domainPart) {
throw new Error(`Could not derive email domain for "${contactEmail}".`);
}

const normalizedDomain = domainPart.toLowerCase();
const pk = `${acmOrg}#${normalizedDomain}`;

const result = await checkOrCreateCustomer({
acmOrg,
emailDomain: normalizedDomain,
redisClient,
dynamoClient,
customerEmail: contactEmail,
customerName: contactName,
stripeApiKey,
});

if (result.needsConfirmation) {
return result;
}

const dynamoCommand = new TransactWriteItemsCommand({
TransactItems: [
{
Put: {
TableName: genericConfig.StripePaymentsDynamoTableName,
Item: marshall(
{
primaryKey: pk,
sortKey: `CHARGE#${invoiceId}`,
invoiceAmtUsd: invoiceAmountUsd,
createdAt: new Date().toISOString(),
},
{ removeUndefinedValues: true },
),
ConditionExpression:
"attribute_not_exists(primaryKey) AND attribute_not_exists(sortKey)",
},
},
{
Update: {
TableName: genericConfig.StripePaymentsDynamoTableName,
Key: {
primaryKey: { S: pk },
sortKey: { S: "CUSTOMER" },
},
UpdateExpression: "SET totalAmount = totalAmount + :inc",
ExpressionAttributeValues: {
":inc": { N: invoiceAmountUsd.toString() },
},
},
},
],
});

await dynamoClient.send(dynamoCommand);
return { customerId: result.customerId };
};
6 changes: 3 additions & 3 deletions src/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@
"prettier:write": "prettier --write *.ts **/*.ts"
},
"dependencies": {
"@aws-sdk/s3-request-presigner": "^3.914.0",
"@aws-sdk/client-s3": "^3.914.0",
"@aws-sdk/client-dynamodb": "^3.922.0",
"@aws-sdk/client-lambda": "^3.922.0",
"@aws-sdk/client-s3": "^3.922.0",
"@aws-sdk/client-secrets-manager": "^3.922.0",
"@aws-sdk/client-ses": "^3.922.0",
"@aws-sdk/client-sqs": "^3.922.0",
"@aws-sdk/client-sts": "^3.922.0",
"@aws-sdk/s3-request-presigner": "^3.922.0",
"@aws-sdk/signature-v4-crt": "^3.922.0",
"@aws-sdk/util-dynamodb": "^3.922.0",
"@azure/msal-node": "^3.8.1",
Expand Down Expand Up @@ -75,4 +75,4 @@
"pino-pretty": "^13.1.1",
"yaml": "^2.8.1"
}
}
}
Loading