Skip to content

production readiness#6

Open
wardbox wants to merge 14 commits intomainfrom
wardbox/acc-del-and-data-export
Open

production readiness#6
wardbox wants to merge 14 commits intomainfrom
wardbox/acc-del-and-data-export

Conversation

@wardbox
Copy link
Owner

@wardbox wardbox commented Nov 20, 2025

Summary by CodeRabbit

  • New Features

    • Privacy & Terms pages with footer legal links.
    • Profile: export your data and delete your account (with cleanup flows).
    • Per‑bank reconnection flow with consent dialog and re‑auth support.
  • Improvements

    • Animated transaction groups; corrected amount sign/color and display.
    • Per‑institution loading states, refined account toggles, improved UI sync.
    • Dialogs constrained with scrolling for small screens; layout tweaks.
  • Security

    • Plaid webhook verification hardened to JWT/ES256 with freshness and body‑hash checks.
  • Data

    • Institutions expose status and error codes; migration added.
  • Chores

    • Enriched environment example docs, deployment configs, Node workflow and added crypto/runtime dependency.

✏️ Tip: You can customize this high-level summary in your review settings.

Implements account deletion and data export functionalities.

Adds routes and pages for privacy policy and terms of service.

Includes agreement links on the signup page and updates dependencies.
Adds reconnection flow for Plaid institutions when credentials expire.

Improves Plaid webhook verification by implementing JWT verification.

Enables user data export and account deletion, respecting user privacy.

Adds institution status tracking for better error handling and user guidance.
@wardbox wardbox changed the title Wardbox/acc del and data export production readiness Nov 20, 2025
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 20, 2025

Walkthrough

Adds Plaid update‑mode reauth and consent flows, ES256 JWT Plaid webhook verification, user export and account deletion, Institution status/errorCode with migration, Privacy/Terms pages and footer/signup links, dialog scroll constraints, UI/animation and amount-sign convention changes, CI/deploy configs, and jose dependency.

Changes

Cohort / File(s) Summary
Env & Config
\.env.server.example, \.gitignore
Expanded .env.server.example with grouped sections, example values (e.g., CLIENT_URL, PLAID_WEBHOOK_URL), detailed Plaid/ENCRYPTION_KEY guidance and production checklist; added env.prod.server to .gitignore.
Schema & Migration
schema.prisma, migrations/.../migration.sql
Added status (String, default "active") and errorCode (String?) to Institution; migration adds corresponding columns.
Plaid Backend: core ops & service
src/plaid/operations.ts, src/plaid/service.ts
Added Update Mode actions (createUpdateModeLinkToken, exchangeUpdateModeToken), accessToken-aware link token creation, Plaid item removal during deletion, per-institution status/errorCode handling, sync resilience and reauth classification, and spending-query sign adjustments.
Plaid Backend: webhooks & security
src/plaid/webhooks.ts, package.json
Replaced prior webhook verification with ES256 JWT verification (JWK retrieval, iat & body‑hash checks) using jose; captures raw body, adds dev bypass, expands transactions webhook handling; added jose dependency.
Plaid Frontend & Consent
src/dashboard/components/plaid-integration.tsx, src/dashboard/components/plaid-link-button.tsx, src/plaid/components/plaid-consent-dialog.tsx
Per‑institution reauth UI, update‑mode Link flow, consent dialog component and props (requireConsent, isUpdateMode), per-institution loading/reconnect flows, and per-account toggles.
User ops: export & delete
src/user/operations.ts, main.wasp
New exportUserData and deleteUserAccount operations; Stripe and Plaid cleanup during deletion; export payload construction; Wasp actions/queries/routes added for Plaid update flows and user ops.
Profile & Account UI
src/ProfilePage.tsx, src/SubscriptionPage.tsx
Added data export (JSON download) and delete-account flow (confirmation, logout/navigation), toasts and loading states; adjusted subscription page container layout.
Legal pages, routes & footer/signup
main.wasp, src/legal/PrivacyPolicyPage.tsx, src/legal/TermsOfServicePage.tsx, src/root/components/footer.tsx, src/auth/auth.tsx
Added Privacy/Terms pages and routes; footer legal links and layout changes; signup agreement text added; removed Home from main nav.
UI: dialogs, transactions, animations
src/client/components/ui/alert-dialog.tsx, src/client/components/ui/dialog.tsx, src/transactions/components/transaction-detail-dialog.tsx, src/transactions/components/transactions-list.tsx
Dialog content constrained to max-h-[90vh] with vertical scrolling; transaction amount sign/color conventions switched to Plaid convention (positive = expense), animated expand/collapse for groups; detail dialog scroll limit.
Date utils & Dashboard tweaks
src/utils/dateUtils.ts, src/dashboard/DashboardPage.tsx, src/transactions/TransactionsPage.tsx
UTC-aware default-time detection for Plaid timestamps; display adjustments; dashboard timeRange default to 1m; removed min-h-screen from Transactions page.
CI & Deployment
.github/workflows/format.yml, fly-client.toml, fly-server.toml, package.json
Bumped Node in CI to 22.12.0, adjusted Wasp compile steps, added Fly configs for client/server, and added jose dependency.
Misc
prisma.config.ts
Whitespace adjustment (blank lines) only.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • Focus review on:
    • src/plaid/webhooks.ts — ES256/JWK verification, raw-body capture, iat and SHA‑256 body‑hash checks, dev bypass.
    • src/plaid/operations.ts — Update‑mode token flows, accessToken decryption/use, status/errorCode transitions, and sync error classification.
    • src/user/operations.ts — Stripe and Plaid cleanup sequencing and error resilience to avoid partial deletion.
    • Frontend ↔ backend integration for reauth flows: plaid-link-button, plaid-integration, new server actions, and UI state coordination.
    • Global sign‑convention changes for spending (queries and UI) to ensure consistent behavior.

Possibly related PRs

  • Wardbox/fix mobile nav #5 — Overlaps Plaid webhook verification and integration changes; touches Plaid webhook handling and related operations.

Poem

🐇
I hop through tokens, tidy burrows new,
Consent gates open, webhooks hum on cue.
I gather exports, tidy up the den,
Reconnect the banks, then hop off again.
Hooray for clean links, secure and true!

Pre-merge checks

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.67% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive The title 'production readiness' is vague and generic. While related to the overall PR scope, it does not clearly convey the specific primary changes (account deletion, data export, Plaid re-auth, legal pages). Consider a more specific title like 'Add account deletion, data export, and Plaid re-authentication support' to better communicate the main changes.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

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: 4

🧹 Nitpick comments (12)
src/user/operations.ts (1)

15-78: Clarify behavior when external cleanup fails (subscriptions and bank links)

Right now, account deletion proceeds even if external cleanup fails (subscriptions or linked financial items), which can leave:

  • Active paid subscriptions with no corresponding user record.
  • Linked financial items not fully removed on the provider’s side.

That may be acceptable by design (e.g., “never block deletion”), but it’s a meaningful trade-off. Consider either:

  • Treating hard failures in external cleanup as a 500 that stops deletion, or
  • At minimum, surfacing a clear message to the user and/or recording an internal “manual cleanup required” signal so you can reconcile orphaned subscriptions/items later.
src/ProfilePage.tsx (1)

78-99: Separate deletion vs. logout/navigation failures and reset isDeleting more defensively

In handleDeleteAccount, any error in the whole block (deletion, toast, logout, navigate) lands in the same catch and is surfaced as “Deletion failed,” and isDeleting is only reset there. That means:

  • If deletion succeeds but logout or navigation fails, the user sees a misleading “Deletion failed” message.
  • In rare edge cases where navigation fails but the component stays mounted, isDeleting would remain true on the success path.

Consider:

  • Using a finally block to always reset isDeleting, and
  • Narrowing the try to the actual delete call (and perhaps the success toast), handling logout/navigation errors separately so the messaging reflects which step failed.

Also applies to: 164-199

.env.server.example (1)

1-70: Helpful env documentation; key ordering warnings are cosmetic

The added structure, examples, and Plaid production checklist are very useful and keep secrets out of version control. The dotenv-linter warnings about key ordering (PLAID_ENV before PLAID_SECRET, STRIPE_PRICE_ID before STRIPE_PUBLISHABLE_KEY) are purely cosmetic; you can reorder those lines if you want a clean lint run, but behavior is unaffected.

Based on static analysis hints

src/plaid/webhooks.ts (1)

27-30: Consider adding explicit webhook verification control.

The development mode bypass is pragmatic for local testing, but consider adding an explicit environment variable (e.g., SKIP_WEBHOOK_VERIFICATION=true) to make this behavior more explicit and controllable across different environments.

src/dashboard/components/plaid-link-button.tsx (1)

55-58: Tighten hook dependencies and callback structure around openPlaidLink.

handleConsentAccept and handleOpen call openPlaidLink but the callbacks are declared before openPlaidLink and have empty dependency arrays. This works today (the closure will see the variable at call time), but it will trip exhaustive-deps lint and can become stale if any of the props used inside openPlaidLink ever change (e.g. new onError/onEvent behavior). Consider either:

  • Wrapping openPlaidLink itself in useCallback and listing it in the consent handlers’ dependency arrays, or
  • Inlining the minimal logic they need, so their deps remain accurate.

This keeps React hook usage idiomatic and future‑proof.

Also applies to: 72-77, 117-121, 123-135, 137-203

src/plaid/components/plaid-consent-dialog.tsx (1)

25-36: Avoid double-calling onDecline from both onOpenChange and the Cancel button.

Right now onDecline is invoked via onOpenChange when open becomes false and also directly on the Cancel button. That’s fine for the current parent (idempotent close), but it’s an API footgun if onDecline ever does more than toggle state. Consider routing the Cancel button through the dialog’s close mechanism (e.g. DialogClose) and keeping only one code path that triggers onDecline.

Also applies to: 84-118

src/dashboard/components/plaid-integration.tsx (1)

72-73: Reauth flow wiring looks good; align types to avoid as any and clarify loading semantics.

The reconnection flow (reconnectingInstitutionId, handleReconnectSuccess/Error, and the needs_reauth alert) is well wired to createUpdateModeLinkToken / exchangeUpdateModeToken, and the per‑institution loading state via reconnectingInstitutionId is clear. Two follow‑ups worth considering:

  • Instead of (institution as any)?.status, extend the shared institution type (GetInstitutionsResult and InstitutionsSummaryProps) to include status and errorCode. That keeps reauth UI type‑safe and avoids any leaks.
  • The setIsLoading prop passed into PlaidLinkButton currently assumes it will always be called with a boolean (not a functional updater). If you ever change PlaidLinkButton to use functional updates (setIsLoading(prev => !prev)), this wrapper will misinterpret the argument; documenting or tightening that contract now can prevent surprises later.

Also applies to: 181-207, 291-340

src/plaid/operations.ts (5)

103-159: Update-mode link token flow is correct; add a small guard around accessToken.

createUpdateModeLinkToken correctly verifies user ownership, decrypts the stored accessToken, and reuses _internalCreateLinkToken with the decrypted token to enter Plaid’s update mode, with consistent HttpError handling. One improvement: if there’s any chance of legacy rows with a missing or malformed accessToken, add an explicit check before decrypt (e.g. 404/409 or a clearer 500) so failures are easier to distinguish from general Plaid errors in logs.


256-334: Exchange-update flow is sound; consider whether to trigger an immediate sync after reauth.

exchangeUpdateModeToken does the right checks (auth, institution ownership), reuses _internalExchangePublicToken, and updates the institution with the new encrypted accessToken, itemId, and resets status/errorCode. Currently it stops there and relies on a later sync to pull fresh transactions. If you want “Reconnect” to immediately refresh data, you might optionally call syncTransactions for that institution in this action (or from the caller) once the credentials are updated.


531-595: Reauth vs error status handling in _syncSingleInstitution is a good pattern; verify Plaid error codes.

Marking institutions as needs_reauth vs error based on error.response?.data?.error_code || error.error_code and persisting status/errorCode is a solid way to drive the re‑connect UI. I’d just recommend double‑checking the list of requiresReauth codes against Plaid’s latest docs—some codes like INSTITUTION_DOWN or INSTITUTION_NOT_RESPONDING may be transient outages rather than genuine reauth requirements, and you might prefer to treat them as error instead of needs_reauth.


720-723: Deleting the Plaid item before DB deletion is good; tighten error logging and null handling.

The new deleteInstitution logic that decrypts accessToken and calls plaidClient.itemRemove before deleting from the database is the right direction and correctly doesn’t block deletion on Plaid failures. Two small hardening tweaks to consider:

  • If institution.accessToken can ever be null/empty (e.g. legacy data), short‑circuit the Plaid call instead of attempting decrypt and relying on the catch.
  • When logging Plaid errors, ensure no sensitive token fragments are ever included (the current code logs response?.data / message, which is usually safe but worth keeping an eye on).

Also applies to: 734-753, 754-773


1146-1154: Update GetInstitutionsResult to include status/errorCode instead of relying on as any in the UI.

getInstitutions now selects status and errorCode in the Prisma query, but GetInstitutionsResult still only exposes id/name/sync/logo/accounts. On the frontend (PlaidIntegration), this forces a cast (institution as any).status to drive the reauth alert. It would be cleaner to extend GetInstitutionsResult (and downstream props like InstitutionsSummaryProps) to include status and errorCode so the reauth UI is fully type‑safe and you get autocomplete/TS checks for these new fields.

Also applies to: 1168-1178

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7a33666 and fb96f92.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (19)
  • .env.server.example (1 hunks)
  • main.wasp (4 hunks)
  • migrations/20251120044613_add_institution_status_fields/migration.sql (1 hunks)
  • package.json (1 hunks)
  • schema.prisma (1 hunks)
  • src/ProfilePage.tsx (3 hunks)
  • src/auth/auth.tsx (1 hunks)
  • src/dashboard/components/plaid-integration.tsx (5 hunks)
  • src/dashboard/components/plaid-link-button.tsx (6 hunks)
  • src/legal/PrivacyPolicyPage.tsx (1 hunks)
  • src/legal/TermsOfServicePage.tsx (1 hunks)
  • src/plaid/components/plaid-consent-dialog.tsx (1 hunks)
  • src/plaid/operations.ts (14 hunks)
  • src/plaid/service.ts (2 hunks)
  • src/plaid/webhooks.ts (3 hunks)
  • src/root/components/footer.tsx (4 hunks)
  • src/transactions/components/transaction-detail-dialog.tsx (1 hunks)
  • src/transactions/components/transactions-list.tsx (1 hunks)
  • src/user/operations.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (9)
src/legal/PrivacyPolicyPage.tsx (1)
src/motion/transitionPresets.tsx (1)
  • fadeIn (74-84)
src/legal/TermsOfServicePage.tsx (1)
src/motion/transitionPresets.tsx (1)
  • fadeIn (74-84)
src/dashboard/components/plaid-link-button.tsx (1)
src/plaid/components/plaid-consent-dialog.tsx (1)
  • PlaidConsentDialog (18-130)
src/user/operations.ts (2)
src/plaid/utils/encryption.ts (1)
  • decrypt (32-52)
src/plaid/client.ts (1)
  • plaidClient (34-34)
src/plaid/webhooks.ts (1)
src/plaid/client.ts (1)
  • plaidClient (34-34)
src/ProfilePage.tsx (1)
src/user/operations.ts (2)
  • exportUserData (97-180)
  • deleteUserAccount (7-95)
src/plaid/components/plaid-consent-dialog.tsx (2)
src/client/components/ui/dialog.tsx (6)
  • Dialog (112-112)
  • DialogContent (117-117)
  • DialogHeader (118-118)
  • DialogTitle (120-120)
  • DialogDescription (121-121)
  • DialogFooter (119-119)
src/client/components/ui/button.tsx (1)
  • Button (63-63)
src/dashboard/components/plaid-integration.tsx (2)
src/dashboard/components/plaid-link-button.tsx (1)
  • PlaidLinkButton (61-239)
src/plaid/operations.ts (2)
  • createUpdateModeLinkToken (114-159)
  • exchangeUpdateModeToken (268-334)
src/plaid/operations.ts (3)
src/plaid/utils/encryption.ts (1)
  • decrypt (32-52)
src/plaid/service.ts (2)
  • _internalCreateLinkToken (22-64)
  • _internalExchangePublicToken (72-140)
src/plaid/client.ts (1)
  • plaidClient (34-34)
🪛 dotenv-linter (4.0.0)
.env.server.example

[warning] 12-12: [UnorderedKey] The PLAID_ENV key should go before the PLAID_SECRET key

(UnorderedKey)


[warning] 27-27: [UnorderedKey] The STRIPE_PRICE_ID key should go before the STRIPE_PUBLISHABLE_KEY key

(UnorderedKey)

🔇 Additional comments (16)
src/transactions/components/transaction-detail-dialog.tsx (2)

33-36: LGTM! Amount styling logic correctly implements Plaid convention.

The implementation correctly treats positive amounts as expenses (red with minus sign) and negative amounts as income (green with plus sign), aligning with Plaid's transaction amount convention. The logic is clear and the derived values (color and sign) are used consistently.


40-40: Good UX enhancement for scrollable content.

Adding vertical scrolling with a 90vh max height ensures transaction details remain accessible on smaller screens or when there are many details to display.

src/auth/auth.tsx (1)

56-67: Signup TOS/Privacy notice looks good; keep routes in sync

The inline consent copy and links to Terms of Service and Privacy Policy are a solid addition. Just ensure /terms and /privacy remain aligned with the routes defined in your routing config/main.wasp if paths change later.

src/user/operations.ts (1)

127-172: Export payload looks well-scoped and avoids obvious secrets

The export shape sensibly:

  • Includes user metadata and related institutions/accounts/transactions.
  • Omits sensitive fields like access tokens, encryption keys, and any Stripe identifiers.

As the schema evolves, just keep this list in sync so you don’t accidentally add new sensitive fields to the export.

src/ProfilePage.tsx (1)

45-76: Data export handler is straightforward and user-friendly

The export handler is well-structured: guards with isExporting, awaits the operation, generates a JSON blob with pretty-print, triggers a download, and cleans up the object URL, with clear success/error toasts. This looks good as-is.

migrations/20251120044613_add_institution_status_fields/migration.sql (1)

1-3: LGTM!

The migration correctly adds the status and errorCode columns to support Plaid re-authentication flows. The syntax is valid, the default value aligns with the schema, and the nullability matches the intended design.

schema.prisma (1)

35-36: LGTM!

The new status and errorCode fields are well-designed to support Plaid re-authentication flows. The inline comments provide clear documentation of expected values, and the default "active" status is appropriate for new connections.

src/root/components/footer.tsx (1)

40-43: LGTM!

The legal navigation links are well-integrated into both mobile and desktop layouts. The implementation is consistent, and the support email provides a clear contact method for users.

Also applies to: 112-130, 163-181

src/plaid/service.ts (1)

22-52: LGTM!

The Update Mode implementation correctly handles both new connections and re-authentication flows. The conditional logic appropriately configures the Link Token request for each scenario, and the comments clearly explain the two modes.

src/legal/TermsOfServicePage.tsx (1)

5-23: Well-structured Terms of Service page.

The page uses appropriate animations and provides comprehensive coverage of terms. The dynamic "Last Updated" date is a good practice for maintaining transparency with users.

src/legal/PrivacyPolicyPage.tsx (1)

4-360: LGTM!

The Privacy Policy is comprehensive and well-structured. It appropriately covers data collection, usage, security measures, and user rights. The inclusion of GDPR and CCPA-specific sections demonstrates compliance awareness.

src/plaid/webhooks.ts (1)

105-117: LGTM!

The JWT token extraction and validation correctly checks for the presence of the Plaid-Verification header and returns appropriate error responses. The async verification call is properly awaited.

src/transactions/components/transactions-list.tsx (1)

292-293: The code change is correct and aligns with Plaid's API documentation.

Plaid's Transactions API specifies that amount is positive when money moves out of the account (debits) and negative when money moves into the account (credits). The updated logic correctly interprets this: transaction.amount > 0 identifies expenses (outflows), and negative amounts represent income (inflows). The comment accurately documents this convention.

src/dashboard/components/plaid-integration.tsx (1)

454-483: Manage Accounts dialog implementation looks solid.

The account management dialog correctly scopes accounts to the selected institution, guards with ?? [], and delegates toggling to handleToggleAccount while keeping UI minimal. No issues from a correctness or UX standpoint.

main.wasp (1)

51-52: New routes and actions are wired consistently with existing Wasp config.

Privacy/Terms routes and pages, Plaid update‑mode actions, and user delete/export operations all follow the existing configuration patterns and entity wiring, so from a routing/API surface perspective this looks good.

Also applies to: 102-108, 119-122, 129-132, 208-223

src/plaid/operations.ts (1)

831-832: Spending queries now treat amount > 0 as “expense only”; confirm this matches all account types.

getSpendingSummary, getMonthlySpending, and getCategorySpending now filter on amount.gt(0) and aggregate the raw amount assuming “positive = money out”. That matches Plaid’s documented convention for most cases, but credit products and certain edge cases can sometimes be counter‑intuitive. It’s worth sanity‑checking with a few real transactions (debits, refunds, card payments) to confirm that what users perceive as “spending” always shows up as positive here.

Also applies to: 855-857, 961-983, 1039-1065

Updates Node.js version and enhances webhook verification for better security by using timing-safe comparison to prevent timing attacks.

Also, updates Terms of Service to use Washington, USA as the governing law.

Improves UI layout of the subscription page and updates transaction list animation for better UX.

Changes error messages to be more generic when deleting user accounts and exporting user data.
Improves the user experience by adding scrollability to dialogs and alerts,
preventing content overflow.

Adds immediate transaction syncing after re-authentication to keep data
up-to-date.

Handles edge cases in Plaid operations, such as missing access tokens, to
prevent errors and improve reliability.

Refactors error handling to better manage Plaid API issues during institution
deletion.
Specifies the required Node.js version.

Sets up Wasp by installing it and adding its binary directory to the PATH.
This ensures that Wasp commands can be run from the command line.
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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/dashboard/components/plaid-integration.tsx (1)

79-115: handleConnectionSuccess must await refetch() to properly handle errors and timing

The issue is confirmed. Line 94 calls refetch() without await in an async function with try/catch/finally. This means:

  • Errors from refetch() won't be caught by the catch block
  • The finally block (which clears the spinner and shows the success toast) runs immediately instead of waiting for the refetch to complete
  • The success toast displays before data actually refreshes

The fix is correct—add await:

    try {
-      refetch()
+      await refetch()

This aligns with the correct pattern already used in handleReconnectSuccess at line 185. Note: the code comment at line 78 claims the handler was "modified to await refetch," but it hasn't been applied yet.

🧹 Nitpick comments (7)
src/legal/TermsOfServicePage.tsx (1)

161-175: Consider linking directly to Plaid’s Terms and Privacy Policy.

The text says users agree to Plaid’s Terms of Service and Privacy Policy but doesn’t link to them, which is less user‑friendly and may not fully satisfy partner expectations.

You could inline links, e.g.:

-            <li>You agree to Plaid's Terms of Service and Privacy Policy</li>
+            <li>
+              You agree to Plaid&apos;s{' '}
+              <a
+                href='https://plaid.com/legal/#end-user-terms-of-use'
+                target='_blank'
+                rel='noreferrer'
+              >
+                Terms of Service
+              </a>{' '}
+              and{' '}
+              <a
+                href='https://plaid.com/legal/#end-user-privacy-policy'
+                target='_blank'
+                rel='noreferrer'
+              >
+                Privacy Policy
+              </a>
+            </li>

(Adjust URLs/attributes to match your legal/partner guidance.)

src/transactions/components/transactions-list.tsx (2)

238-255: Group expand/collapse animation structure looks good; consider minor a11y polish

The animated expand/collapse container for each transaction group is composed correctly: conditional rendering, presence-based exit animation, and overflow clipping all line up, and the timing/easing are reasonable for production.

One optional improvement: consider respecting the user’s prefers-reduced-motion setting (e.g., skip or greatly shorten these height/opacity transitions when reduced motion is requested) to avoid unnecessary motion for sensitive users.

Also applies to: 342-345


269-340: Plaid-style amount sign/color logic is clear; double-check upstream normalization & zero handling

The per-row container change is fine, and the inline amount logic is easy to read. Mapping transaction.amount > 0 to expenses (red, - sign) and <= 0 to income (green, + sign) matches the intended Plaid convention and keeps formatting straightforward.

Two small follow-ups to verify/consider:

  • Ensure you’re not already flipping the sign when building TransactionWithDetails; otherwise this would double-invert and mislabel income vs. expense.
  • Decide how you want to treat amount === 0 transactions (currently they render as +$0.00 in green), in case you ever surface zero-amount holds/reversals and want them styled as neutral instead.

Both are minor, but clarifying now will prevent subtle UX inconsistencies later.

src/dashboard/components/plaid-integration.tsx (1)

72-73: Update‑mode reconnection flow and per‑institution loading state look solid (double‑check Wasp action usage)

The new reconnectingInstitutionId state, handleReconnectSuccess/handleReconnectError handlers, and the needs_reauth alert wiring into PlaidLinkButton with isUpdateMode={true} form a coherent reconnection flow:

  • Ownership and institution ID are passed down cleanly to createUpdateModeLinkToken and exchangeUpdateModeToken.
  • isLoading/setIsLoading are correctly scoped per institution via reconnectingInstitutionId, so only the reconciling institution shows a spinner.
  • Reauth success triggers a refetch and user‑facing toast; failures clear loading and show an error toast.

One thing to verify: here you call createUpdateModeLinkToken / exchangeUpdateModeToken from wasp/client/operations directly inside the lambdas. In other places (e.g., deleteInstitutionAction, toggleAccountTrackingAction, and SubscriptionPage.tsx) actions are invoked via useAction. Ensure that in your Wasp version these operations are indeed directly callable on the client; if not, mirror the useAction pattern and pass the resulting functions into PlaidLinkButton.

Also applies to: 181-207, 301-340

src/plaid/webhooks.ts (1)

6-10: JWT webhook verification flow is correct overall; consider a few hardening tweaks

The new flow (ES256 enforcement, JWK fetch via Plaid, jwtVerify, 5‑minute window, and constant‑time SHA‑256 body hash comparison) looks aligned with Plaid’s JWT docs and fixes the earlier timing‑attack concern.

A few small improvements to consider:

  • Header decoding: Instead of manually splitting the JWT and doing Buffer.from(headerB64, 'base64'), use jose’s decodeProtectedHeader(jwtToken) so you get robust base64url handling and fewer parsing edge cases if the header format changes.
  • iat validation: Before currentTime - issuedAt, check typeof payload.iat === 'number'; if it’s missing or not numeric, log and return false so failures are explicit rather than falling through due to NaN.
  • Body hash canonicalization: You currently hash JSON.stringify(req.body). Plaid’s request_body_sha256 is computed over a specific canonical form of the body; please double‑check that this matches their documented normalization. If not, capture the raw request body in plaidWebhookMiddlewareConfigFn (e.g., via the verify option on the JSON/body parser storing req.rawBody) and pass that raw string into verifyPlaidWebhook to avoid subtle hash mismatches.

These changes would make the verifier more robust without altering the high‑level behavior.

Also applies to: 22-103, 117-127

src/user/operations.ts (2)

7-95: Account deletion flow is robust; add a small null guard for Plaid access tokens

The deletion operation does a good job of:

  • Canceling Stripe subscriptions and deleting the customer best‑effort, without blocking account deletion on Stripe errors.
  • Iterating the user’s institutions and calling Plaid itemRemove for each, logging but not failing the overall deletion on Plaid issues.
  • Relying on Prisma cascades to remove institutions, accounts, and transactions in one User.delete call.

One minor improvement: in the Plaid cleanup loop you unconditionally call decrypt(institution.accessToken). If an older row has a missing/empty accessToken, decrypt will throw, get caught, and log “Failed to decrypt data.” for every such institution. Functionally you still continue, but this adds noisy logs.

Consider mirroring deleteInstitution and short‑circuiting:

for (const institution of institutions) {
  try {
    if (!institution.accessToken) {
      console.warn(
        `Institution ${institution.id} has no accessToken, skipping Plaid item removal`,
      )
      continue
    }
    const decryptedAccessToken = decrypt(institution.accessToken)
    await plaidClient.itemRemove({ access_token: decryptedAccessToken })
    // ...
  } catch (plaidError: any) {
    // existing logging
  }
}

This keeps logs cleaner while preserving the current best‑effort behavior.


97-180: Data export shape is comprehensive and avoids sensitive fields (optional error behavior tweak)

The export operation:

  • Fetches user → institutions → accounts → transactions in one query.
  • Returns a structured JSON export including identifiers and timestamps but omits secrets like Plaid access tokens and Stripe customer IDs.
  • Provides a clear exportedAt and exportVersion for future evolution.

Currently, a HttpError(404, 'User not found') thrown inside the try is caught and rethrown as a generic 500. Since this action is only callable by an authenticated user, that case should be extremely rare; but if you ever want the client to distinguish “user not found” from generic errors, you could mirror your Plaid operations pattern and rethrow HttpError instances:

} catch (error: any) {
  console.error('Error exporting user data:', error)
  if (error instanceof HttpError) throw error
  throw new HttpError(500, 'Failed to export data')
}

Not required for correctness, but it keeps semantically meaningful status codes if this edge case ever occurs.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fb96f92 and 97b7006.

📒 Files selected for processing (10)
  • .github/workflows/format.yml (1 hunks)
  • src/SubscriptionPage.tsx (1 hunks)
  • src/client/components/ui/alert-dialog.tsx (1 hunks)
  • src/client/components/ui/dialog.tsx (1 hunks)
  • src/dashboard/components/plaid-integration.tsx (5 hunks)
  • src/legal/TermsOfServicePage.tsx (1 hunks)
  • src/plaid/operations.ts (15 hunks)
  • src/plaid/webhooks.ts (3 hunks)
  • src/transactions/components/transactions-list.tsx (5 hunks)
  • src/user/operations.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
src/legal/TermsOfServicePage.tsx (1)
src/motion/transitionPresets.tsx (1)
  • fadeIn (74-84)
src/dashboard/components/plaid-integration.tsx (2)
src/dashboard/components/plaid-link-button.tsx (1)
  • PlaidLinkButton (61-239)
src/plaid/operations.ts (2)
  • createUpdateModeLinkToken (114-164)
  • exchangeUpdateModeToken (273-348)
src/plaid/webhooks.ts (1)
src/plaid/client.ts (1)
  • plaidClient (34-34)
src/user/operations.ts (2)
src/plaid/utils/encryption.ts (1)
  • decrypt (32-52)
src/plaid/client.ts (1)
  • plaidClient (34-34)
src/plaid/operations.ts (3)
src/plaid/utils/encryption.ts (1)
  • decrypt (32-52)
src/plaid/service.ts (2)
  • _internalCreateLinkToken (22-64)
  • _internalExchangePublicToken (72-140)
src/plaid/client.ts (1)
  • plaidClient (34-34)
🔇 Additional comments (11)
src/client/components/ui/dialog.tsx (1)

41-41: Good overflow handling for long content.

The addition of max-h-[90vh] and overflow-y-auto appropriately constrains dialog height and enables scrolling when content overflows.

Please verify the close button remains visible and accessible when scrolling through long content, especially on mobile/smaller viewports.

src/client/components/ui/alert-dialog.tsx (1)

37-37: Consistent overflow handling applied.

The same height constraint and scrolling behavior applied here maintains consistency with the dialog component changes.

.github/workflows/format.yml (1)

20-20: Node 22.x upgrade is compatible and production-safe.

Node.js 22 entered Active LTS (production‑stable) on October 29, 2024. Verification confirms:

  • jose 6.1.2 has no Node version constraints and works with Node 22.x
  • All other dependencies are compatible with Node 22.x
  • Only one workflow file exists (no consistency issues)
  • No engines field is needed in package.json for this change to be functional

The upgrade from Node 20.x to 22.x is appropriate for this codebase.

src/transactions/components/transactions-list.tsx (1)

3-3: Animation import aligns with usage; just ensure dependency wiring is correct

The added import for the animation helpers matches how they’re used below in the component. As long as the corresponding animation package is installed and your bundler is configured for its new entrypoint, this should be fine.

src/SubscriptionPage.tsx (1)

123-176: Subscription layout container update looks good

Wrapping the card in a full-width, flex-1 centered container with an inner max-w-md keeps behavior unchanged while aligning layout with the rest of the app.

src/dashboard/components/plaid-integration.tsx (1)

454-483: Manage Accounts dialog mapping is safe and resilient

Using institutions?.find(... )?.accounts ?? [] avoids crashes when the institution is missing and keeps the Switch strictly controlled via account.isTracked with a single toggle handler, which is a clean, maintainable pattern.

src/plaid/operations.ts (5)

103-164: Update Mode server actions correctly enforce auth and institution ownership

Both createUpdateModeLinkToken and exchangeUpdateModeToken look solid:

  • They require context.user and validate the institutionId is present and belongs to the current user before doing anything with Plaid.
  • createUpdateModeLinkToken ensures an access token exists, decrypts it once, and passes it into _internalCreateLinkToken to generate an Update Mode link token.
  • exchangeUpdateModeToken reuses _internalExchangePublicToken to get a fresh encrypted access token and item ID, then updates the institution and resets status to 'active' with errorCode: null.
  • Immediate post‑reauth sync is attempted but failures are logged and don’t cause the action itself to fail, which is the right trade‑off.

This is a clean, least‑privilege implementation for the new reconnection flows.

Also applies to: 261-348


545-555: New institution status / errorCode handling improves resilience and UX

The _syncSingleInstitution helper now:

  • Marks an institution as 'active' and clears errorCode after a successful sync, so previously errored connections recover cleanly.
  • Examines Plaid error_code values and, for known reauth‑required codes, sets status: 'needs_reauth', stores the code, and throws a 400 HttpError instructing the client to reconnect.
  • For other Plaid errors with an error_code, sets status: 'error' and persists the code for debugging, while still throwing a 500 back to the caller.

This gives the UI a clear, queryable source of truth about whether an institution is healthy, needs reauth, or has a non‑reauth error, and avoids tight retry loops by updating lastSync on error. Just keep the requiresReauth list in sync with Plaid’s latest error codes over time.

Also applies to: 563-610


737-772: Per‑institution Plaid /item/remove in deleteInstitution is well‑behaved and non‑blocking

Enhancing deleteInstitution to:

  • Select accessToken when loading the institution,
  • Skip Plaid removal when there is no token, and
  • Decrypt and call plaidClient.itemRemove with best‑effort error handling,

ensures Plaid items are cleaned up for individual deletions while still allowing the database delete to proceed even if Plaid returns an error. This matches the behavior in deleteUserAccount and aligns with Plaid’s data‑retention guidance.


851-852: Consistently filtering to positive Plaid amounts clarifies “spending” semantics

Updating the spending queries to use:

  • amount: { gt: 0 } in getSpendingSummary, getMonthlySpending, and getCategorySpending, and
  • Using tx.amount directly in aggregation

makes it explicit that “spending” is based only on outgoing transactions (Plaid convention: positive = money out, negative = money in). This avoids income inflows skewing charts and keeps aggregation logic simple and consistent across all three endpoints.

Also applies to: 875-877, 981-982, 1001-1003, 1059-1060, 1082-1085


1165-1169: Extending GetInstitutionsResult with status and errorCode supports the new reconnection UX

Including status and errorCode in GetInstitutionsResult and selecting them in getInstitutions gives the client exactly what it needs to:

  • Show the “Reconnection required” banner for status === 'needs_reauth'.
  • Potentially surface or log Plaid error codes for diagnostics.

This also keeps the type alias aligned with the Prisma select, so consuming components get a strongly‑typed view of these new fields.

Also applies to: 1196-1198

Updates dashboard to default to a 1-month view for a more immediate user experience.

Modifies data fetching in the Plaid integration component to ensure data consistency after user actions.

Updates the Privacy Policy and Terms of Service pages to use a static last updated date for better maintainability, related to work on account deletion and data export features.
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: 1

🧹 Nitpick comments (5)
src/dashboard/DashboardPage.tsx (1)

51-67: Initial timeRange and Tabs default aligned to 1m

The new '1m' default is consistent with the TimeRange union and with the Tabs setup, so behavior stays coherent with the chart props. Since Tabs is fully controlled via value={timeRange}, defaultValue is effectively unused; you could optionally drop defaultValue (or derive both from a shared constant) to reduce confusion.

src/dashboard/components/plaid-integration.tsx (4)

139-179: Refetch after delete/toggle is correct; consider guarding repeated calls

Awaiting refetch() after deleteInstitutionAction and toggleAccountTrackingAction keeps the institutions/accounts list in sync with server state, which is production-friendly. As a future improvement, you might temporarily disable the delete button and switches while these calls are in flight (or batch multiple toggles into a single refetch) to avoid rapid-fire refetches on large account sets.


72-74: Update-mode reconnection flow is well-wired; tighten busy-state handling

The new reconnectingInstitutionId state plus the PlaidLinkButton wrapper for createUpdateModeLinkToken/exchangeUpdateModeToken correctly scopes re-auth to a specific institution and surfaces clear success/error toasts, with a refetch on success. Using isLoading={reconnectingInstitutionId === institution.id} and the custom setIsLoading keeps the update-mode button behavior aligned with the existing connect flow.

Two optional robustness tweaks to consider:

  • While an institution is reconnecting, it’s probably safer UX-wise to treat it as “busy” the same way you do when syncing. For example, derive a flag like const isBusy = syncingInstitutionId === institution.id || reconnectingInstitutionId === institution.id and use that both to hide the action area and to drive the disabled props on the manage/delete buttons, so users can’t delete/manage an institution mid–re-auth.
  • Because reconnectingInstitutionId is a single scalar, starting a reconnect on institution B while A’s Plaid Link is still open will move the “Connecting…” state to B. If you ever care about multiple concurrent reconnects, switching this to a per-institution map/set would avoid that visual race.

Overall the flow looks correct; these would just harden the UX around edge cases.

Also applies to: 181-207, 296-403


200-207: Unused error parameter in handleReconnectError

handleReconnectError accepts an error argument but doesn’t use it. To keep linters happy and aid debugging, either log/inspect it (e.g., surface a more specific message when a status code is available) or rename to _error to indicate intentional non-use.


454-483: Manage Accounts mapping is solid; refine Switch handler to use checked

The dialog’s account list safely handles missing institutions via find(...)?.accounts ?? [], and the per-account layout looks good. The Switch currently calls handleToggleAccount(account.id, account.isTracked) and then flips that boolean inside the handler; relying on the captured account.isTracked is slightly more brittle than using the checked value from the control.

You could make this more idiomatic and future-proof by letting the Switch drive the next state directly, e.g.:

-const handleToggleAccount = async (
-  accountId: string,
-  currentlyTracked: boolean,
-) => {
+const handleToggleAccount = async (
+  accountId: string,
+  nextTracked: boolean,
+) => {
   await toggleAccountTrackingAction({
     accountId,
-    isTracked: !currentlyTracked,
+    isTracked: nextTracked,
   })
   await refetch()
 }

-<Switch
-  checked={account.isTracked}
-  onCheckedChange={() =>
-    handleToggleAccount(account.id, account.isTracked)
-  }
/>
+<Switch
+  checked={account.isTracked}
+  onCheckedChange={(checked) =>
+    handleToggleAccount(account.id, checked)
+  }
/>

Functionally it’s the same today, but it avoids stale-closure issues if the component tree or data flow gets more complex.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 97b7006 and 066a227.

📒 Files selected for processing (5)
  • .github/workflows/format.yml (1 hunks)
  • src/dashboard/DashboardPage.tsx (2 hunks)
  • src/dashboard/components/plaid-integration.tsx (8 hunks)
  • src/legal/PrivacyPolicyPage.tsx (1 hunks)
  • src/legal/TermsOfServicePage.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/format.yml
🧰 Additional context used
🧬 Code graph analysis (3)
src/legal/TermsOfServicePage.tsx (1)
src/motion/transitionPresets.tsx (1)
  • fadeIn (74-84)
src/dashboard/components/plaid-integration.tsx (2)
src/dashboard/components/plaid-link-button.tsx (1)
  • PlaidLinkButton (61-239)
src/plaid/operations.ts (2)
  • createUpdateModeLinkToken (114-164)
  • exchangeUpdateModeToken (273-348)
src/legal/PrivacyPolicyPage.tsx (1)
src/motion/transitionPresets.tsx (1)
  • fadeIn (74-84)
🔇 Additional comments (5)
src/dashboard/components/plaid-integration.tsx (2)

13-18: New update-mode operations correctly wired into Plaid integration

Importing createUpdateModeLinkToken and exchangeUpdateModeToken alongside the existing Plaid actions fits the new re-auth flow and keeps all institution-related operations centralized here. As long as their client signatures match the server-side definitions (returning a link token and { institutionId } respectively), this is consistent with how PlaidLinkButton is used elsewhere.


79-115: Awaiting refetch on initial connect improves sync UX

Making handleConnectionSuccess async and awaiting refetch() before clearing syncingInstitutionId and showing the “Initial transaction sync complete” toast gives a more accurate signal that the UI has actually refreshed from the backend. No behavior issues spotted here.

src/legal/TermsOfServicePage.tsx (1)

1-21: Static LAST_UPDATED and motion wrapper look good

Using a fixed LAST_UPDATED string and the shared fadeIn variant with motion.div is clean and avoids the dynamic-date and hydration issues from the earlier version. No runtime or typing concerns here.

src/legal/PrivacyPolicyPage.tsx (2)

1-21: Privacy page scaffold and static LAST_UPDATED are solid

The component structure mirrors the Terms page nicely: shared fadeIn preset, motion wrapper, and a static LAST_UPDATED string. This is a straightforward, production‑friendly setup with no obvious runtime issues.


199-281: Address policy text contradictions; verify infrastructure retention settings outside codebase

The review's core concern is confirmed: the policy contains internal contradictions that should be harmonized.

Confirmed issues in text:

  • Lines 212–215 state deletion is "permanent and cannot be undone"
  • Lines 269–273 describe a 30-day grace period where accounts can be restored
  • Lines 236–247 & 277–281 mention backup/log retention up to 90 days

These statements are inconsistent as written. The suggested diff rewording (clarifying that deletion starts a process with a grace window, after which it becomes permanent) correctly resolves this contradiction.

Infrastructure verification:
Codebase search shows:

  • ✓ Cascade delete logic implemented in schema.prisma and src/user/operations.ts
  • ✗ No explicit infrastructure configs found for backup retention (30 days), log retention (90 days), or Plausible retention (30 days)

These retention periods are typically configured in external systems (cloud provider dashboards, Plausible account settings) rather than in code. Manual verification is required to confirm that your actual backup retention, log retention, and Plausible configuration match the stated 30/90-day windows in the policy. If they don't match, either the policy numbers or the external configurations need adjustment.

Refactors Plaid integration for better user experience and adds minor UI tweaks:

- Improves Plaid component by adding busy states for sync and reconnect actions and account toggling.
- Fixes some layout issues on the legal pages
- Removes a default value from the Tabs component in the Dashboard page.
- Minor CSS class ordering in `AlertDialogContent` and `DialogContent`.
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: 2

🧹 Nitpick comments (9)
src/transactions/components/transactions-list.tsx (2)

238-253: Animated expand/collapse container looks solid; watch for over-optimization

The AnimatePresence + motion.div setup (height/opacity, overflow-hidden, short duration) is a good fit for this grouped list and should feel snappy. The explicit willChange: 'height, opacity' and transform: 'translateZ(0)' are fine, but they’re micro‑optimizations—if you notice any odd rendering on low‑end devices, those are easy to remove without changing behavior.


300-325: Plaid sign/color semantics look correct; consider zero-amount behavior and reuse

The new logic (positive → expense → red -, negative → income → green +) matches Plaid’s convention for amount and is clear in the UI. One small edge case: amount === 0 is currently treated as income (+ and green). If zero-amount transactions can appear, you might want a neutral style instead, and if you need this convention in multiple places, extracting a small formatPlaidAmount(amount) helper could help keep things consistent.

src/dashboard/DashboardPage.tsx (1)

51-51: Controlled time range Tabs and loading wiring look correct

The TimeRange typing plus controlled Tabs driven by timeRange is consistent and avoids default/initial mismatches; the as TimeRange cast is acceptable given the fixed trigger values. Extending CategorySummary.isLoading to include syncingInstitutionId and isConnectingPlaid also makes sense to avoid showing stale data during sync/connect.

If you ever add dynamic tab values, consider tightening onValueChange to validate against TimeRange instead of casting.

Also applies to: 65-67, 101-101

src/client/components/ui/dialog.tsx (1)

41-41: Scrollable DialogContent default is sensible and non-breaking

Adding max-h-[90vh] and overflow-y-auto at the base DialogContent level improves usability for long content and still allows callers to override via className (since it’s merged last). This looks good; just be aware that components like PlaidConsentDialog now duplicate these classes, which is harmless but could be cleaned up later.

src/root/components/footer.tsx (1)

133-159: Desktop footer structure, social icons, and copyright

The desktop split into main and bottom rows reads well: main nav in the top row, legal/support plus social/copyright in the bottom row. Social links retain proper target/rel and aria labels, and reducing icon size to 18 keeps visual balance. The dynamic new Date().getFullYear() usage is fine in this SPA context.

Also applies to: 183-205

src/ProfilePage.tsx (1)

45-76: Data export flow is correct; consider minor robustness tweaks

The export handler correctly calls exportUserData, builds a JSON Blob, triggers a download, revokes the object URL, and uses isExporting to gate the button and show “Exporting…”. Toasts cover success and failure states cleanly.

As minor polish, you could:

  • Set the Blob type to include charset ('application/json;charset=utf-8') for maximum compatibility.
  • Optionally log the error in the catch for easier debugging.

These are non-blocking.

Also applies to: 132-150

src/plaid/components/plaid-consent-dialog.tsx (1)

11-27: Plaid consent dialog behavior and copy look solid; a couple of small nits

The consent dialog is well-structured: clear explanation of what’s collected, how it’s used, Plaid’s role, and a direct Privacy Policy link with proper target/rel. The isUpdateMode variations in title, description, and button label are straightforward and keep the flow understandable for reconnect vs first connect.

A couple of minor points you might consider:

  • You’re already handling declines via the Cancel button (onClick={onDecline}) and via onOpenChange={open => !open && onDecline()} for overlay/ESC close. That’s fine, but be mindful not to also close the dialog via parent state changes without checking, or onDecline may fire more than once; if that’s a concern, you could centralize closing logic in the parent and let onOpenChange simply forward open up.
  • DialogContent here repeats max-h-[90vh] overflow-y-auto, which are now also defaults in the shared DialogContent. Not an issue, but you can simplify to just sm:max-w-[500px] if you prefer to avoid duplication.

Overall, this is ready for production as-is.

Also applies to: 38-125

src/dashboard/components/plaid-integration.tsx (2)

387-422: Consider aligning button visibility with disabled logic.

The action buttons are hidden when the current institution is busy (!isBusy), but disabled when any institution is syncing or reconnecting. This creates an inconsistency where buttons may be visible but disabled without clear user feedback.

Consider one of these approaches:

  1. Use per-institution disabling (allow operations on other institutions):

    disabled={isBusy}
  2. Hide buttons globally (prevent all operations during sync):

    {!syncingInstitutionId && !reconnectingInstitutionId && (
      <div className='flex gap-1'>...</div>
    )}

Option 1 is generally preferred as it allows users to manage multiple institutions concurrently.


497-503: Switch disabled state is too broad.

The Switch is disabled when any account is being toggled (!!togglingAccountId), preventing users from toggling other accounts simultaneously.

Apply this diff to enable per-account disabling:

 <Switch
   checked={account.isTracked}
   onCheckedChange={checked =>
     handleToggleAccount(account.id, checked)
   }
-  disabled={!!togglingAccountId}
+  disabled={togglingAccountId === account.id}
 />

This allows toggling multiple accounts concurrently without conflicts.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 066a227 and 430cdd4.

📒 Files selected for processing (12)
  • src/ProfilePage.tsx (4 hunks)
  • src/client/components/ui/alert-dialog.tsx (1 hunks)
  • src/client/components/ui/dialog.tsx (1 hunks)
  • src/dashboard/DashboardPage.tsx (1 hunks)
  • src/dashboard/components/plaid-integration.tsx (7 hunks)
  • src/legal/PrivacyPolicyPage.tsx (1 hunks)
  • src/legal/TermsOfServicePage.tsx (1 hunks)
  • src/plaid/components/plaid-consent-dialog.tsx (1 hunks)
  • src/plaid/operations.ts (15 hunks)
  • src/root/components/footer.tsx (5 hunks)
  • src/transactions/TransactionsPage.tsx (1 hunks)
  • src/transactions/components/transactions-list.tsx (2 hunks)
✅ Files skipped from review due to trivial changes (1)
  • src/transactions/TransactionsPage.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/client/components/ui/alert-dialog.tsx
  • src/legal/PrivacyPolicyPage.tsx
  • src/legal/TermsOfServicePage.tsx
🧰 Additional context used
🧬 Code graph analysis (5)
src/dashboard/components/plaid-integration.tsx (2)
src/dashboard/components/plaid-link-button.tsx (1)
  • PlaidLinkButton (61-239)
src/plaid/operations.ts (2)
  • createUpdateModeLinkToken (114-180)
  • exchangeUpdateModeToken (289-371)
src/transactions/components/transactions-list.tsx (3)
src/utils/categories.ts (3)
  • getCategoryIcon (75-78)
  • getPrettyCategoryName (40-42)
  • getCategoryCssVariable (45-51)
src/utils/dateUtils.ts (1)
  • formatTransactionDisplayDate (56-75)
src/lib/utils.ts (1)
  • cn (9-11)
src/ProfilePage.tsx (1)
src/user/operations.ts (2)
  • exportUserData (97-180)
  • deleteUserAccount (7-95)
src/plaid/operations.ts (3)
src/plaid/utils/encryption.ts (1)
  • decrypt (32-52)
src/plaid/service.ts (2)
  • _internalCreateLinkToken (22-64)
  • _internalExchangePublicToken (72-140)
src/plaid/client.ts (1)
  • plaidClient (34-34)
src/plaid/components/plaid-consent-dialog.tsx (2)
src/client/components/ui/dialog.tsx (6)
  • Dialog (112-112)
  • DialogContent (117-117)
  • DialogHeader (118-118)
  • DialogTitle (120-120)
  • DialogDescription (121-121)
  • DialogFooter (119-119)
src/client/components/ui/button.tsx (1)
  • Button (63-63)
🔇 Additional comments (14)
src/transactions/components/transactions-list.tsx (1)

3-3: Confirm motion/react import matches the animation library in use

The import looks fine assuming the project is actually using the motion package that exports both motion and AnimatePresence from 'motion/react'. If the app is still on framer-motion, this path would instead need to be 'framer-motion'. Please double‑check it aligns with your installed dependency.

src/root/components/footer.tsx (1)

39-42: Legal navigation and support links are wired cleanly

The new navigation.legal structure and its usage in both mobile and desktop layouts are straightforward and consistent with the existing navigation.main pattern. Using ScrollToTopLink for internal legal routes and a mailto: link for Support is appropriate. No issues from a routing or UX standpoint.

Also applies to: 111-129, 161-180

src/dashboard/components/plaid-integration.tsx (6)

13-13: LGTM!

The imports for update-mode operations and the new state management for reconnection and account toggling are correctly implemented and consistent with existing patterns.

Also applies to: 15-15, 72-77


98-98: LGTM!

Awaiting refetch() ensures the UI state is synchronized with the server response before clearing loading indicators. This prevents displaying stale data.

Also applies to: 151-151, 172-172


162-186: LGTM!

The refactored handleToggleAccount properly tracks per-account loading state and provides clear user feedback. The use of togglingAccountId prevents UI conflicts when toggling multiple accounts.


188-216: LGTM!

The reconnection handlers properly manage state cleanup and provide appropriate user feedback. Error handling for the refetch operation is well-implemented.


316-354: LGTM!

The update-mode reconnection flow is correctly implemented with proper error handling and state management. The inline async functions correctly extract and pass the required values to PlaidLinkButton.


328-333: Return type mismatch in createLinkTokenAction.

The createLinkTokenAction prop expects a function that returns a string (the link token), but you're returning the entire result object { linkToken: string } from createUpdateModeLinkToken.

Apply this diff to extract the linkToken:

 createLinkTokenAction={async () => {
   const result = await createUpdateModeLinkToken({
     institutionId: institution.id,
   })
   return result.linkToken
 }}

Wait, looking at the code again, line 332 already returns result.linkToken. Let me reconsider... Actually, the code is correct. The inline function already extracts and returns result.linkToken. Let me mark this for skip.

Likely an incorrect or invalid review comment.

src/plaid/operations.ts (6)

4-4: LGTM!

The imports and type definitions for update-mode operations are correctly structured and consistent with the Wasp framework patterns.

Also applies to: 6-6, 23-24, 103-108


114-180: LGTM!

The createUpdateModeLinkToken operation properly validates authorization, checks for null access tokens before decryption, and correctly enables Plaid Update Mode by passing the decrypted access token.


289-371: LGTM!

The exchangeUpdateModeToken operation correctly updates the institution credentials and triggers an immediate sync. The decision to log sync errors without failing the re-authentication is appropriate.


568-577: LGTM!

Automatically resetting the institution status to 'active' after successful sync enables graceful recovery from error states without requiring manual intervention.


760-760: LGTM!

The enhanced deletion flow properly deactivates the Plaid item while ensuring user data can still be deleted even if the Plaid API call fails. This prevents users from being blocked by external API issues.

Also applies to: 771-793


872-872: LGTM!

The clarifying comments about Plaid's amount convention prevent future confusion, and the addition of status and errorCode to the query result correctly exposes the re-authentication state to the UI.

Also applies to: 896-897, 1002-1002, 1022-1023, 1080-1080, 1103-1105, 1186-1201, 1223-1224

Improves Plaid error handling by adding more granular error codes for re-authentication.

Refactors account deletion flow to ensure navigation after logout, even if the logout process fails.

Adds fly.toml configuration files for both client and server, and ignores `env.prod.server` in `.gitignore`.
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: 0

🧹 Nitpick comments (3)
fly-server.toml (1)

19-22: Consider resource monitoring and scaling strategy for production workloads.

The VM specification allocates 1GB memory and 1 shared CPU core. While this is reasonable for initial deployment, monitor application performance under production load to determine if this is adequate for the Plaid integrations and user data export/deletion features mentioned in the PR objectives. Be prepared to scale resources if needed.

src/dashboard/components/plaid-integration.tsx (2)

399-402: Consider scoping button disabled state to per-institution busy status.

The action buttons are disabled when ANY institution is syncing or reconnecting (!!syncingInstitutionId || !!reconnectingInstitutionId), which prevents users from managing accounts of idle institutions while one is busy. Since the buttons are already hidden when the current institution is busy (line 389 checks !isBusy), the global disable check may be unnecessary.

Consider removing the global disabled checks if there's no risk of race conditions:

 <Button
   variant='ghost'
   size='icon'
   className='flex-shrink-0 text-muted-foreground hover:bg-muted hover:text-foreground'
   onClick={() =>
     setManageAccountsInstitutionId(institution.id)
   }
-  disabled={
-    !!syncingInstitutionId ||
-    !!reconnectingInstitutionId
-  }
 >
   <Gear />
 </Button>

Apply similar changes to the delete button at lines 415-418.

Also applies to: 415-418


499-505: Consider per-account disabled state instead of global.

The Switch is disabled for ALL accounts when ANY account is toggling (disabled={!!togglingAccountId}). This prevents users from toggling account A while account B is in progress, which may be overly restrictive.

Consider disabling only the specific account being toggled:

 <Switch
   checked={account.isTracked}
   onCheckedChange={checked =>
     handleToggleAccount(account.id, checked)
   }
-  disabled={!!togglingAccountId}
+  disabled={togglingAccountId === account.id}
 />

This would allow toggling multiple accounts if there's no risk of backend race conditions.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 430cdd4 and a32d81b.

📒 Files selected for processing (6)
  • .gitignore (1 hunks)
  • fly-client.toml (1 hunks)
  • fly-server.toml (1 hunks)
  • src/ProfilePage.tsx (4 hunks)
  • src/dashboard/components/plaid-integration.tsx (7 hunks)
  • src/plaid/operations.ts (15 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/ProfilePage.tsx
🧰 Additional context used
🧬 Code graph analysis (2)
src/dashboard/components/plaid-integration.tsx (2)
src/dashboard/components/plaid-link-button.tsx (1)
  • PlaidLinkButton (61-239)
src/plaid/operations.ts (2)
  • createUpdateModeLinkToken (114-180)
  • exchangeUpdateModeToken (289-371)
src/plaid/operations.ts (3)
src/plaid/utils/encryption.ts (1)
  • decrypt (32-52)
src/plaid/service.ts (2)
  • _internalCreateLinkToken (22-64)
  • _internalExchangePublicToken (72-140)
src/plaid/client.ts (1)
  • plaidClient (34-34)
🔇 Additional comments (19)
fly-client.toml (2)

9-10: Verify that build configuration is defined elsewhere.

The [build] section is empty, which means the build process must be configured through other means (e.g., a Dockerfile or fly deploy conventions). Confirm that the build configuration for spender-client is properly defined to ensure successful deployments.


6-17: Deployment configuration aligns with production readiness objectives.

The configuration appropriately sets the client service to auto-scale to zero machines (min_machines_running = 0) for cost optimization, enables HTTPS enforcement, and defines reasonable resource specifications for a client application. The internal port 8043 and auto-start/stop behavior are suitable for a development/staging client.

fly-server.toml (2)

9-10: Verify that build configuration is defined elsewhere.

The [build] section is empty, which means the build process must be configured through other means (e.g., a Dockerfile or fly deploy conventions). Confirm that the build configuration for spender-server is properly defined to ensure successful deployments.


6-17: Production-appropriate server configuration with high availability.

The configuration appropriately ensures the server is always running with min_machines_running = 1, enabling HTTPS enforcement, and maintaining reasonable resource specifications. The asymmetry with the client (which can scale to zero) is correct for a production backend architecture.

.gitignore (1)

17-17: Confirm the intended filename: env.prod.server or .env.prod.server?

The entry env.prod.server breaks the naming convention established by other dotenv entries (.env, .env.*, .env.example, .env.*.example). Additionally, the catch-all pattern .env.* on line 6 should already exclude .env.prod.server if that were the intended filename.

The codebase contains no references to either env.prod.server or .env.prod.server, so clarify:

  • If the file is named env.prod.server without a leading dot (and why it differs from convention), or
  • If it should be renamed to .env.prod.server for consistency.
src/dashboard/components/plaid-integration.tsx (6)

13-13: LGTM! Update-mode operations imported correctly.

The new imports for createUpdateModeLinkToken and exchangeUpdateModeToken support the reconnection flow and are used appropriately throughout the component.

Also applies to: 15-15


72-77: LGTM! Per-operation loading states added correctly.

The reconnectingInstitutionId and togglingAccountId state variables properly track loading states for reconnection and account toggling operations.


98-98: LGTM! Data synchronization improved with awaited refetch.

Awaiting refetch() after mutations ensures the UI displays up-to-date data before showing success messages. This prevents race conditions and stale data issues.

Also applies to: 151-151, 172-172


162-186: LGTM! Account toggling logic improved.

The refactored handleToggleAccount correctly accepts an explicit nextTracked parameter, manages loading state with togglingAccountId, and provides clear user feedback.


188-218: LGTM! Reconnection handlers implemented correctly.

The handleReconnectSuccess and handleReconnectError handlers properly manage state, refresh data, and provide user feedback. The unused parameters are acceptable as they maintain consistent callback signatures.


318-356: LGTM! Update-mode reconnection flow implemented correctly.

The reconnection alert and update-mode PlaidLinkButton are properly configured:

  • Async token creation and exchange callbacks correctly call update-mode operations
  • Loading state properly scoped to the reconnecting institution
  • isUpdateMode={true} enables appropriate consent dialog handling
src/plaid/operations.ts (8)

23-24: LGTM! Required utilities imported correctly.

The decrypt and plaidClient imports support update-mode operations and item removal functionality.


103-180: LGTM! Update-mode link token creation implemented securely.

The createUpdateModeLinkToken operation correctly:

  • Validates user authentication and input
  • Verifies institution ownership before allowing re-authentication
  • Checks for accessToken existence before decryption
  • Creates update-mode link token with decrypted access token
  • Handles errors appropriately

277-371: LGTM! Update-mode token exchange handles re-authentication robustly.

The exchangeUpdateModeToken operation correctly:

  • Verifies institution ownership
  • Exchanges public token for new encrypted access token
  • Resets status to 'active' and clears error codes
  • Triggers immediate sync without failing re-authentication if sync errors occur

This defensive approach ensures successful re-authentication even when sync encounters transient issues.


568-577: LGTM! Institution status automatically recovered on successful sync.

Marking the institution as 'active' and clearing error codes after successful sync is good defensive coding that automatically recovers from transient errors.


772-772: LGTM! Plaid item removal integrated with defensive error handling.

The institution deletion now properly calls Plaid's itemRemove API before database deletion. The defensive error handling ensures users can delete institutions even if the Plaid API call fails, while still logging errors for debugging. This follows Plaid's best practices.

Also applies to: 783-805


884-884: LGTM! Spending calculations correctly aligned with Plaid convention.

Removing Math.abs is correct since the queries filter for amount > 0 (expenses only per Plaid's convention: positive = money OUT, negative = money IN). The comments clearly document this convention.

Also applies to: 908-909, 1014-1014, 1034-1035, 1092-1092, 1115-1117


1199-1213: LGTM! Institution status and error code exposed for UI handling.

The GetInstitutionsResult type and query correctly expose status and errorCode fields, enabling the frontend to display appropriate reconnection UI when institutions require re-authentication.

Also applies to: 1235-1236


585-643: Remove the suggested error code additions—PENDING_DISCONNECT and PENDING_EXPIRATION are webhook codes, not error codes.

PENDING_DISCONNECT and PENDING_EXPIRATION are webhook codes that Plaid sends proactively to notify you of pending Item issues, not error codes returned in API error responses. These webhooks indicate that the Item should be re-initialized via update mode, but they would never appear in the error.response?.data?.error_code field that this code is checking.

The current error handling is correct as implemented. All six error codes mentioned in the original review (INVALID_CREDENTIALS, INSUFFICIENT_CREDENTIALS, ITEM_NOT_SUPPORTED, MFA_NOT_SUPPORTED, NO_ACCOUNTS, INSTITUTION_NO_LONGER_SUPPORTED) are still valid for 2025 and appropriately included in the requiresReauth array.

Likely an incorrect or invalid review comment.

This commit fixes critical issues with Plaid webhook handling and
transaction time display:

**Webhook Signature Verification Fix:**
- Capture raw request body before Express parsing for signature
  verification
- The body hash must match the exact bytes Plaid sent, not a
  re-stringified version
- Add verify callback to express.json() middleware to store raw body
- Fixes 401 "Webhook body hash mismatch" errors in production

**Transaction Time Display Fix:**
- Treat common default times (midnight, 4 AM, 5 AM UTC) as date-only
  values
- When institutions don't provide specific times, Plaid sends default
  times that shouldn't be displayed
- Show just the date for default times, preserve actual times for real
  transaction timestamps
- Display times in UTC to preserve original transaction time

**Webhook Handler Improvements:**
- Add handlers for INITIAL_UPDATE and HISTORICAL_UPDATE webhook codes
- These webhooks now trigger automatic transaction syncing
- Improves automatic transaction updates when connecting new
  institutions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
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: 2

🧹 Nitpick comments (3)
src/plaid/webhooks.ts (3)

42-53: Prefer decodeProtectedHeader from jose over manual base64 parsing of the JWT header.

Manually splitting on . and decoding with Buffer.from(..., 'base64') is more brittle (base64url vs base64, malformed tokens, etc.) than using jose’s built-in helper.

You can simplify and harden this part by letting jose parse the header:

-import { jwtVerify, importJWK } from 'jose'
+import { jwtVerify, importJWK, decodeProtectedHeader } from 'jose'
@@
-    // Step 1: Decode JWT header without verification to get kid (key ID)
-    const [headerB64] = jwtToken.split('.')
-    const header = JSON.parse(Buffer.from(headerB64, 'base64').toString())
+    // Step 1: Decode JWT header without verification to get kid (key ID)
+    const header = decodeProtectedHeader(jwtToken)
@@
-    if (header.alg !== 'ES256') {
+    if (header.alg !== 'ES256') {
@@
-    const keyResponse = await plaidClient.webhookVerificationKeyGet({
-      key_id: header.kid,
-    })
+    const keyResponse = await plaidClient.webhookVerificationKeyGet({
+      key_id: header.kid,
+    })

This keeps the logic the same while deferring header parsing to jose’s trusted implementation.


125-135: Handle Plaid-Verification header as string | string[] for robustness.

req.headers['plaid-verification'] can be string | string[] | undefined; the current cast to string works in the common case but is a bit optimistic. Consider normalizing it like this:

-  const jwtToken = req.headers['plaid-verification'] as string
-  const rawBody = (req as any).rawBody || JSON.stringify(req.body)
+  const plaidVerificationHeader = req.headers['plaid-verification']
+  const jwtToken = Array.isArray(plaidVerificationHeader)
+    ? plaidVerificationHeader[0]
+    : plaidVerificationHeader
+  const rawBody = (req as any).rawBody || JSON.stringify(req.body)

The subsequent if (!jwtToken) check then naturally handles the undefined case, while avoiding surprises if multiple headers are ever present.


146-187: Dynamic import of syncTransactions should handle import() rejection explicitly.

The async import('./operations.js').then(...) correctly keeps the webhook response fast by not awaiting syncTransactions, and you already catch errors from syncTransactions itself. However, if the import() fails (e.g., bundling/runtime issue), that rejection is currently unhandled.

You can add an outer .catch to ensure all failures are logged cleanly:

-          // Trigger async sync - don't await to avoid webhook timeout
-          // Import syncTransactions dynamically to avoid circular dependencies
-          import('./operations.js').then(({ syncTransactions }) => {
-            syncTransactions({ institutionId: institution.id }, {
-              user: { id: institution.userId },
-              entities: prisma,
-            } as any).catch(error => {
-              console.error(
-                `Error syncing transactions for institution ${institution.id}:`,
-                error,
-              )
-            })
-          })
+          // Trigger async sync - don't await to avoid webhook timeout.
+          // Import syncTransactions dynamically to avoid circular dependencies.
+          import('./operations.js')
+            .then(({ syncTransactions }) => {
+              return syncTransactions(
+                { institutionId: institution.id },
+                {
+                  user: { id: institution.userId },
+                  entities: prisma,
+                } as any,
+              ).catch(error => {
+                console.error(
+                  `Error syncing transactions for institution ${institution.id}:`,
+                  error,
+                )
+              })
+            })
+            .catch(error => {
+              console.error(
+                `Error loading Plaid operations module for institution ${institution.id}:`,
+                error,
+              )
+            })

This keeps the non‑blocking behavior while avoiding silent failures if the module load itself breaks.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a32d81b and ef6836a.

📒 Files selected for processing (2)
  • src/plaid/webhooks.ts (4 hunks)
  • src/utils/dateUtils.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/plaid/webhooks.ts (1)
src/plaid/client.ts (1)
  • plaidClient (34-34)
🔇 Additional comments (3)
src/utils/dateUtils.ts (1)

50-51: LGTM: Comments accurately reflect UTC handling.

The updated comments clearly describe the UTC-based behavior and default time detection.

src/plaid/webhooks.ts (2)

11-22: Raw body capture middleware looks correct for signature verification.

Using express.json’s verify hook to stash req.rawBody before parsing is a solid way to preserve the exact payload for Plaid’s body hash check; this integration with the Wasp middlewareConfig.set('express.json', …) API looks good.


85-105: Constant‑time body hash comparison is correctly implemented.

The move to compute bodyHash and compare it with request_body_sha256 using crypto.timingSafeEqual, with a prior length check, addresses the timing‑attack concern from the previous review and matches Plaid’s body‑hash verification guidance.

The note about needing the exact raw body (canonical JSON, no extra whitespace) is also helpful for future maintainers.

Strengthens Plaid webhook JWT validation by explicitly checking for a valid `iat` claim in addition to verifying token age.

Improves transaction date display by:
- Defining Plaid's default time values for better identification.
- Using a list to check for default times.
- Adding 'UTC' suffix to displayed times for clarity.
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: 2

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ef6836a and 20d621f.

📒 Files selected for processing (2)
  • src/plaid/webhooks.ts (4 hunks)
  • src/utils/dateUtils.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/utils/dateUtils.ts
🧰 Additional context used
🧬 Code graph analysis (1)
src/plaid/webhooks.ts (1)
src/plaid/client.ts (1)
  • plaidClient (34-34)
🔇 Additional comments (5)
src/plaid/webhooks.ts (5)

1-10: LGTM - Proper imports for JWT verification.

The imports correctly include jose for ES256 JWT verification and the Plaid client for dynamic key retrieval, aligning with Plaid's documented webhook verification flow.


11-24: LGTM - Raw body capture for hash verification.

The middleware correctly captures the raw request body before JSON parsing, which is essential for computing the SHA-256 hash that matches Plaid's request_body_sha256 claim.


34-38: LGTM - Development bypass with clear warning.

Skipping verification in development mode is reasonable for local testing, and the warning ensures developers are aware that production will enforce full JWT verification.


71-113: Excellent - Critical security issues resolved.

Both critical security issues from the previous review have been properly addressed:

  1. Timing attack mitigation (lines 99-113): Now uses crypto.timingSafeEqual() with proper buffer conversion and length validation, preventing timing-based attacks on the body hash comparison.

  2. Robust iat validation (lines 73, 76-91): The implementation uses maxTokenAge: '5m' in jwtVerify() for built-in enforcement, plus defense-in-depth manual validation that ensures iat is present, numeric, and finite. The Math.abs() check also defends against future-dated tokens.

The implementation now correctly follows Plaid's JWT verification requirements with proper security safeguards.


154-199: LGTM - Proper async webhook handling.

The webhook handling correctly:

  • Responds immediately to avoid timeouts (line 194)
  • Triggers transaction sync asynchronously (lines 182-192)
  • Uses dynamic import to avoid circular dependencies
  • Includes proper error handling in the promise chain

The expanded webhook code coverage for SYNC_UPDATES_AVAILABLE, INITIAL_UPDATE, and HISTORICAL_UPDATE aligns with Plaid's transaction sync patterns.

Ensures the `request_body_sha256` claim in Plaid webhook JWTs is a valid SHA-256 hex digest.

It also handles cases where the claim is missing or is of an invalid data type, preventing potential verification failures. Also ensures the raw request body is correctly extracted for JWT validation.
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: 1

🧹 Nitpick comments (2)
src/plaid/webhooks.ts (2)

174-211: Add a top‑level .catch on the dynamic import to prevent unhandled rejections.

The current code only catches errors from syncTransactions(), but if import('./operations.js') itself fails, the rejection goes unhandled. Chaining .catch() directly to the import() call ensures both import and execution errors are caught.

-          // Trigger async sync - don't await to avoid webhook timeout
-          // Import syncTransactions dynamically to avoid circular dependencies
-          import('./operations.js').then(({ syncTransactions }) => {
-            syncTransactions({ institutionId: institution.id }, {
-              user: { id: institution.userId },
-              entities: prisma,
-            } as any).catch(error => {
-              console.error(
-                `Error syncing transactions for institution ${institution.id}:`,
-                error,
-              )
-            })
-          })
+          // Trigger async sync - don't await to avoid webhook timeout
+          // Import syncTransactions dynamically to avoid circular dependencies
+          import('./operations.js')
+            .then(({ syncTransactions }) => {
+              return syncTransactions(
+                { institutionId: institution.id },
+                {
+                  user: { id: institution.userId },
+                  entities: prisma,
+                } as any,
+              )
+            })
+            .catch(error => {
+              console.error(
+                `Error loading or running syncTransactions for institution ${institution.id}:`,
+                error,
+              )
+            })

41-69: Use decodeProtectedHeader from jose for more robust JWT header decoding and add kid validation.

Manual JWT header decoding with Buffer.from(..., 'base64') bypasses base64url validation that the JWS spec requires. Replace with decodeProtectedHeader(jwtToken) from jose, and validate kid before calling Plaid to avoid unnecessary API calls with missing or invalid key IDs.

-import { jwtVerify, importJWK } from 'jose'
+import { jwtVerify, importJWK, decodeProtectedHeader } from 'jose'

-    // Step 1: Decode JWT header without verification to get kid (key ID)
-    const [headerB64] = jwtToken.split('.')
-    const header = JSON.parse(Buffer.from(headerB64, 'base64').toString())
+    // Step 1: Decode JWT header without verification to get kid (key ID)
+    const header = decodeProtectedHeader(jwtToken)

     // Step 2: Verify algorithm is ES256
     if (header.alg !== 'ES256') {
       console.error(`Invalid JWT algorithm: ${header.alg}, expected ES256`)
       return false
     }

+    // Step 2a: Validate kid before API call
+    if (!header.kid || typeof header.kid !== 'string') {
+      console.error('Webhook JWT header missing or invalid kid')
+      return false
+    }
+
     // Step 3: Get verification key from Plaid using the kid
     const keyResponse = await plaidClient.webhookVerificationKeyGet({
       key_id: header.kid,
     })
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 20d621f and 8b21e89.

📒 Files selected for processing (1)
  • src/plaid/webhooks.ts (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/plaid/webhooks.ts (1)
src/plaid/client.ts (1)
  • plaidClient (34-34)
🔇 Additional comments (2)
src/plaid/webhooks.ts (2)

71-132: JWT freshness + body-hash verification implementation confirmed and correct.

The code properly implements defense-in-depth webhook verification:

  • jose.jwtVerify with maxTokenAge: '5m' enforces a 5-minute window based on the JWT's iat claim
  • Plaid's webhook JWT payload includes request_body_sha256 which should be compared against the SHA-256 hash of the webhook body
  • The explicit iat validation, SHA-256 hashing, and crypto.timingSafeEqual constant-time comparison align with Plaid's documented webhook verification requirements and prevent replay/timing attacks.

11-22: Now let me search the codebase to verify the middleware registration and rawBody usage patterns:

Based on the web search results, I've confirmed that Express middleware and routes are evaluated in the order they are registered, with middleware declared before routes running for all routes. The pattern shown in your code—using bodyParser.json({ verify: (req, res, buf, encoding) => { req.rawBody = buf.toString(...) } })—is the recommended approach for webhook signature verification.

Plaid webhook verification requires computing the SHA-256 of the webhook body using constant-time comparison, and is sensitive to whitespace with 2-space tab-spacing. This confirms the review comment's approval of the raw-body capture approach is correct.

No actionable issues remain; the implementation pattern is sound. The code correctly uses Express's middleware verify callback to capture the raw body before JSON parsing—this runs before route handlers if registered before routes. The main verification concern (that plaidWebhookMiddlewareConfigFn is guaranteed to run) depends on deployment configuration being correct, which is outside the scope of code review.

Ensures the code correctly handles cases where NODE_ENV is explicitly undefined, preventing potential issues with default environment configurations.
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: 0

🧹 Nitpick comments (4)
src/plaid/webhooks.ts (4)

14-22: Tighten req typing in the rawBody capture middleware.

The raw body capture is correct and essential for Plaid’s body-hash check. To avoid any and get better type safety, you can type req as Request & { rawBody?: string }:

-        verify: (req: any, _res, buf) => {
+        verify: (req: Request & { rawBody?: string }, _res, buf) => {
           // Store raw body buffer for signature verification
           req.rawBody = buf.toString('utf8')
         },

This keeps the behavior but improves TS correctness.


27-136: JWT webhook verification flow is solid; consider using decodeProtectedHeader and optional key caching.

The overall flow (alg guard, Plaid key fetch, ES256 verify, strict iat checks, SHA‑256 body hash with timingSafeEqual) aligns well with Plaid’s guidance and is security‑sound. Two optional refinements:

  1. Use jose’s header decoder instead of manual base64/JSON parsing.

This avoids manual split('.') + Buffer.from(..., 'base64') and relies on jose’s base64url handling:

-import { jwtVerify, importJWK } from 'jose'
+import { jwtVerify, importJWK, decodeProtectedHeader } from 'jose'
...
-    // Step 1: Decode JWT header without verification to get kid (key ID)
-    const [headerB64] = jwtToken.split('.')
-    const header = JSON.parse(Buffer.from(headerB64, 'base64').toString())
+    // Step 1: Decode JWT header without verification to get kid (key ID)
+    const header = decodeProtectedHeader(jwtToken)
  1. Optionally cache Plaid verification keys by kid.

If webhook volume grows, you might add a small in‑memory cache (e.g., Map<string, JWK> with a TTL) around plaidClient.webhookVerificationKeyGet to reduce latency and external dependency load. Not required for correctness, just a performance/resiliency improvement.

The extra manual iat age check on top of maxTokenAge: '5m' is reasonable defense‑in‑depth.


150-160: Normalize Plaid-Verification header to a string before verification.

req.headers['plaid-verification'] can be string | string[] | undefined. Passing a string[] into verifyPlaidWebhook would cause .split('.') to throw. You can normalize once up front:

-  const jwtToken = req.headers['plaid-verification'] as string
+  const headerValue = req.headers['plaid-verification']
+  const jwtToken = Array.isArray(headerValue) ? headerValue[0] : headerValue
   const rawBody = (req as any).rawBody ?? JSON.stringify(req.body)

   if (!jwtToken) {

This keeps the behavior but guards against multiple header values.


171-215: Handle errors from the dynamic import of syncTransactions to avoid unhandled rejections.

The async, fire‑and‑forget syncTransactions trigger is appropriate to avoid webhook timeouts, but if import('./operations.js') itself rejects (e.g., transient load error), that rejection isn’t currently handled. You can add a .catch on the import promise:

-          import('./operations.js').then(({ syncTransactions }) => {
-            syncTransactions({ institutionId: institution.id }, {
-              user: { id: institution.userId },
-              entities: prisma,
-            } as any).catch(error => {
-              console.error(
-                `Error syncing transactions for institution ${institution.id}:`,
-                error,
-              )
-            })
-          })
+          import('./operations.js')
+            .then(({ syncTransactions }) => {
+              return syncTransactions(
+                { institutionId: institution.id },
+                {
+                  user: { id: institution.userId },
+                  entities: prisma,
+                } as any,
+              ).catch(error => {
+                console.error(
+                  `Error syncing transactions for institution ${institution.id}:`,
+                  error,
+                )
+              })
+            })
+            .catch(error => {
+              console.error(
+                `Error loading syncTransactions module for institution ${institution.id}:`,
+                error,
+              )
+            })

This keeps the non‑blocking behavior while making failures more observable and avoiding unhandled promise rejections.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8b21e89 and 6d48b30.

📒 Files selected for processing (1)
  • src/plaid/webhooks.ts (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/plaid/webhooks.ts (1)
src/plaid/client.ts (1)
  • plaidClient (34-34)
🔇 Additional comments (1)
src/plaid/webhooks.ts (1)

6-9: JWT verification imports and NODE_ENV handling look good.

Using jose for JWT verification and dropping the default 'development' fallback for NODE_ENV ensures verification won’t be silently skipped in misconfigured environments. No changes needed here.

Introduces WASP_WEB_CLIENT_URL environment variable, which lets the client app run on a different port.

It allows overriding the default client URL for Stripe integration,
making it easier to develop and test in different environments.
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: 0

🧹 Nitpick comments (1)
src/stripe/service.ts (1)

9-12: Approve fallback chain; consider adding URL validation and production safeguards.

The environment variable precedence and fallback to localhost for development is correct. However, consider these improvements for production readiness:

  1. Validate URL format: Stripe requires absolute HTTPS URLs in production. Invalid URLs will cause runtime failures in checkout flows.
  2. Warn on localhost in production: The fallback to localhost:3000 could mask misconfiguration in production environments.

Apply this diff to add validation and production warnings:

 const CLIENT_URL =
   process.env.WASP_WEB_CLIENT_URL ||
   process.env.CLIENT_URL ||
   'http://localhost:3000'
+
+// Validate CLIENT_URL format
+try {
+  const url = new URL(CLIENT_URL)
+  if (process.env.NODE_ENV === 'production' && !url.protocol.startsWith('https')) {
+    console.warn('Warning: CLIENT_URL should use HTTPS in production')
+  }
+  if (process.env.NODE_ENV === 'production' && url.hostname === 'localhost') {
+    console.error('Error: CLIENT_URL is set to localhost in production environment')
+  }
+} catch (error) {
+  throw new Error(`Invalid CLIENT_URL: ${CLIENT_URL}. Must be a valid absolute URL.`)
+}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6d48b30 and 10cd8dd.

📒 Files selected for processing (2)
  • prisma.config.ts (1 hunks)
  • src/stripe/service.ts (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • prisma.config.ts

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.

1 participant