Skip to content

✨️ server: add activity and push notification on card decline#622

Open
aguxez wants to merge 4 commits intomainfrom
card-declined-events
Open

✨️ server: add activity and push notification on card decline#622
aguxez wants to merge 4 commits intomainfrom
card-declined-events

Conversation

@aguxez
Copy link
Contributor

@aguxez aguxez commented Jan 7, 2026

closes #114

Summary by CodeRabbit

  • New Features

    • Push notifications for declined transactions and insufficient-funds events.
    • Activity feed now records declined transactions and "requested" actions with status, reason, timestamps, merchant/icon, and transaction details.
    • User-facing handling for frozen or non-active cards with clear responses.
  • Bug Fixes

    • Fixed a key typo.
  • Tests

    • Added/updated tests for declined-transaction flows, push notifications, and activity serialization.
  • Chores

    • Version/changeset updates.

Open with Devin

Greptile Summary

This PR wires up activity-feed entries and push notifications for declined card transactions. When Panda delivers a requested webhook for a frozen/inactive card, or when a created/updated webhook carries status: "declined", the new reject() helper inserts (or appends to) a transaction record with a zero-hash placeholder and sends a targeted push notification for recognized decline reasons (insufficient_funds, merchant_blocked, frozen_card). The PandaActivity schema is extended to parse these records on the activity-feed API side.

Key changes:

  • New reject() function in panda.ts uses PostgreSQL jsonb_set to append declined bodies to existing transaction rows and gates push notifications via xmax = 0 (first-insert detection).
  • PandaActivity transformer normalizes "requested" to "created" and spreads status/reason onto the output when a declined body is found; the settled field is removed (no consumers found in the codebase).
  • Frozen-card and non-active card states now receive distinct trackAuthorizationRejected calls in the requested handler; only the frozen-card path calls reject().
  • captureException context for bad transactions is improved by flattening valibot issues instead of passing raw parse objects.

Issues found:

  • mapped.find(b => b.status === "declined") picks the first declined body, so when multiple declined entries accumulate for the same transaction (e.g., a created decline followed by an updated decline with a different reason), the stale earlier reason is shown to the user. findLast would show the most recent.
  • reject() is called in the generic unexpected-error path (line 469) regardless of whether the error was issuing-network related. Any unhandled server error will produce a reason: "transaction declined" activity record that is indistinguishable from a real network decline to the user, though no push notification is sent.

Confidence Score: 3/5

  • Mostly safe to merge with two moderate logic issues: stale decline reasons shown to users and misleading "transaction declined" entries for server errors.
  • The core notification and activity-feed logic is solid with good test coverage. However, two verified logic issues warrant attention: (1) find() instead of findLast() will surface stale decline reasons when multiple declines accumulate for a transaction, and (2) unexpected server errors now create permanent "transaction declined" activity records indistinguishable from real declines to users. Neither causes data loss or security issues, but both degrade UX and observability. The issues are straightforward to fix and should be addressed before or immediately after merge.
  • server/api/activity.ts (findLast fix) and server/hooks/panda.ts (unexpected-error handling)

Sequence Diagram

sequenceDiagram
    participant Panda
    participant Hook as panda.ts hook
    participant DB as transactions DB
    participant Push as Push Notification

    Panda->>Hook: POST requested (card spend attempt)
    Hook->>DB: query card by id (fetch status)
    alt card FROZEN
        Hook->>DB: reject() → INSERT/APPEND declined body (frozen_card)
        Hook->>Push: sendDeclinedNotification (if isNew)
        Hook-->>Panda: 403 frozen card
    else card not ACTIVE
        Hook-->>Panda: 403 card not active (no reject() call)
    else card ACTIVE
        Hook->>Hook: risk assess + prepareCollection
        alt PandaError (e.g. InsufficientAccountLiquidity)
            Hook->>DB: reject() → INSERT/APPEND declined body
            Hook->>Push: sendDeclinedNotification (if isNew & notify)
            Hook-->>Panda: 557 error code
        else unexpected error
            Hook->>DB: reject() → INSERT/APPEND declined body (reason: "transaction declined")
            Hook-->>Panda: 569 ouch
        else success
            Hook-->>Panda: 200 ok
        end
    end

    Panda->>Hook: POST created/updated (status: declined)
    Hook->>DB: await reject() → INSERT/APPEND declined body
    Hook->>Push: sendDeclinedNotification (if isNew & notify)
    Hook-->>Panda: 200 ok

    note over DB,Push: isNew = (xmax = 0) — only first insert fires notification
Loading

Last reviewed commit: dd260a8

@changeset-bot
Copy link

changeset-bot bot commented Jan 7, 2026

🦋 Changeset detected

Latest commit: 86b52cf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@exactly/server Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link

coderabbitai bot commented Jan 7, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds server-side decline handling: classifies decline reasons, persists declined transaction bodies, routes frozen/non-active/error flows through new decline handlers, triggers push notifications for select decline types, extends activity schema to include "requested"/declined fields, and adds tests for declines and notifications.

Changes

Cohort / File(s) Summary
Changesets
.changeset/honest-peas-stand.md, .changeset/six-dancers-hang.md
Add two changeset files bumping @exactly/server (patch); metadata only.
Decline Handling Core
server/hooks/panda.ts
Add DECLINE_REASONS and NOTIFICATION_TRIGGERING_REASONS; new helpers (getDeclineReason, handleDeclinedTransaction, updateTransactionRecord, sendDeclinedNotification, handleRejectedTransaction*); integrate decline flow for requested/created/error paths; persist declined bodies; handle frozen/non-active card flows and route errors through decline handlers.
Activity Schema & Transform
server/api/activity.ts
Extend PandaActivity bodies to accept requested, optional status/reason, nested body.spend with enrichedMerchantIcon; normalize "requested" to "created"; propagate merchant icon and single timestamp; add declined-path shaping. CardActivity action set now includes requested.
Decline & Notification Tests
server/test/hooks/panda.test.ts
Add OneSignal import and push-notification tests; replace balance checks with maxWithdraw; safer transaction receipt handling; insert declined-transaction fixtures and assert appended bodies and pushes.
Activity Tests
server/test/api/activity.test.ts
Add httpSerialize/removeUndefined helpers; refactor logs/borrows gathering; adjust test payload shapes and assertions to use serialized comparisons.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant CardSystem as Card System
    participant PandaHook as Panda Hook
    participant DB as Database
    participant Notif as Notification Service

    Client->>CardSystem: submit transaction
    CardSystem->>PandaHook: webhook / payload
    PandaHook->>DB: fetch card & transaction
    alt card FROZEN or not ACTIVE
        DB-->>PandaHook: card status != ACTIVE
        PandaHook->>PandaHook: getDeclineReason()
        PandaHook->>PandaHook: handleRejectedTransactionSync()
        PandaHook-->>CardSystem: 403 / decline response
    else processing error or explicit decline
        PandaHook->>PandaHook: getDeclineReason()
        PandaHook->>DB: updateTransactionRecord(...) with declined body
        DB-->>PandaHook: OK
        PandaHook->>PandaHook: handleDeclinedTransaction()
        PandaHook->>Notif: sendDeclinedNotification()
        Notif-->>Client: push notification delivered
    else success
        PandaHook-->>CardSystem: success response
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • nfmelendez
  • cruzdanilo
  • franm91
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately describes the main changes: adding activity tracking and push notification functionality for card decline events.
Linked Issues check ✅ Passed The PR addresses both objectives from issue #114: storing activity records on card decline and sending push notifications on specific decline reasons.
Out of Scope Changes check ✅ Passed All changes are within scope: core decline handling in panda.ts, activity API expansion in activity.ts, and corresponding tests. Two changeset files document the changes appropriately.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch card-declined-events

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.

@gemini-code-assist
Copy link

Summary of Changes

Hello @aguxez, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request enhances the server's transaction handling by introducing a mechanism to process declined card transactions. The immediate user-facing change is the implementation of push notifications, which will inform users instantly about rejected purchases. Although the foundational code for recording these declined transactions as user activity has been added, this specific database logging functionality is temporarily disabled, pending the development of corresponding user interface elements.

Highlights

  • New Transaction Handling: Implemented a new handleDeclinedTransaction function to process card rejections within the panda hook.
  • Push Notifications for Declines: Enabled push notifications for users when their card transactions are declined, providing immediate feedback on rejected purchases.
  • Deferred Activity Logging: Prepared the server-side logic for logging declined transactions as activity in the database, though this feature is currently commented out and awaiting UI implementation.
  • Test Coverage for Future Features: Added it.todo test cases to cover the future enablement of declined transaction activity logging, ensuring proper functionality once the UI is ready.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

gemini-code-assist[bot]

This comment was marked as resolved.

Copy link

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

🤖 Fix all issues with AI agents
In @server/hooks/panda.ts:
- Around line 955-998: Remove the large commented-out DB logic inside
handleDeclinedTransaction and track the work in your issue tracker: create an
issue describing the pending UI changes needed to handle declined transactions
and include its ID; then replace the commented block with a single-line comment
in handleDeclinedTransaction referencing that issue (e.g., "See ISSUE-1234:
enable declined-transaction persistence once UI supports it"). Ensure the rest
of the function (push notification and error capture) remains unchanged.
- Line 965: Replace the existing comment "// TODO: Enable once UI has proper
designs to handle declined transactions in activity" with the coding-guideline
compliant format: use uppercase tag, a single space, and a fully lowercase
comment body (e.g. "// TODO enable once ui has proper designs to handle declined
transactions in activity"); update the line containing that TODO comment in the
server/hooks/panda.ts hook to remove the colon and capitalize only the TODO tag.
📜 Review details

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0860357 and 118a340.

📒 Files selected for processing (3)
  • .changeset/honest-peas-stand.md
  • server/hooks/panda.ts
  • server/test/hooks/panda.test.ts
🧰 Additional context used
📓 Path-based instructions (7)
**/.changeset/*.md

📄 CodeRabbit inference engine (.cursor/rules/style.mdc)

Use a lowercase sentence in the imperative present tense for changeset summaries

Files:

  • .changeset/honest-peas-stand.md
server/**/*.ts

📄 CodeRabbit inference engine (.cursor/rules/server.mdc)

server/**/*.ts: Use c.var object to pass strongly-typed data between Hono middleware and route handlers; do not use c.set
All request validation (headers, body, params) must be handled by @hono/valibot-validator middleware; do not perform manual validation inside route handlers
Use Hono's built-in error handling by throwing new HTTPException() for expected errors; unhandled errors will be caught and logged automatically
Enforce Node.js best practices using ESLint plugin:n/recommended configuration
Enforce Drizzle ORM best practices using ESLint plugin:drizzle/all configuration, including requiring where clauses for update and delete operations
Use Drizzle ORM query builder for all database interactions; do not write raw SQL queries unless absolutely unavoidable
All authentication and authorization logic must be implemented in Hono middleware
Do not access process.env directly in application code; load all configuration and secrets once at startup and pass them through dependency injection or context
Avoid long-running, synchronous operations; use async/await correctly and be mindful of CPU-intensive tasks to prevent blocking the event loop

Files:

  • server/hooks/panda.ts
  • server/test/hooks/panda.test.ts
**/*.{js,ts,tsx,jsx,sol}

📄 CodeRabbit inference engine (AGENTS.md)

Follow linter/formatter (eslint, prettier, solhint) strictly with high strictness level. No any type.

Files:

  • server/hooks/panda.ts
  • server/test/hooks/panda.test.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Omit redundant type names in variable declarations - let the type system explain itself

**/*.{ts,tsx}: Use PascalCase for TypeScript types and interfaces
Use valibot for all runtime validation of API inputs, environment variables, and other data; define schemas once and reuse them
Infer TypeScript types from valibot schemas using type User = v.Input<typeof UserSchema> instead of manually defining interfaces

Files:

  • server/hooks/panda.ts
  • server/test/hooks/panda.test.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: Omit contextual names - don't repeat class/module names in members
Omit meaningless words like 'data', 'state', 'manager', 'engine', 'value' from variable and function names unless they add disambiguation

**/*.{ts,tsx,js,jsx}: Prefer function declarations for all multi-line functions; use function expressions or arrow functions only for single-line implementations
Prefer const for all variable declarations by default; only use let if the variable's value will be reassigned
Declare each variable on its own line with its own const or let keyword, not multiple declarations on one line
Use camelCase for TypeScript variables and functions
Always use import type { ... } for type imports
Use relative paths for all imports within the project; avoid tsconfig path aliases
Follow eslint-plugin-import order: react, external libraries, then relative paths
Use object and array destructuring to access and use properties
Use object method shorthand syntax when a function is a property of an object
Prefer optional chaining (?.), nullish coalescing (??), object and array spreading (...), and for...of loops over traditional syntax
Do not use abbreviations or cryptic names; write out full words like error, parameters, request instead of err, params, req
Use Number.parseInt() instead of the global parseInt() function when parsing numbers
All classes called with new must use PascalCase
Use Buffer.from(), Buffer.alloc(), or Buffer.allocUnsafe() instead of the deprecated new Buffer()
Use @ts-expect-error instead of @ts-ignore; follow it immediately with a single-line lowercase comment explaining why the error is expected, without separators like - or :
Do not include the type in a variable's name; let the static type system do its job (e.g., use const user: User not const userObject: User)
Do not repeat the name of a class or module within its members; omit contextual names (e.g., use `class User { getProfil...

Files:

  • server/hooks/panda.ts
  • server/test/hooks/panda.test.ts
server/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

server/**/*.{ts,tsx}: Server API: implement schema-first approach using OpenAPI via hono with validation via valibot middleware
Server database: drizzle schema is source of truth. Migrations required. No direct database access in handlers - use c.var.db

Files:

  • server/hooks/panda.ts
  • server/test/hooks/panda.test.ts
**/*.ts

📄 CodeRabbit inference engine (.cursor/rules/style.mdc)

For files with a single default export, name the file identically to the export; for files with multiple exports, use camelCase with a strong preference for a single word

Files:

  • server/hooks/panda.ts
  • server/test/hooks/panda.test.ts
🧠 Learnings (2)
📚 Learning: 2025-12-31T00:23:55.034Z
Learnt from: cruzdanilo
Repo: exactly/exa PR: 610
File: .changeset/ready-experts-fly.md:1-2
Timestamp: 2025-12-31T00:23:55.034Z
Learning: In the exactly/exa repository, allow and require empty changeset files (containing only --- separators) when changes are not user-facing and do not warrant a version bump. This is needed because CI runs changeset status --since origin/main and requires a changeset file to exist. Ensure such empty changesets are used only for non-user-facing changes and document the rationale in the commit or changelog notes.

Applied to files:

  • .changeset/honest-peas-stand.md
📚 Learning: 2025-12-23T19:58:16.574Z
Learnt from: CR
Repo: exactly/exa PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-23T19:58:16.574Z
Learning: Zero config local dev environment: no `.env` files, mock all external services

Applied to files:

  • server/test/hooks/panda.test.ts
🧬 Code graph analysis (2)
server/hooks/panda.ts (1)
server/utils/onesignal.ts (1)
  • sendPushNotification (7-25)
server/test/hooks/panda.test.ts (1)
server/database/schema.ts (1)
  • transactions (36-43)
🔇 Additional comments (5)
server/test/hooks/panda.test.ts (2)

2-2: LGTM!

Import adjustments for mocks are clean and improve organization.

Also applies to: 7-7


1352-1439: Test stubs properly scaffolded for future implementation.

The two test cases for declined transaction handling are well-structured and align with the commented-out database logic in server/hooks/panda.ts. Using it.todo is appropriate while waiting for UI designs.

server/hooks/panda.ts (2)

533-533: LGTM!

The call to handleDeclinedTransaction is correctly placed after the mutex is released, and the type cast is necessary due to TypeScript's union type handling.


988-998: LGTM!

Push notification implementation properly formats the transaction details and includes error handling consistent with the rest of the codebase.

.changeset/honest-peas-stand.md (1)

1-5: LGTM!

Changeset properly documents the feature addition with appropriate version bump and clear description.

@aguxez aguxez force-pushed the card-declined-events branch from 118a340 to dc0ac8a Compare January 7, 2026 18:15
@sentry
Copy link

sentry bot commented Jan 7, 2026

Codecov Report

❌ Patch coverage is 72.16495% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.97%. Comparing base (05716e4) to head (86b52cf).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
server/api/activity.ts 74.57% 5 Missing and 10 partials ⚠️
server/hooks/panda.ts 68.42% 8 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #622      +/-   ##
==========================================
+ Coverage   71.13%   71.97%   +0.84%     
==========================================
  Files         211      211              
  Lines        8355     8629     +274     
  Branches     2724     2871     +147     
==========================================
+ Hits         5943     6211     +268     
- Misses       2133     2135       +2     
- Partials      279      283       +4     
Flag Coverage Δ
e2e 65.68% <50.51%> (-1.41%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

coderabbitai[bot]

This comment was marked as resolved.

@aguxez aguxez force-pushed the card-declined-events branch 3 times, most recently from 2b6c4a8 to 64325f3 Compare January 12, 2026 14:37
sentry[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

@aguxez aguxez force-pushed the card-declined-events branch from 64325f3 to b3814d0 Compare January 13, 2026 11:09
coderabbitai[bot]

This comment was marked as resolved.

@aguxez aguxez force-pushed the card-declined-events branch from b3814d0 to 8c11c8c Compare January 13, 2026 14:49
coderabbitai[bot]

This comment was marked as resolved.

@aguxez aguxez force-pushed the card-declined-events branch from 8c11c8c to 77c26c2 Compare January 14, 2026 13:30
coderabbitai[bot]

This comment was marked as resolved.

@aguxez aguxez force-pushed the card-declined-events branch 2 times, most recently from 98e690f to a16d92d Compare January 15, 2026 22:41
@aguxez aguxez force-pushed the card-declined-events branch from a16d92d to 685fbf3 Compare January 19, 2026 12:04
coderabbitai[bot]

This comment was marked as resolved.

@aguxez aguxez force-pushed the card-declined-events branch from 685fbf3 to 4bcf82f Compare January 19, 2026 17:03
coderabbitai[bot]

This comment was marked as resolved.

@aguxez aguxez force-pushed the card-declined-events branch from 4bcf82f to bbacd86 Compare January 22, 2026 14:15
Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 4 additional flags.

Open in Devin Review

devin-ai-integration[bot]

This comment was marked as resolved.

greptile-apps[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

greptile-apps[bot]

This comment was marked as resolved.

greptile-apps[bot]

This comment was marked as resolved.

@greptile-apps
Copy link

greptile-apps bot commented Mar 4, 2026

Additional Comments (1)

server/api/activity.ts
find() returns first declined body — may surface a stale reason when multiple declines are stored

mapped.find(...) returns the first body where status === "declined" and body !== undefined. In the scenario where the requested-path reject() fires first (e.g. frozen card or InsufficientAccountLiquidity) and then Panda delivers a created webhook with a different decline reason, the stored bodies array will contain two declined entries. The activity feed will always show the first one's reason, even if the second entry carries a more specific or more recent reason provided directly by the card network.

Using findLast() would consistently surface the most recent decline event:

    const declined = mapped.findLast((b) => b.status === "declined" && b.body !== undefined);

greptile-apps[bot]

This comment was marked as resolved.

@greptile-apps
Copy link

greptile-apps bot commented Mar 4, 2026

Additional Comments (1)

server/hooks/panda.ts
reject() fires for "tx reverted" despite existing intent to exclude it

The pre-existing guard at line 452 explicitly skips trackAuthorizationRejected for "tx reverted":

error.message !== "tx reverted" && trackAuthorizationRejected(...)

This signals that "tx reverted" was intentionally treated differently from genuine card declines — it is a blockchain-level execution failure, not a card network rejection. However, the new reject() call only excludes "Replay", so "tx reverted" will now create a "declined" activity record with reason: "transaction declined" (since "tx reverted" matches no entry in declineReasons).

The user will see a phantom "transaction declined" entry in their activity feed for what is actually an on-chain technical failure. To stay consistent with the existing exclusion, add "tx reverted" to the guard:

            error.message !== "tx reverted" &&
              trackAuthorizationRejected(account, payload, card.mode, card.credential.source, "panda-error");
            if (error.statusCode !== (557 as UnofficialStatusCode)) {
              captureException(error, { level: "error", tags: { unhandled: true } });
            }

            if (error.message !== "Replay" && error.message !== "tx reverted") {
              reject(account, payload, jsonBody, error.message).catch((error_: unknown) =>
                captureException(error_, { level: "error" }),
              );
            }

greptile-apps[bot]

This comment was marked as resolved.

@greptile-apps
Copy link

greptile-apps bot commented Mar 4, 2026

Additional Comments (1)

server/test/hooks/panda.test.ts
Misleading test description

The test title says "sends notification when declined transaction is completed", but the webhook payload uses action: "created" — not "completed". This will confuse future readers trying to understand what scenario is being tested.

    it("sends notification when declined transaction is created", async () => {

devin-ai-integration[bot]

This comment was marked as resolved.

greptile-apps[bot]

This comment was marked as resolved.

@greptile-apps
Copy link

greptile-apps bot commented Mar 4, 2026

Additional Comments (1)

server/test/hooks/panda.test.ts
Missing push notification test for the frozen-card path

The new push notification tests cover InsufficientAccountLiquidity (via the PandaError path) and merchant_blocked/insufficient_funds (via the created/updated declined webhook path), but there is no test verifying that a push notification is fired when a requested webhook arrives for a frozen card.

The frozen-card branch added in panda.ts (line ~266) calls:

reject(account, payload, jsonBody, "frozen_card").catch(...)

declineReasons["frozen_card"] resolves to { reason: "frozen card", notify: true }, so a notification should be sent when isNew === true. However, the fire-and-forget invocation means the response can return before the notification is dispatched — exactly the race condition the other test works around with vi.waitFor(). Without a test, this code path is exercised only incidentally.

Consider adding a test along the lines of the InsufficientAccountLiquidity case that:

  1. Posts a requested webhook for a card whose status is FROZEN.
  2. Uses await vi.waitFor(() => expect(sendPushNotificationSpy).toHaveBeenCalled()) to wait for the fire-and-forget call.
  3. Asserts the notification content includes the correct merchant name and "frozen card" reason.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

greptile-apps[bot]

This comment was marked as resolved.

sentry[bot]

This comment was marked as resolved.

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

sentry[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

sentry[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

sentry[bot]

This comment was marked as resolved.

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 3 new potential issues.

View 12 additional findings in Devin Review.

Open in Devin Review

Comment on lines +598 to +599
case "requested":
return f;

Choose a reason for hiding this comment

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

🔴 PandaActivity parser throws 'invalid flow' for declined transactions initiated during 'requested' webhooks

When a transaction is declined during the "requested" webhook (e.g., frozen card at server/hooks/panda.ts:266, InsufficientAccountLiquidity at server/hooks/panda.ts:457), the reject() function stores a body with action: "requested" as bodies[0] with hashes: [zeroHash]. When the activity API later reads this transaction, the PandaActivity transform maps hashes[0] to bodies[0], producing a single operation with action: "requested". The flow reducer (line 598-599) explicitly skips "requested" actions (return f), so flow.created and flow.completed remain undefined. Line 609 then throws "invalid flow", causing safeParse to fail. The declined transaction never appears in the activity response.

Even if Panda subsequently sends a "created" declined webhook and reject() appends a second body via onConflictDoUpdate, the hashes array is never extended (it stays [zeroHash]), so hashes.map() only processes bodies[0] (the "requested" body). The "created" body at index 1 is never reached.

Test data masks the bug with wrong body ordering

The test at server/test/api/activity.test.ts:179-219 manually constructs the transaction with bodies[0] as "created" and bodies[1] as "requested". In production, reject() during the "requested" webhook creates the row with "requested" as bodies[0], so the real ordering is reversed and the parser fails.

Prompt for agents
In server/api/activity.ts, the PandaActivity transform's flow reducer (around line 598) skips 'requested' actions entirely. When a transaction only has a 'requested' declined body as bodies[0] (which is what reject() creates during the 'requested' webhook in server/hooks/panda.ts), the flow.created and flow.completed are both undefined, causing 'invalid flow' to throw on line 609.

Two changes are needed:

1. In server/api/activity.ts around line 598, update the flow reducer to use 'requested' as a fallback for details. For example, change the 'requested' case to set flow.created if it's not already set:
   case 'requested':
     if (!f.created) f.created = operation;
     return f;

   Or alternatively, change the details fallback on line 608 to include requested operations:
   const details = flow.created ?? flow.completed ?? operations.find((op) => op.action === 'requested');

2. In server/test/api/activity.test.ts around line 179-219, fix the test data for 'transaction-declined' to match the production body ordering (requested first, then created), since reject() during the 'requested' webhook creates the 'requested' body as bodies[0]. Also add a test for the case where only a 'requested' declined body exists (no subsequent 'created' webhook).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1251 to +1259
.onConflictDoUpdate({
target: transactions.id,
set: {
payload: sql`jsonb_set(
${transactions.payload},
'{bodies}',
COALESCE(${transactions.payload}::jsonb->'bodies', '[]'::jsonb) || ${JSON.stringify([declinedBody])}::jsonb
)`,
},

Choose a reason for hiding this comment

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

🚩 reject() upsert only appends to bodies but never extends hashes array

The reject() function at server/hooks/panda.ts:1243-1259 uses onConflictDoUpdate to append a declined body to the payload.bodies array. However, it only modifies payload — the hashes array is not extended. This means when reject() is called multiple times for the same transaction (e.g., first during "requested" webhook, then during "created" declined webhook), bodies grows but hashes stays at [zeroHash]. The PandaActivity parser at server/api/activity.ts:551 maps hashes to bodies by index (hashes.map((hash, index) => { ... bodies[index] ... })), so only bodies[0] is ever processed. All subsequent appended bodies are effectively invisible to the parser. This design choice directly contributes to the reported bug and may also cause other data to be silently lost.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

.filter((p) => p.provider === "panda");

const declined = (function () {
const operation = operations.findLast((b) => b.action === "created" && b.status === "declined");

Choose a reason for hiding this comment

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

🚩 Updated declined transactions may not surface as declined in the activity response

When an "updated" webhook arrives with status: "declined", it falls through from the case "updated" to case "created" in server/hooks/panda.ts:472-643. The reject() call at line 663 correctly stores the decline. However, the PandaActivity parser's declined detection at server/api/activity.ts:571 only checks for b.action === "created" && b.status === "declined" or b.action === "requested". It does not check for b.action === "updated" with declined status. Additionally, the CardActivity schema's "updated" variant (server/api/activity.ts:666) doesn't include status or reason fields, so these are stripped during parsing. This means "updated" declined transactions are stored correctly but never surfaced as declined in the API response.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

server: handle card declined events

2 participants