Skip to content

feat(emails): charge 1 credit per send (POST /api/emails) - #717

Open
sweetmantech wants to merge 3 commits into
testfrom
feat/emails-charge-credits
Open

feat(emails): charge 1 credit per send (POST /api/emails)#717
sweetmantech wants to merge 3 commits into
testfrom
feat/emails-charge-credits

Conversation

@sweetmantech

@sweetmantechsweetmantech commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Why

POST /api/emails charged nothing — sends (incl. agent/scheduled) were free and unmetered (chat#1815 billing item).

Pricing → 1 credit

1 credit = $0.01. Resend's per-email cost is ≤ $0.0004 (cheapest paid tier: Pro $20 / 50,000 emails; every tier is well under $0.01). Per the rule "round up to $0.01 if below, else charge at cost without markup" → 1 credit per email.

How

Mirrors the deep-research handler's gate→work→deduct pattern:

  1. ensureCreditsOrShortCircuit gates the send — returns 402 (no send) if the account can't cover it, auto-recharging via a card on file when possible. It does not deduct.
  2. Send.
  3. On success, recordCreditDeduction({ creditsToDeduct: 1, source: "api" }) — the atomic wallet+meter wrapper (one transaction: credits_usage debit + usage_events insert). A failed send (502) is not charged.

EMAIL_CREDIT_COST is a named, documented const. Only /api/emails charges — the shared processAndSendEmail (MCP send_email) path is unchanged.

Tests

6 handler tests green: gate→charge on success (cost 1, source: api), 402 + no send when insufficient, no charge on send failure. tsc/lint clean.

Tracked on chat#1815.

🤖 Generated with Claude Code


Summary by cubic

Charge 1 credit per email for POST /api/emails, with a credit gate upfront and deduction only after a successful send. Also tags usage with model_id = "POST /api/emails" and returns a CORS-safe 500 on unexpected credit gate failures.

  • New Features

    • Gate with ensureCreditsOrShortCircuit; 402 and no send if insufficient credits, with auto-recharge via CREDIT_AUTO_RECHARGE_FALLBACK_SUCCESS_URL.
    • On success, deduct EMAIL_CREDIT_COST = 1 via recordCreditDeduction({ source: "api", modelId: "POST /api/emails" }); no charge on 502 failures.
    • Only POST /api/emails is billed; the shared processAndSendEmail path remains unchanged.
  • Bug Fixes

    • Wrap sendEmailHandler in try/catch to return a { status: "error" } 500 with CORS when the credit gate throws.

Written for commit fd178c1. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Email sending now uses credit-based billing, checking available credits before sending.
    • Email sends now include billing metadata to support usage tracking.
  • Bug Fixes

    • Improved error handling so unexpected email send failures return a controlled response instead of an unhandled error.
    • Prevents email attempts when credits are insufficient, helping avoid failed sends and inconsistent billing.

Sends were free/unmetered. Charge EMAIL_CREDIT_COST = 1 credit ($0.01): Resend's
per-email cost is <= $0.0004 (cheapest paid tier Pro $20/50k), which rounds up to
the $0.01 minimum, so 1 credit, no markup.
Pattern (matches the deep-research handler): ensureCreditsOrShortCircuit gates
first — 402 if the account can't cover it (auto-recharges via a card on file) and
does NOT deduct; then deduct only on a successful send via recordCreditDeduction
(atomic credits_usage debit + usage_events insert, source "api"). A failed send
(502) is not charged.
Tests: gate→charge on success, 402 + no send when insufficient, no charge on send
failure. 6 handler tests green; tsc/lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
apiReadyReadyPreviewJun 26, 2026 1:04am

Request Review

@coderabbitai

coderabbitaiBot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds credits-based gating to email sends. It exports billing constants, checks account credits before sending, records a credit deduction after successful sends, and returns controlled JSON errors for unexpected failures.

Changes

Email billing flow

Layer / File(s)Summary
Billing constants and imports
lib/emails/sendEmailHandler.ts
Exports EMAIL_CREDIT_COST and EMAIL_USAGE_MODEL_ID and imports the billing helpers used by the email handler.
Credits-gated send flow
lib/emails/sendEmailHandler.ts
Validates accountId, short-circuits when credits are insufficient, sends the email, records the credit deduction on success, and returns a controlled 500 response for unexpected errors.

Sequence Diagram(s)

sequenceDiagram
participant Request as NextRequest
participant Handler as sendEmailHandler
participant Billing as ensureCreditsOrShortCircuit
participant Ledger as recordCreditDeduction
participant Response as NextResponse
Request->>Handler: POST /api/emails with accountId
Handler->>Billing: ensure EMAIL_CREDIT_COST for the account
alt insufficient credits
Billing-->>Handler: short-circuit response
Handler-->>Response: return short-circuit JSON
else credits available
Billing-->>Handler: continue
Handler->>Handler: attempt email send
Handler->>Handler: inspect result.success
alt send succeeded
Handler->>Ledger: record EMAIL_CREDIT_COST and EMAIL_USAGE_MODEL_ID
Handler-->>Response: return success JSON
else send failed
Handler-->>Response: return failure JSON
end
end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • recoupable/api#570: Uses the same credits-metering path via recordCreditDeduction and related usage tracking helpers.
  • recoupable/api#612: Also routes a request flow through shared credit debit and usage recording logic.

Poem

A mailer tapped the credit gate,
Then sailed through send-or-seal-the-fate.
If stars and credits both align,
The ledger writes a tidy line.
✉️✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Solid & Clean Code⚠️ WarningsendEmailHandler is 58 lines and mixes validation, billing, send, deduction, and error handling; it also logs raw caught errors, violating the clean-code limits.Split the route into smaller helpers (in appropriately named files if adding functions) and replace raw console.error(error) with sanitized metadata-only logging.
✅ Passed checks (2 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/emails-charge-credits

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.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files

Confidence score: 3/5

  • In lib/emails/sendEmailHandler.ts, a credit-gate failure can throw outside the handler’s normal error path, which may return an uncontrolled error shape or miss CORS headers and break client-side handling of send failures. Wrap the gate call in the handler’s try/catch flow and return a controlled 500 JSON response with CORS headers before merging.
Architecture diagram
sequenceDiagram
participant Client as External Client
participant Handler as sendEmailHandler (POST /api/emails)
participant Validator as validateSendEmailBody
participant Credits as ensureCreditsOrShortCircuit
participant Wallet as recordCreditDeduction
participant Email as processAndSendEmail
participant Resend as Resend API
Note over Client,Resend: Email send flow with credit gating and deduction
Client->>Handler: POST /api/emails (to, subject, text, etc.)
Handler->>Validator: validate request body + auth
Validator-->>Handler: validated data (includes accountId)
Handler->>Credits: ensureCreditsOrShortCircuit(accountId, creditsToDeduct:1, successUrl)
alt Credits insufficient (402 path)
Credits->>Credits: Attempt auto-recharge via card on file
alt Auto-recharge succeeds
Credits-->>Handler: null (proceed)
else Auto-recharge fails or no card
Credits-->>Handler: NextResponse 402 (Insufficient credits)
Handler-->>Client: 402 error response
end
else Credits sufficient
Credits-->>Handler: null (proceed)
end
Handler->>Email: processAndSendEmail(to, cc, subject, text, html, headers, chat_id)
Email->>Resend: send email via Resend API
Resend-->>Email: Send result (success/failure)
alt Send successful
Email-->>Handler: { success: true, message, id }
Handler->>Wallet: recordCreditDeduction(accountId, creditsToDeduct:1, source:"api")
Wallet->>Wallet: Atomic transaction: debit credits_usage + insert usage_events
Wallet-->>Handler: { success: true }
Handler-->>Client: 200 (success=true, message, id)
else Send failed
Email-->>Handler: { success: false, error }
Handler-->>Client: 502 error response (no credit deduction)
end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadlib/emails/sendEmailHandler.ts Outdated
…iew)
Per cubic review: the credit gate (ensureCreditsOrShortCircuit →
autoRechargeOrFail) makes Stripe calls and can throw, but the handler had no
try/catch, so a Stripe/DB hiccup produced an uncaught, uncontrolled 500 with no
CORS headers. Wrap the whole handler body and return a controlled
{ status: "error" } 500 with CORS on any unexpected throw.
Added a test: gate throws → 500 with CORS, no send. 7 handler tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sweetmantech

Copy link
Copy Markdown
ContributorAuthor

Fixed the cubic finding (f514770d): the credit gate (ensureCreditsOrShortCircuitautoRechargeOrFail) makes Stripe calls (resolveStripeCustomerForAccount, chargeCustomerOffSession, createCreditsStripeSession) and can throw — confirmed valid. Wrapped the whole handler body in try/catch returning a controlled { status: "error" }500 with CORS headers on any unexpected throw, instead of an uncaught error. Added a test (gate throws → 500 + CORS, no send). 7 handler tests green; tsc/lint clean.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="lib/emails/__tests__/sendEmailHandler.test.ts">
<violation number="1" location="lib/emails/__tests__/sendEmailHandler.test.ts:96">
P2: Test doesn't assert the error message is the safe hardcoded string — could mask a leak of the raw exception text in the response.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

expect(response.status).toBe(500);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
const json = await response.json();
expect(json.status).toBe("error");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Test doesn't assert the error message is the safe hardcoded string — could mask a leak of the raw exception text in the response.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/__tests__/sendEmailHandler.test.ts, line 96:
<comment>Test doesn't assert the error message is the safe hardcoded string — could mask a leak of the raw exception text in the response.</comment>
<file context>
@@ -87,6 +87,16 @@ describe("sendEmailHandler", () => {
+ expect(response.status).toBe(500);
+ expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
+ const json = await response.json();
+ expect(json.status).toBe("error");
+ expect(mockProcessAndSendEmail).not.toHaveBeenCalled();
+ });
</file context>

@sweetmantech

Copy link
Copy Markdown
ContributorAuthor

Preview test — 1 credit charged per send, verified end-to-end ✅

Tested on the branch preview https://api-git-feat-emails-charge-credits-recoup.vercel.app (commit f514770d, account fb678396).

StepResult
credits_usage.remaining_creditsbefore989
POST /api/emails (1 send)200, delivered (Resend a250bc29-…)
credits_usage.remaining_creditsafter988 — exactly −1 (EMAIL_CREDIT_COST)
usage_events row✅ new row source: api, credits_deducted_cents: 1, ts 00:54:38 (matches the send)

So both halves of the atomic write landed: the wallet (credits_usage) debited by 1 and the meter (usage_events) recorded the charge. Deduction happens only on a successful send.

Not forced on the deployment (unit-tested instead):

  • 402 insufficient — this account has a card on file, so the gate auto-recharges ($5 → 500 credits) rather than 402. Covered by the handler unit test (ensureCreditsOrShortCircuit → 402, no send, no charge).
  • 500 gate-throws (the cubic fix) — can't force a Stripe error on demand; covered by the new unit test (gate throws → controlled 500 with CORS, no send).

7 handler tests green; tsc adds 0 new errors; lint clean.

So endpoint usage is queryable per the request:
select count(*) from usage_events where model_id = 'POST /api/emails'
recordCreditDeduction already accepts modelId; pass EMAIL_USAGE_MODEL_ID.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sweetmantech

Copy link
Copy Markdown
ContributorAuthor

Update — usage_events.model_id added + verified (fd178c10)

Per the request to track endpoint usage by query, each send now stamps model_id = "POST /api/emails" on the usage_events row (recordCreditDeduction({ … modelId })).

Verified on the preview — sent one email, latest usage_events row:

source = api | model_id = "POST /api/emails" | credits_deducted_cents = 1

So usage is now queryable:

selectcount(*) from usage_events where model_id ='POST /api/emails';

(The earlier test send — before this commit — has model_id = null, as expected.)

Recap of what's on this PR now

  1. Charge 1 credit/send (gate→send→deduct; recordCreditDeductionsource: api). Verified: 989 → 988, usage_events row written.
  2. Cubic fix: handler wrapped in try/catch → controlled 500 + CORS if the credit gate throws (Stripe error).
  3. model_id stamping (this update).

7 handler tests green; tsc 0 new errors; lint clean. The stacked Telegram PR (api#718) was merged-forward to include all three.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
lib/emails/sendEmailHandler.ts (1)

40-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Split the handler to meet the function-size/SRP rules.

sendEmailHandler is now doing validation, credit gating, email dispatch, billing, and error shaping in one 58-line function. Extract focused domain functions into their own matching files so the handler remains orchestration-only.

As per coding guidelines, “Flag functions longer than 20 lines.” As per path instructions, lib/**/*.ts functions should “Keep functions under 50 lines” and follow “Single responsibility per function.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/emails/sendEmailHandler.ts` around lines 40 - 97, `sendEmailHandler` is
doing validation, credit gating, email dispatch, billing, and error handling all
in one place, so split it into smaller SRP-focused helpers. Extract the
validation, credit check, send/charge flow, and error shaping into dedicated
functions near `sendEmailHandler`, then keep `sendEmailHandler` as a thin
orchestrator that calls those helpers and returns the final `NextResponse`.

Sources: Coding guidelines, Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/emails/sendEmailHandler.ts`:
- Around line 76-82: The post-send billing step in sendEmailHandler should not
be treated as best-effort, because recordCreditDeduction can fail silently and
still let the email request return success. Update the sendEmailHandler flow to
make the deduction outcome durable and observable, using recordCreditDeduction
as a required step after a successful send and handling failures explicitly via
a failure result, retry/outbox, or alert path before completing the response.
- Around line 88-94: The catch block in sendEmailHandler is logging the raw
error object, which can expose sensitive request/provider data. Update the error
handling in sendEmailHandler to log only sanitized, structured fields instead of
the full caught error, and keep the controlled 500 response unchanged. Use the
sendEmailHandler catch path and the existing console.error call as the place to
replace with safe, redacted logging.
---
Nitpick comments:
In `@lib/emails/sendEmailHandler.ts`:
- Around line 40-97: `sendEmailHandler` is doing validation, credit gating,
email dispatch, billing, and error handling all in one place, so split it into
smaller SRP-focused helpers. Extract the validation, credit check, send/charge
flow, and error shaping into dedicated functions near `sendEmailHandler`, then
keep `sendEmailHandler` as a thin orchestrator that calls those helpers and
returns the final `NextResponse`.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 57765b10-ed7d-47c3-b50e-d706b86292c8

📥 Commits

Reviewing files that changed from the base of the PR and between cae9f35 and fd178c1.

⛔ Files ignored due to path filters (1)
  • lib/emails/__tests__/sendEmailHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (1)
  • lib/emails/sendEmailHandler.ts

Comment on lines +76 to +82
// Charge on success (best-effort: recordCreditDeduction never throws).
await recordCreditDeduction({
accountId,
creditsToDeduct: EMAIL_CREDIT_COST,
source: "api",
modelId: EMAIL_USAGE_MODEL_ID,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make the post-send billing write durable, not best-effort.

Line 76 says recordCreditDeduction never throws, so a failed wallet/usage write can still return a successful email response with no charge recorded. That violates the PR’s billing contract for successful sends; make the deduction failure observable/durable, e.g. explicit failure result + alert/retry/outbox before returning success.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/emails/sendEmailHandler.ts` around lines 76 - 82, The post-send billing
step in sendEmailHandler should not be treated as best-effort, because
recordCreditDeduction can fail silently and still let the email request return
success. Update the sendEmailHandler flow to make the deduction outcome durable
and observable, using recordCreditDeduction as a required step after a
successful send and handling failures explicitly via a failure result,
retry/outbox, or alert path before completing the response.

Comment on lines +88 to +94
} catch (error) {
// Anything unexpected (e.g. a Stripe error inside the credit gate) returns a
// controlled 500 with CORS headers instead of an uncaught error.
console.error("[sendEmailHandler]", error);
return NextResponse.json(
{ status: "error", error: "Internal server error" },
{ status: 500, headers: getCorsHeaders() },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Avoid logging raw caught errors from the email request path.

Raw external/request errors can include email addresses, headers, account identifiers, or provider payloads. Log sanitized fields instead of the full object.

Safer logging shape
- console.error("[sendEmailHandler]", error);+ console.error("[sendEmailHandler]", {+ name: error instanceof Error ? error.name : "NonError",+ });
📝 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
}catch(error){
// Anything unexpected (e.g. a Stripe error inside the credit gate) returns a
// controlled 500 with CORS headers instead of an uncaught error.
console.error("[sendEmailHandler]",error);
returnNextResponse.json(
{status: "error",error: "Internal server error"},
{status: 500,headers: getCorsHeaders()},
}catch(error){
// Anything unexpected (e.g. a Stripe error inside the credit gate) returns a
// controlled 500 with CORS headers instead of an uncaught error.
console.error("[sendEmailHandler]",{
name: errorinstanceofError ? error.name : "NonError",
});
returnNextResponse.json(
{status: "error",error: "Internal server error"},
{status: 500,headers: getCorsHeaders()},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/emails/sendEmailHandler.ts` around lines 88 - 94, The catch block in
sendEmailHandler is logging the raw error object, which can expose sensitive
request/provider data. Update the error handling in sendEmailHandler to log only
sanitized, structured fields instead of the full caught error, and keep the
controlled 500 response unchanged. Use the sendEmailHandler catch path and the
existing console.error call as the place to replace with safe, redacted logging.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 2 files (changes from recent commits).

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

Sign up for freeto 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.

1 participant

@sweetmantech