Skip to content

fix(issue-89): close AFTER_TTL cancel policy loopholes#136

Open
georgyia wants to merge 1 commit into
mainfrom
fix/issue-89-after-ttl-cancel-policy
Open

fix(issue-89): close AFTER_TTL cancel policy loopholes#136
georgyia wants to merge 1 commit into
mainfrom
fix/issue-89-after-ttl-cancel-policy

Conversation

@georgyia

@georgyia georgyia commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator

Closes #89.

Summary

Four related bugs in the AFTER_TTL card-cancel policy could leave a virtual card permanently active in Stripe after checkout. This PR plugs all four and adds regression tests.

# Bug Fix
1 savePrefPolicy left a stale cardTtlMinutes when switching away from AFTER_TTL (src/telegram/menuHandler.ts:254) Clear cardTtlMinutes to null on every non-AFTER_TTL switch, mirroring the API-level Zod invariant.
2 AFTER_TTL + null / non-positive cardTtlMinutes silently skipped cancellation (src/orchestrator/intentService.ts:131) Log at error level and fall back to IMMEDIATE cancellation so no card outlives checkout.
3 cancelCardProcessor had no try/catch, no structured logs, and no failed listener (src/worker/processors/cancelCardProcessor.ts) Wrap Stripe/DB errors in structured logs, throw UnrecoverableError for IntentNotFoundError so BullMQ stops retrying a dead intent, and escalate loudly when retries are exhausted.
4 Orchestrator imported getTelegramBot + InlineKeyboard directly, violating the module boundary Move notifyManualCardPending into src/telegram/notificationService.ts as sendManualCardPendingNotice; orchestrator now only calls the telegram module interface.

Testing

  • tests/unit/telegram/menuHandler.test.ts — asserts cardTtlMinutes is cleared on every non-AFTER_TTL switch (parameterized over ON_TRANSACTION, IMMEDIATE, MANUAL).
  • tests/unit/orchestrator/cancelPolicy.test.ts (new) — covers all four cancel policies, including the null / 0 / negative TTL fallback and the MANUAL notification being dispatched through the telegram module.
  • tests/unit/worker/cancelCardProcessor.test.ts (new) — happy path, transient-error retry, and IntentNotFoundErrorUnrecoverableError mapping.

Local results:

  • 370 / 370 unit tests pass (jest --testPathPattern=unit)
  • tsc --noEmit clean
  • eslint 0 errors (pre-existing warnings only)
  • prettier --check clean
  • tests/integration/telegram/menuHandler.test.ts passes end-to-end against real Postgres + Redis

Review / feedback notes

A few design choices worth calling out:

  1. Fallback vs. fail-closed. Bug 2 had two defensible fixes: fall back to IMMEDIATE cancel, or refuse the checkout with a DB CHECK constraint. I chose the fallback because checkout has already succeeded by the time we hit this branch — refusing the card cancel at that point does not undo the purchase, it only increases the window in which the card is live. A DB CHECK(cancelPolicy != 'AFTER_TTL' OR cardTtlMinutes IS NOT NULL) would be a good additional belt-and-braces defense in a follow-up migration.
  2. UnrecoverableError for missing intents. Without this, the queue's 5 configured attempts with exponential backoff will burn all retries on an intent that will never exist again. Fail fast, keep the logs clean, free up worker capacity.
  3. Loud failed listener. A permanently-live test card is a real money leak in production. The exhausted branch intentionally logs at error with "manual intervention required" so alerting can page on it.
  4. Module boundary. Moving the notification out of the orchestrator keeps grammy/InlineKeyboard isolated to src/telegram/. If a second notification channel is added later (e.g. email, SMS) the orchestrator will not need to change.
  5. Testability. The processor now exports handleCancelCardJob as a plain async function so unit tests can drive it without spinning up a real BullMQ Worker and Redis connection. The exported createCancelCardWorker is unchanged from the caller's perspective.

Out of scope

  • A DB-level CHECK constraint enforcing the AFTER_TTLcardTtlMinutes invariant (see point 1 above).
  • Adding an IPaymentProvider-style interface for telegram notifications in src/contracts/services.ts. The current direct import respects the boundary rule and keeps the diff surgical; a formal contract can be extracted when a second channel appears.

Summary by CodeRabbit

  • Bug Fixes

    • Improved cancel-policy handling to gracefully manage invalid or missing TTL values by canceling cards immediately instead of hanging.
    • Enhanced card-cancellation error handling with better retry logic for transient failures.
    • Fixed stale TTL state when switching between cancellation policies.
  • New Features

    • Manual card pending notifications now centralized through shared Telegram service.

Review Change Stack

@georgyia georgyia left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Self-review of the four fixes for #89. Reviewer: georgyia.

All four bugs are addressed end-to-end with regression tests. Inline annotations below explain the key decisions and the trade-offs I considered.

data: {
cancelPolicy: policy,
cardTtlMinutes: policy === CardCancelPolicy.AFTER_TTL ? undefined : null,
},

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Bug 1 fix — cardTtlMinutes cleared on policy switch.

Using undefined for AFTER_TTL (so we don't overwrite an existing TTL) and null for every other policy is intentional — it mirrors the invariant the PATCH /preferences Zod schema already enforces. This closes the gap where the Telegram path could persist AFTER_TTLIMMEDIATE with a stale TTL still in the DB.

Comment thread src/orchestrator/intentService.ts Outdated
} else if (cancelPolicy === 'MANUAL') {
await getPaymentProvider()
.freezeCard(intentId)
.catch((err) => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Bug 2 fix — no more silent no-op.

The old guard && cardTtlMinutes dropped the job whenever cardTtlMinutes was null, 0, or negative. Checkout has already succeeded by the time this runs, so refusing to cancel the card at this point only extends the window in which it is live. I chose a loud log.error + fallback to IMMEDIATE so the card is always neutralised; a DB CHECK constraint as a belt-and-braces defense would be a reasonable follow-up.

});
if (telegramChatId) {
await notifyManualCardPending(telegramChatId, intentId, intent.subject ?? intent.query);
await sendManualCardPendingNotice(telegramChatId, intentId, intent.subject ?? intent.query);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Bug 4 fix — module boundary restored.

notifyManualCardPending moved to src/telegram/notificationService.ts as sendManualCardPendingNotice. The orchestrator no longer imports getTelegramBot or InlineKeyboard from grammy. If a second notification channel is added later (email / SMS), the orchestrator won't need to change.

throw new UnrecoverableError(`Intent ${intentId} not found during AFTER_TTL cancel`);
}

// All other failures (Stripe 5xx, DB unreachable, …) are retried using

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Bug 3 fix — fail-fast on unrecoverable errors.

IntentNotFoundError will never succeed on retry, so wrapping it in BullMQ's UnrecoverableError stops the queue from burning all 5 attempts on a dead intent. Duck-typing on .name covers the case where a different Error subclass with the same name leaks from another module.

exhausted,
err,
},
exhausted

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Observability — loud failure when retries are exhausted.

A permanently-live virtual card is a real money leak. The exhausted branch logs "manual intervention required" at error level so alerting can page on it. The error listener on the next line catches worker-level failures (e.g. Redis disconnect) that never make it to a job.

Extracting handleCancelCardJob as a top-level export makes this unit-testable without Redis (see tests/unit/worker/cancelCardProcessor.test.ts).

);

it('IMMEDIATE policy cancels card synchronously', async () => {
mockIntent('IMMEDIATE', null);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The parameterized null | 0 | negative case is the direct regression test for the original report in #89. Before the fix these inputs silently skipped the cancel job; now they must all fall back to IMMEDIATE cancel.

@georgyia georgyia requested a review from JonasBaeumer April 21, 2026 22:55

@JonasBaeumer JonasBaeumer left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Solid PR — all four bugs from #89 are addressed and the test coverage is thorough. A few questions and small observations before merge.

Question on the duck-typed error check

src/worker/processors/cancelCardProcessor.ts:30

if (err instanceof IntentNotFoundError || (err as Error)?.name === 'IntentNotFoundError') {

IntentNotFoundError is a plain class extending Error from src/contracts/errors.ts. When does instanceof actually fail here? If this is guarding against a real case (module duplication, a serialization boundary, an error rebuilt from BullMQ's wire format), please add a one-line comment explaining the case so we don't lose the reasoning. If it's purely defensive, I'd drop the duck-type branch and the corresponding treats duck-typed IntentNotFoundError test — duck-typing on .name widens the surface that swallows retries, and a future renamed-but-similarly-named error could accidentally trip it.

Bug 1 follow-up: switching TO AFTER_TTL

src/telegram/menuHandler.ts:258-267

cardTtlMinutes: policy === CardCancelPolicy.AFTER_TTL ? undefined : null,

The undefined (Prisma no-op) is correct, but it means the sequence AFTER_TTL(60) → IMMEDIATE → AFTER_TTL leaves the user on AFTER_TTL with cardTtlMinutes === null (because the second hop cleared it). The Bug 2 fallback handles that safely, so this is not a regression — but it's a UX gap worth a follow-up issue for forcing TTL re-selection when switching back to AFTER_TTL. Not blocking.

Test flush pattern

tests/unit/orchestrator/cancelPolicy.test.ts:79-83

for (let i = 0; i < 10; i++) {
  await Promise.resolve();
}

This relies on the microtask depth never exceeding 10. If the orchestrator ever gains an additional await in the cancel-policy chain, the assertions will silently run against stale mocks and the test will pass for the wrong reason. Prefer one of: await new Promise(setImmediate), a flushPromises helper that loops until the microtask queue drains, or refactoring applyPostCheckoutCancelPolicy to return a promise so the test can await it directly. Minor, but worth tightening.

Suggested follow-up

The PR description already calls this out — please open a follow-up issue for the DB-level CHECK(cancelPolicy != 'AFTER_TTL' OR cardTtlMinutes IS NOT NULL) so the invariant is enforced at write time and we have belt-and-braces protection if a future code path ever updates User directly.

Things I liked

  • Module boundary cleanup (Bug 4) is exactly the right move — orchestrator no longer pulls in grammy.
  • Loud failed listener with manual intervention required wording — appropriate for a real money leak.
  • UnrecoverableError for IntentNotFoundError to stop burning retries on dead intents — good queue hygiene.
  • Parameterized .each for null/0/negative TTL — exactly the regression class the original bug allowed.

Happy to approve once the duck-type question is answered (either with a code comment or by trimming the branch).

@JonasBaeumer

Copy link
Copy Markdown
Owner

Opened #143 to track the DB-level CHECK constraint as a follow-up — flagging here so it isn't lost. Not a blocker for this PR; the runtime fallback you added covers the gap until the constraint lands.

Four related bugs could leave virtual cards permanently active in Stripe
after checkout on the AFTER_TTL cancel policy. This change plugs all of
them and adds regression tests.

1. src/telegram/menuHandler.ts `savePrefPolicy` now clears
   `cardTtlMinutes` when switching to any policy other than AFTER_TTL,
   restoring the invariant enforced by the PATCH /preferences Zod schema.

2. src/orchestrator/intentService.ts `applyPostCheckoutCancelPolicy` no
   longer silently no-ops when `cancelPolicy === AFTER_TTL` but
   `cardTtlMinutes` is null or non-positive. It now logs at error level
   and falls back to IMMEDIATE cancellation so no card outlives checkout.

3. src/worker/processors/cancelCardProcessor.ts gains try/catch with
   structured logging, wraps IntentNotFoundError in BullMQ's
   UnrecoverableError to stop pointless retries, and attaches `failed`
   and `error` listeners that escalate loudly when retries are
   exhausted (potential money leak). The handler is also exported
   independently so it can be unit tested without Redis. Uses
   getProviderForIntent(intentId) so the correct payment provider is used.

4. `notifyManualCardPending` moves from the orchestrator to
   src/telegram/notificationService.ts as `sendManualCardPendingNotice`.
   The orchestrator now imports it via the telegram module interface and
   no longer pulls in grammy or telegramClient directly, respecting the
   module boundary rule in CLAUDE.md.

Tests:
- tests/unit/telegram/menuHandler.test.ts asserts cardTtlMinutes is
  cleared on every non-AFTER_TTL policy switch.
- tests/unit/orchestrator/cancelPolicy.test.ts covers all four cancel
  policies including the null / zero / negative TTL fallback.
- tests/unit/worker/cancelCardProcessor.test.ts covers happy path,
  transient retry, and unrecoverable error behaviour.
- tests/unit/orchestrator/postCheckoutCancelPolicy.test.ts updated for
  null cardTtlMinutes under AFTER_TTL (immediate cancel).

Rebased onto main (per-user paymentProvider + getProviderForIntent). Review
follow-up: drop duck-typed IntentNotFoundError name check; use setImmediate
flush in cancelPolicy tests.

405/405 unit tests pass locally (tests/unit).

Closes #89
@georgyia georgyia force-pushed the fix/issue-89-after-ttl-cancel-policy branch from 30bc04c to 4377bbc Compare May 12, 2026 13:44
@georgyia

Copy link
Copy Markdown
Collaborator Author

Rebased onto main + review follow-up

  • Resolved conflicts with current orchestrator work: applyPostCheckoutCancelPolicy now uses per-user getPaymentProvider(paymentProvider) for cancel/freeze, keeps the AFTER_TTL invariant (null / ≤0 → error log + immediate provider.cancelCard), and still enqueues delayed cancel only when cardTtlMinutes > 0.
  • handleCancelCardJob uses getProviderForIntent(intentId) (same as main’s worker direction) with try/catch, UnrecoverableError only for instanceof IntentNotFoundError (removed .name duck-typing per review).
  • Tests: cancelPolicy.test.ts flushes fire-and-forget work with setImmediate; postCheckoutCancelPolicy.test.ts null AFTER_TTL case now expects immediate cancel (aligned with bug: AFTER_TTL cancel policy silently no-ops — cards may remain active after checkout #89); processor tests mock getProviderForIntent.
  • 405/405 tests/unit green locally.

Ready for another look when you have time.

@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR fixes four interconnected bugs in the AFTER_TTL card cancel policy (issue #89). It refactors cancel-policy handling to validate and fall back on invalid TTL values, centralizes Telegram notifications to restore module boundaries, clears stale TTL state on policy switches, adds proper error handling to the cancel-card job processor, and provides comprehensive regression test coverage.

Changes

Card Cancel Policy and Job Processing

Layer / File(s) Summary
Telegram notification service extraction
src/telegram/notificationService.ts
New sendManualCardPendingNotice function sends Telegram notifications for manual card freezes with cancel-now callback support, centralizing Telegram delivery logic previously inline in the orchestrator.
Post-checkout cancel-policy refactoring
src/orchestrator/intentService.ts
Imports and uses the new shared sendManualCardPendingNotice; refactors AFTER_TTL branch to log an invariant violation and fall back to immediate cancellation when cardTtlMinutes is missing/non-positive, instead of silently skipping. Removes direct Telegram client dependency.
User preference TTL cleanup
src/telegram/menuHandler.ts, tests/unit/telegram/menuHandler.test.ts
savePrefPolicy now clears orphaned cardTtlMinutes when switching away from AFTER_TTL and updates it when selecting AFTER_TTL, preventing stale TTL state; updated tests verify both IMMEDIATE and policy-switch scenarios.
Cancel card job processor error handling
src/worker/processors/cancelCardProcessor.ts, tests/unit/worker/cancelCardProcessor.test.ts
Extracts handleCancelCardJob as a testable exported function with differentiated error handling: IntentNotFoundError triggers UnrecoverableError to stop retries, while transient errors are logged with context and rethrown for backoff retry. Tests validate happy path, transient failures, and unrecoverable failures.
Comprehensive cancel-policy regression test suite
tests/unit/orchestrator/cancelPolicy.test.ts, tests/unit/orchestrator/postCheckoutCancelPolicy.test.ts
New 174-line test suite covers all cancel-policy modes (AFTER_TTL, IMMEDIATE, MANUAL, ON_TRANSACTION) with edge cases: positive/null/negative TTL values, Telegram notification presence, and virtual card existence. Existing test updated to assert immediate fallback behavior for null TTL instead of silent no-op.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • JonasBaeumer/AgentWallet#93: This PR adds comprehensive unit test coverage for applyPostCheckoutCancelPolicy behavior across all cancel-policy modes and edge cases, directly addressing the missing test coverage identified in that issue.
  • JonasBaeumer/AgentWallet#143: This PR implements application-level protections around the cancelPolicycardTtlMinutes invariant by handling missing/invalid TTLs in applyPostCheckoutCancelPolicy and clearing stale TTLs in menuHandler, addressing the same invariant that issue proposes enforcing at the database level.

Possibly related PRs

  • JonasBaeumer/AgentWallet#155: Both PRs modify post-checkout cancel-policy orchestration and Telegram notification integration in src/orchestrator/intentService.ts and job processor logic in src/worker/processors/cancelCardProcessor.ts, indicating overlapping feature areas.

Poem

🐰 A rabbit hops through cancel states,
fixing bugs that sealed our fates—
TTL gone, no silent skip,
proper retries, no crashed trip,
tests now guard each edge case bright! 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main objective of fixing AFTER_TTL cancel policy loopholes, which is the primary focus across all changed files.
Linked Issues check ✅ Passed All four coding requirements from issue #89 are met: savePrefPolicy clears cardTtlMinutes [#89], AFTER_TTL null/non-positive values trigger fallback cancellation [#89], cancelCardProcessor has error handling and retry semantics [#89], and notifyManualCardPending moved to telegram module [#89].
Out of Scope Changes check ✅ Passed All changes are directly aligned with closing the four AFTER_TTL loopholes identified in issue #89; no unrelated modifications detected.

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

✨ 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 fix/issue-89-after-ttl-cancel-policy

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

@georgyia georgyia left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verification (local)

  • npx prisma generate && npx tsc --noEmit — clean
  • npm test -- --testPathPattern=tests/unit405/405 passed
  • tests/integration/telegram/menuHandler — not run here (Docker unavailable); rely on CI for that job.

Review

applyPostCheckoutCancelPolicy: Integrates cleanly with current main (paymentProvider + getPaymentProvider). AFTER_TTL only enqueues when cardTtlMinutes != null && cardTtlMinutes > 0; otherwise logs at error and immediate provider.cancelCard — removes the dangerous silent no-op without blocking checkout.

handleCancelCardJob: getProviderForIntent(intentId) is consistent with the rest of the app; IntentNotFoundErrorUnrecoverableError only via instanceof (no duck-typing). Exhausted-retry logging is appropriate for a potential live-card leak.

Telegram savePrefPolicy: Clearing TTL when switching off AFTER_TTL matches the API invariant.

sendManualCardPendingNotice: Good module-boundary fix (orchestrator no longer imports grammy).

Tests: setImmediate flush and updated postCheckoutCancelPolicy null case look correct.

Cannot self-approve on GitHub; posting as comment. Jonas: please approve/merge when satisfied.

Optional follow-up (non-blocking): UX when switching back to AFTER_TTL after an intermediate policy cleared TTL (user may need to re-select TTL) — separate issue if you want it tracked.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@src/telegram/notificationService.ts`:
- Around line 8-24: The Number() conversion of telegramChatId in
sendManualCardPendingNotice can produce NaN for invalid strings; validate and
guard the chat ID before calling bot.api.sendMessage by parsing/validating
telegramChatId (e.g., parseInt or regex to ensure a signed integer) and if
invalid, log a clear error including intentId and the raw telegramChatId and
return early; keep the existing try/catch around the send but move the
validation before creating the InlineKeyboard and calling bot.api.sendMessage so
the log distinguishes formatting errors from API/network failures.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: af42de7b-c4a1-4c85-9207-2e80e937f8bb

📥 Commits

Reviewing files that changed from the base of the PR and between 4980290 and 4377bbc.

📒 Files selected for processing (8)
  • src/orchestrator/intentService.ts
  • src/telegram/menuHandler.ts
  • src/telegram/notificationService.ts
  • src/worker/processors/cancelCardProcessor.ts
  • tests/unit/orchestrator/cancelPolicy.test.ts
  • tests/unit/orchestrator/postCheckoutCancelPolicy.test.ts
  • tests/unit/telegram/menuHandler.test.ts
  • tests/unit/worker/cancelCardProcessor.test.ts

Comment on lines +8 to +24
export async function sendManualCardPendingNotice(
telegramChatId: string,
intentId: string,
label: string,
): Promise<void> {
try {
const bot = getTelegramBot();
const keyboard = new InlineKeyboard().text('Cancel Card Now', `menu_card_cancel:${intentId}`);
await bot.api.sendMessage(
Number(telegramChatId),
`Checkout complete for "${label}".\n\nYour virtual card is frozen. Tap below when you no longer need it.`,
{ reply_markup: keyboard },
);
} catch (err) {
log.error({ intentId, err }, 'Failed to send MANUAL card notification');
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider guarding the Number() conversion.

If telegramChatId contains an invalid numeric string, Number(telegramChatId) will produce NaN, which the Telegram API will reject. Since the error is caught and logged, this won't crash the worker, but the log entry won't make it obvious that the root cause was an invalid chat ID format rather than a network/API issue.

🛡️ Proposed guard for invalid chat ID
 export async function sendManualCardPendingNotice(
   telegramChatId: string,
   intentId: string,
   label: string,
 ): Promise<void> {
   try {
+    const chatIdNum = Number(telegramChatId);
+    if (!Number.isFinite(chatIdNum)) {
+      log.error({ intentId, telegramChatId }, 'Invalid telegramChatId format');
+      return;
+    }
     const bot = getTelegramBot();
     const keyboard = new InlineKeyboard().text('Cancel Card Now', `menu_card_cancel:${intentId}`);
     await bot.api.sendMessage(
-      Number(telegramChatId),
+      chatIdNum,
       `Checkout complete for "${label}".\n\nYour virtual card is frozen. Tap below when you no longer need it.`,
       { reply_markup: keyboard },
     );
   } catch (err) {
     log.error({ intentId, err }, 'Failed to send MANUAL card notification');
   }
 }
🤖 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 `@src/telegram/notificationService.ts` around lines 8 - 24, The Number()
conversion of telegramChatId in sendManualCardPendingNotice can produce NaN for
invalid strings; validate and guard the chat ID before calling
bot.api.sendMessage by parsing/validating telegramChatId (e.g., parseInt or
regex to ensure a signed integer) and if invalid, log a clear error including
intentId and the raw telegramChatId and return early; keep the existing
try/catch around the send but move the validation before creating the
InlineKeyboard and calling bot.api.sendMessage so the log distinguishes
formatting errors from API/network failures.

@georgyia

Copy link
Copy Markdown
Collaborator Author

Self-review pass complete — ready for maintainer review.

What I checked:

  • Re-read the AFTER_TTL path through completeCheckout() / applyPostCheckoutCancelPolicy() and cancelCardProcessor.
  • Confirmed no console.*, TODO, or FIXME was introduced in the changed orchestrator/worker files.
  • Regenerated Prisma Client first; the targeted local suite initially failed due to stale generated Prisma enums, then passed after npx prisma generate.
  • Local verification: npx tsc --noEmit -p tsconfig.json clean; npm test -- --testPathPattern="cancel|ttl|policy" passed (4 suites, 23 tests).
  • GitHub CI remains green across Lint, Type Check, Unit, Integration, CodeQL, and CodeRabbit.

Suggested review focus: the fallback behavior for invalid/missing cardTtlMinutes under AFTER_TTL, and the decision to keep post-checkout cancel-policy errors non-blocking while logging them.

@georgyia georgyia requested a review from JonasBaeumer May 13, 2026 18:11
georgyia added a commit that referenced this pull request May 13, 2026
…utes invariant (#147)

* fix(issue-143): add CHECK constraint enforcing AFTER_TTL ↔ cardTtlMinutes

Adds a Postgres CHECK constraint so the database itself rejects any User
row where cancelPolicy = AFTER_TTL but cardTtlMinutes is NULL or non-positive.
This is defense-in-depth on top of the application-level handling added in
PR #136 — it closes the gap that any future code path writing User directly
(scripts, ad-hoc updates, new endpoints, typos in another module) could
re-introduce the broken state.

The migration backfills any existing AFTER_TTL rows missing a TTL with the
60-minute default already used by the Telegram menu, so the migration is
safe to run on existing data.

Closes #143

* 📝 CodeRabbit Chat: Implement requested code changes

* test(issue-143): fix accepts-valid-states test wrapper

Wrap the AFTER_TTL positive TTL case in an it() block so the integration test is syntactically valid and actually executes.

* test(issue-143): add atomic switch-to-AFTER_TTL acceptance case

Symmetric to the existing 'switch away from AFTER_TTL' test, this asserts
the CHECK constraint accepts an atomic update that flips cancelPolicy to
AFTER_TTL while setting a positive cardTtlMinutes in the same statement.

Pins the intended behaviour against future migrations or trigger-based
rewrites that might evaluate the constraint mid-update.

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Jonas <71970468+JonasBaeumer@users.noreply.github.com>
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.

bug: AFTER_TTL cancel policy silently no-ops — cards may remain active after checkout

2 participants