Skip to content

Conversation

@AdityaK2905
Copy link
Contributor

@AdityaK2905 AdityaK2905 commented Nov 17, 2025

Branched off of Vishnu's work to

  • Update Customer information when it differs
  • Add metadata to payload for payment_intent.succeeded webhook

Summary by CodeRabbit

New Features

  • Introduced invoice creation endpoint with automated Stripe customer management and checkout link generation
  • Added customer confirmation workflow that alerts when contact information conflicts
  • Implemented role-based access control for invoice operations (STRIPE_LINK_CREATOR role)

@AdityaK2905 AdityaK2905 linked an issue Nov 17, 2025 that may be closed by this pull request
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 17, 2025

Walkthrough

Introduces an idempotent invoice creation workflow using Redis-backed distributed locking and DynamoDB persistence. A new POST /createInvoice endpoint validates requests, checks or creates Stripe customers with confirmation handling, and returns either a checkout session link or a conflict response indicating name/email mismatches.

Changes

Cohort / File(s) Summary
Stripe Customer & Invoice Functions
src/api/functions/stripe.ts
Adds checkOrCreateCustomer with Redis-backed distributed locking and DynamoDB persistence for Stripe customer records. Implements addInvoice to record invoices and update customer totals. Introduces types checkCustomerParams, CheckOrCreateResult, and InvoiceAddParams. Handles race conditions with conditional writes and returns needsConfirmation when customer data mismatches occur.
Stripe Route Endpoint
src/api/routes/stripe.ts
Adds POST /createInvoice route that validates payloads using createInvoicePostRequestSchema, calls addInvoice, returns 409 on needsConfirmation, creates Stripe checkout session via createCheckoutSessionWithCustomer, and returns 201 with invoiceId and checkout link. Restricts access to STRIPE_LINK_CREATOR role.
Stripe Type Schemas
src/common/types/stripe.ts
Adds request schema createInvoicePostRequestSchema and response schemas createInvoicePostResponseSchema (success) and createInvoiceConflictResponseSchema (conflict with needsConfirmation). Exports union type PostCreateInvoiceResponseUnion and type aliases PostCreateInvoiceRequest and PostCreateInvoiceResponse.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Route as POST /createInvoice
    participant AddInvoice as addInvoice()
    participant CheckCustomer as checkOrCreateCustomer()
    participant Redis as Redis Lock
    participant DynamoDB as DynamoDB
    participant Stripe as Stripe API

    Client->>Route: Invoice creation request
    Route->>AddInvoice: Call with invoice params
    AddInvoice->>CheckCustomer: Ensure customer exists
    CheckCustomer->>Redis: Acquire distributed lock
    alt Lock acquired
        Redis-->>CheckCustomer: Lock granted
        CheckCustomer->>DynamoDB: Query existing customer
        alt Customer found
            DynamoDB-->>CheckCustomer: Customer record
            CheckCustomer->>Stripe: Fetch customer data
            Stripe-->>CheckCustomer: Current name/email
            alt Data matches input
                CheckCustomer-->>AddInvoice: customerId
            else Data mismatch
                CheckCustomer-->>AddInvoice: {needsConfirmation: true, ...}
            end
        else Customer not found
            CheckCustomer->>Stripe: Create new customer
            Stripe-->>CheckCustomer: customerId
            CheckCustomer->>DynamoDB: Store CUSTOMER & EMAIL# entries
            CheckCustomer-->>AddInvoice: customerId
        end
        Redis->>Redis: Release lock
    else Lock timeout/contention
        Redis-->>CheckCustomer: Lock failed
        CheckCustomer->>DynamoDB: Query customer (fallback)
    end
    
    alt needsConfirmation returned
        AddInvoice-->>Route: Conflict response
        Route-->>Client: 409 Conflict
    else Success
        AddInvoice->>DynamoDB: Write CHARGE# entry
        AddInvoice->>DynamoDB: Update CUSTOMER totalAmount
        AddInvoice-->>Route: customerId
        Route->>Stripe: Create checkout session
        Stripe-->>Route: Checkout link
        Route-->>Client: 201 Created {id, link}
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20–30 minutes

  • Redis-backed distributed locking: Verify lock acquisition, release, and timeout handling; ensure no deadlocks or orphaned locks
  • DynamoDB persistence & conditional writes: Check for race conditions, validate CUSTOMER/EMAIL#/CHARGE# entry schemas, and ensure idempotent writes
  • needsConfirmation flow: Review logic for detecting name/email mismatches between current Stripe customer and incoming data; validate conflict response payload structure
  • Error handling paths: Examine fallback behavior when lock acquisition fails, EMAIL# write failures are ignored, and DynamoDB query exceptions

Pre-merge checks and finishing touches

❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'Feature/customer checkout' is vague and generic, using non-descriptive terms that don't clearly convey the specific changes made in this PR. Use a more specific and descriptive title that captures the main change, such as 'Add distributed locking for Stripe customer creation and invoice checkout flow' or 'Implement idempotent Stripe customer checkout with confirmation workflow'.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/customer-checkout

Tip

📝 Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining why this PR is needed, why this solution was chosen, and what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions
Copy link
Contributor

💰 Infracost report

Monthly estimate generated

This comment will be updated when code changes.

Copy link
Contributor

@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: 5

🧹 Nitpick comments (1)
src/api/routes/stripe.ts (1)

785-866: New payment_intent.succeeded handler is robust; consider capturing more metadata

The handler defensively validates customerId, email, and acm_org, normalizes the email domain, and writes a concise record into StripePaymentsDynamoTable keyed by acmOrg#domain. Logging and error handling also look solid.

You may optionally want to include customerId in the stored item for easier debugging/analytics later, but the current implementation is sound.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

Cache: Disabled due to data retention organization setting

Knowledge base: Disabled due to data retention organization setting

📥 Commits

Reviewing files that changed from the base of the PR and between 8ae6f87 and 7df5f73.

📒 Files selected for processing (3)
  • src/api/functions/stripe.ts (2 hunks)
  • src/api/routes/stripe.ts (3 hunks)
  • src/common/types/stripe.ts (2 hunks)
🧰 Additional context used
🪛 ESLint
src/api/functions/stripe.ts

[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)

src/common/types/stripe.ts

[error] 24-24: Insert ,

(prettier/prettier)


[error] 42-42: Delete ····

(prettier/prettier)


[error] 55-55: Insert ,

(prettier/prettier)


[error] 59-60: Replace >;⏎ with ⏎>;

(prettier/prettier)


[error] 63-63: Insert

(prettier/prettier)


[error] 78-78: Insert

(prettier/prettier)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Run Unit Tests
  • GitHub Check: Build Application
🔇 Additional comments (2)
src/api/routes/stripe.ts (2)

12-22: New Stripe helper imports and request schema wiring look correct

addInvoice, createCheckoutSessionWithCustomer, InvoiceAddParams, and createInvoicePostRequestSchema are all used consistently in the new route below; the import surface looks clean.

Also applies to: 40-43


117-151: 409 confirmation flow response shape is consistent with types

The /createInvoice handler’s 409 path (needsConfirmation, customerId, current, incoming, message) matches the CheckOrCreateResult structure and createInvoiceConflictResponseSchema you defined, so the conflict semantics and payload look coherent.

Comment on lines +5 to +14
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";
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.

Comment on lines +339 to +489
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 lock = createLock({
adapter: new IoredisAdapter(redisClient),
key: `stripe:${acmOrg}:${emailDomain}`,
retryAttempts: 5,
retryDelay: 300,
}) as SimpleLock;

const pk = `${acmOrg}#${emailDomain}`;
const normalizedEmail = customerEmail.trim().toLowerCase();

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
}

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

return { customerId: existingCustomerId };
});
};
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

Normalize email domain when computing StripePaymentsDynamoTable keys and lock key

checkOrCreateCustomer currently builds the partition key as:

const pk = `${acmOrg}#${emailDomain}`;
const normalizedEmail = customerEmail.trim().toLowerCase();

while the payment_intent.succeeded handler stores records under a key built from a lowercased domain. If emailDomain is not already normalized, the same customer/org can end up under multiple partition keys, and the Redis lock key also varies by casing.

To keep data and locking consistent, normalize the domain from the email and reuse it everywhere in this function, e.g.:

 export const checkOrCreateCustomer = async ({
   acmOrg,
-  emailDomain,
+  emailDomain,
   redisClient,
   dynamoClient,
   customerEmail,
   customerName,
   stripeApiKey,
 }: checkCustomerParams): Promise<CheckOrCreateResult> => {
-  const lock = createLock({
-    adapter: new IoredisAdapter(redisClient),
-    key: `stripe:${acmOrg}:${emailDomain}`,
-    retryAttempts: 5,
-    retryDelay: 300,
-  }) as SimpleLock;
-
-  const pk = `${acmOrg}#${emailDomain}`;
-  const normalizedEmail = customerEmail.trim().toLowerCase();
+  const normalizedEmail = customerEmail.trim().toLowerCase();
+  const [, domainPart] = normalizedEmail.split("@");
+  if (!domainPart) {
+    throw new ValidationError({
+      message: `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}`;

And in addInvoice, pass and use the normalized domain similarly when computing pk so CHARGE records line up with the same partition key.

Comment on lines +503 to +562
export const addInvoice = async ({
contactName,
contactEmail,
acmOrg,
invoiceId,
invoiceAmountUsd,
emailDomain,
redisClient,
dynamoClient,
stripeApiKey,
}: InvoiceAddParams): Promise<CheckOrCreateResult> => {
const pk = `${acmOrg}#${emailDomain}`;

const result = await checkOrCreateCustomer({
acmOrg,
emailDomain,
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 },
),
},
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 };
};
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 | 🔴 Critical

🧩 Analysis chain

Fix TransactWriteItems structure and make CHARGE writes idempotent

The addInvoice transaction currently uses a single TransactItems element containing both Put and Update:

TransactItems: [
  {
    Put: { ... },
    Update: { ... },
  },
],

For DynamoDB, each TransactWriteItem must contain exactly one of Put, Update, Delete, or ConditionCheck. Having both operations in the same element will cause the command to fail, and since you don’t catch that error here, /createInvoice will bubble a 500.

Additionally, the CHARGE Put has no condition, so if the same invoice ID is retried, the item will be overwritten but totalAmount will be incremented again, breaking the intended idempotency.

You can fix both issues by separating the operations into two items and adding a condition on the CHARGE write, for example:

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

This matches DynamoDB’s API shape and ensures you don’t double-count the same invoice on retries.



---

🏁 Script executed:

```shell
#!/bin/bash
# Inspect the actual file at the specified lines
cat src/api/functions/stripe.ts | head -n 570 | tail -n 70

Length of output: 1679


🏁 Script executed:

#!/bin/bash
# Search for error handling around dynamoClient.send or addInvoice calls
rg -n -A5 -B5 "dynamoClient.send|addInvoice" src/api/functions/stripe.ts

Length of output: 1328


🏁 Script executed:

#!/bin/bash
# Check if there are other TransactWriteItemsCommand usages in the codebase
rg -n "TransactWriteItemsCommand" --type=ts

Length of output: 7010


🏁 Script executed:

#!/bin/bash
# Look for error handlers around the addInvoice function calls
rg -n -C8 "addInvoice\(" src/ --type=ts -g '!stripe.ts'

Length of output: 0


🌐 Web query:

Does DynamoDB TransactWriteItem require exactly one of Put, Update, Delete, or ConditionCheck per element?

💡 Result:

Yes. Each TransactWriteItem in TransactWriteItems must contain exactly one of: Put, Update, Delete, or ConditionCheck. See the DynamoDB API docs. [1][2]

Sources
[1] TransactWriteItem — Amazon DynamoDB API Reference.
[2] TransactWriteItems — Amazon DynamoDB API Reference.


🌐 Web query:

What happens if you put both Put and Update in the same TransactWriteItem object in AWS SDK v3?

💡 Result:

Short answer: You must not put both Put and Update in the same TransactWriteItem. Each TransactWriteItem is one request — exactly one of ConditionCheck, Put, Update or Delete — and the TransactWriteItems API requires that each item contains only one action. If you send a TransactWriteItem with multiple actions (e.g. both Put and Update) the request is invalid and will fail (API-side validation / transaction cancellation). (docs.aws.amazon.com)

Citations:



---

🏁 Script executed:

```shell
#!/bin/bash
# Search for addInvoice in routes/stripe.ts
rg -n "addInvoice" src/api/routes/stripe.ts

Length of output: 68


🏁 Script executed:

#!/bin/bash
# Check what functions are exported from stripe.ts
rg -n "^export" src/api/functions/stripe.ts | head -20

Length of output: 1027


🏁 Script executed:

#!/bin/bash
# Verify that the malformed structure would cause AWS SDK to reject it
# by checking test expectations around TransactWriteItemsCommand failures
rg -n -B3 -A3 "TransactWriteItemsCommand.*rejects" tests/unit/stripe.test.ts | head -30

Length of output: 418


Fix TransactWriteItems structure and make CHARGE writes idempotent

The addInvoice function in src/api/functions/stripe.ts (lines 530–560) has a critical bug. The TransactWriteItemsCommand currently places both Put and Update operations in the same TransactItems element, violating DynamoDB's API requirement that each TransactWriteItem contain exactly one of: Put, Update, Delete, or ConditionCheck. This causes the transaction to fail with an API validation error and bubble up a 500 response since there is no error handler.

Additionally, the Put operation lacks a ConditionExpression, so retrying the same invoice ID will overwrite the CHARGE item but increment totalAmount again, causing double-counting.

Separate the operations into two distinct TransactItems array elements and add an idempotency condition to the Put:

  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() },
          },
        },
+     },
    ],
  });

Comment on lines +153 to +176
const checkoutUrl = await createCheckoutSessionWithCustomer({
customerId: result.customerId,
stripeApiKey: secretApiConfig.stripe_secret_key as string,
items: [
{
price: "<PRICE_ID_OR_DYNAMICALLY_CREATED_PRICE>",
quantity: 1,
},
],
initiator: request.username || "system",
allowPromotionCodes: true,
successUrl: `${fastify.environmentConfig.UserFacingUrl}/success`,
returnUrl: `${fastify.environmentConfig.UserFacingUrl}/cancel`,
metadata: {
acm_org: request.body.acmOrg,
billing_email: request.body.contactEmail,
invoice_id: request.body.invoiceId,
},
});

reply.status(201).send({
id: request.body.invoiceId,
link: checkoutUrl,
});
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 | 🔴 Critical

🧩 Analysis chain

Replace placeholder price and tie checkout amount to invoiceAmountUsd

Right now the checkout session is created with a hard-coded placeholder price:

items: [
  {
    price: "<PRICE_ID_OR_DYNAMICALLY_CREATED_PRICE>",
    quantity: 1,
  },
],

This will fail at runtime and also ignores invoiceAmountUsd, so the charged amount won’t track the invoice.

Consider wiring this to a real Stripe Price derived from the invoice amount (or a configured price), for example by:

  • Reusing the existing price/product creation logic from createStripeLink, or
  • Introducing a helper that creates or looks up a price for invoiceAmountUsd and passing that price ID here.

Until this is done, /createInvoice won’t be usable in production.


I'll verify the review comment by examining the codebase. Let me search for the relevant code and understand the current implementation.


---

🏁 Script executed:

```shell
#!/bin/bash
# Find and examine stripe.ts file
fd -t f "stripe.ts" --exec cat {} \;

Length of output: 50502


🏁 Script executed:

#!/bin/bash
# Search for createStripeLink function definition
rg -n "createStripeLink" -A 10 --type ts

Length of output: 1949


🏁 Script executed:

#!/bin/bash
# Search for invoiceAmountUsd usage in the codebase
rg -n "invoiceAmountUsd" -B 2 -A 2 --type ts

Length of output: 12237


🏁 Script executed:

#!/bin/bash
# Search for price creation logic in Stripe-related files
rg -n "price:" -B 3 -A 3 --type ts -g "*stripe*"

Length of output: 2472


Create a dynamic price for /createInvoice from invoiceAmountUsd

The placeholder price "<PRICE_ID_OR_DYNAMICALLY_CREATED_PRICE>" will fail at runtime—Stripe doesn't recognize it as a valid price ID. Additionally, invoiceAmountUsd is passed but never used, so the checkout amount won't match the invoice.

Reuse the price creation logic from createStripeLink (which dynamically creates a price with stripe.prices.create() using invoiceAmountUsd), or extract it into a helper function. Apply the same approach in the /createInvoice endpoint before calling createCheckoutSessionWithCustomer.

🤖 Prompt for AI Agents
In src/api/routes/stripe.ts around lines 153 to 176, the checkout session is
using a placeholder price id and invoiceAmountUsd is ignored; create a Stripe
Price dynamically from invoiceAmountUsd (convert dollars to cents, set currency
like "usd" and unit_amount) using the same logic as createStripeLink or a shared
helper, then pass the returned price.id in the items array to
createCheckoutSessionWithCustomer; ensure you handle and await
stripe.prices.create errors and validate invoiceAmountUsd exists before creating
the price.

Comment on lines +22 to 64
export const createInvoicePostResponseSchema = z.object({
id: z.string().min(1),
link: z.url()
});

export const createInvoiceConflictResponseSchema = z.object({
needsConfirmation: z.literal(true),
customerId: z.string().min(1),
current: z.object({
name: z.string().nullable().optional(),
email: z.string().nullable().optional(),
}),
incoming: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
message: z.string().min(1),
});

export const createInvoicePostResponseSchemaUnion = z.union([
createInvoicePostResponseSchema, // success: 201
createInvoiceConflictResponseSchema, // info mismatch: 409
]);

export type PostCreateInvoiceResponseUnion = z.infer<
typeof createInvoicePostResponseSchemaUnion
>;

export const createInvoicePostRequestSchema = z.object({
invoiceId: z.string().min(1),
invoiceAmountUsd: z.number().min(50),
contactName: z.string().min(1),
contactEmail: z.email(),
acmOrg: z.string().min(1)
});

export type PostCreateInvoiceRequest = z.infer<
typeof createInvoicePostRequestSchema>;


export type PostCreateInvoiceResponse = z.infer<
typeof createInvoicePostResponseSchema>;

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

Fix Prettier/ESLint formatting in new createInvoice schemas and types

Schemas and types look good, but Prettier is complaining about missing trailing commas and line breaks on these lines. This will block linting until fixed.

You can resolve the reported prettier/prettier errors with something like:

 export const createInvoicePostResponseSchema = z.object({
   id: z.string().min(1),
-  link: z.url()
-});
+  link: z.url(),
+});
@@
 export const createInvoicePostRequestSchema = z.object({
   invoiceId: z.string().min(1),
   invoiceAmountUsd: z.number().min(50),
   contactName: z.string().min(1),
-  contactEmail: z.email(),
-  acmOrg: z.string().min(1)
-});
+  contactEmail: z.email(),
+  acmOrg: z.string().min(1),
+});
@@
-export type PostCreateInvoiceRequest = z.infer<
-  typeof createInvoicePostRequestSchema>;
-
-
-export type PostCreateInvoiceResponse = z.infer<
-  typeof createInvoicePostResponseSchema>;
+export type PostCreateInvoiceRequest = z.infer<
+  typeof createInvoicePostRequestSchema
+>;
+
+export type PostCreateInvoiceResponse = z.infer<
+  typeof createInvoicePostResponseSchema
+>;
@@
-export type GetInvoiceLinksResponse = z.infer<
-  typeof invoiceLinkGetResponseSchema>;
+export type GetInvoiceLinksResponse = z.infer<
+  typeof invoiceLinkGetResponseSchema
+>;

Also applies to: 78-78

🧰 Tools
🪛 ESLint

[error] 24-24: Insert ,

(prettier/prettier)


[error] 42-42: Delete ····

(prettier/prettier)


[error] 55-55: Insert ,

(prettier/prettier)


[error] 59-60: Replace >;⏎ with ⏎>;

(prettier/prettier)


[error] 63-63: Insert

(prettier/prettier)

🤖 Prompt for AI Agents
In src/common/types/stripe.ts around lines 22 to 64 (and also line 78), the new
zod schemas and type aliases are failing Prettier/ESLint due to missing trailing
commas and inconsistent line breaks; fix by adding trailing commas to all
multiline object/array literals (every z.object({...}), z.union([...]), and
multi-line type inference lines), ensure each closing brace/paren is on its own
line and add a final newline at EOF so formatting matches project Prettier
rules.

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.

Create a customer for each Stripe Link payer

3 participants