- Backend refund endpoint: allow refunds on paid invoices without stripePaymentIntentId (manual refund path)
- Backend GET /invoices/🆔 inline fetch cardLast4 + paymentStatus from Stripe when stripePaymentIntentId present
- Frontend: show Refund button on all paid invoices for managers (not just Stripe-backed ones)
- Seed: add stripePaymentIntentId (pi_test_*) to ~20% of paid invoices for Stripe-path testing
cc @cpfarhood
Cherry-pick of 628ed34 to fix @typescript-eslint/no-unused-vars
error on PR #351 Lint & Typecheck.
The issueRefund function was defined but never called. This commit:
- Removes the inline async onClick handler that bypassed issueRefund
- Wires the Refund button to open setShowRefundDialog(true) instead
- Uses issueRefund function (with refundAmount/refundError/refunding state)
- Adds manager role check before showing refund button
- Shows "Refunded" badge when invoice.stripeRefundId is set
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(gro-609): include stripePaymentIntentId in invoice list and wrap stats endpoint in try/catch
- Add stripePaymentIntentId to the GET /invoices list query so the refund button
renders when seed data includes a payment intent ID
- Wrap /api/invoices/stats/summary in try/catch so errors return 200 with zero
defaults instead of 5xx, preventing the Invoices page from crashing on
mount for groomer-role sessions
Parent: GRO-882
Grandparent: GRO-816
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(gro-609): add payment stats to admin dashboard (AppointmentsPage)
- Fetch /api/invoices/stats/summary on mount and display Revenue/Outstanding/Refunds
summary cards above the calendar view on /admin
- Mirrors the same stats section already on /admin/invoices
- Gracefully handles errors via try/catch on the stats endpoint
Parent: GRO-882
Grandparent: GRO-816
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Add GET /api/branding/logo as a public endpoint that proxies logo bytes
from S3, and change /api/branding to return logoUrl: "/api/branding/logo"
instead of calling getPresignedGetUrl(). Eliminates mixed-content warnings
when the branding context is consumed on unauthenticated pages (portal,
login).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
ESLint @typescript-eslint/no-unused-vars flagged the import.
The logo proxy no longer uses pre-signed GET URLs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
c.body() signature only accepts string | ArrayBuffer | ReadableStream | Uint8Array
in Hono 4.x, not Node.js Buffer. Return a plain Response directly instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
transformToBuffer() does not exist on StreamingBlobPayloadOutputTypes
in the AWS SDK v3 client. Use for-await-of over the async iterable body
to collect chunks and Buffer.concat instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
All logo S3 interactions are now server-proxied:
- GET /api/admin/settings/logo streams image bytes directly instead of
returning a presigned S3 URL to the browser
- Upload already went through POST /api/admin/settings/logo/upload
- Frontend uses relative /api/admin/settings/logo path as img src,
never a raw S3 URL
- Appends cache-buster query param (?t=Date.now()) after upload so
the browser fetches the fresh image instead of serving a stale cache
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The PR refactored appointments response from { upcoming, past } to
{ appointments: [] } but the `now` variable used to compute those
filters was left behind. ESLint correctly flags it as unused.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- PetProfiles.tsx: update AppointmentsResponse interface to use flat
appointments[] array instead of { upcoming, past }
- PetProfiles.tsx: update petHistory filter to use appointments.appointments
with date filter for past-only appointments
- portal.ts: change /api/portal/appointments response to { appointments: [] }
instead of { upcoming: [], past: [] }
- portal.ts: change /api/portal/pets response field names to match frontend
Pet interface: weightKg→weight, dateOfBirth→birthDate, photoKey→photoUrl,
groomingNotes→notes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
1. Refund stats now sum actual refund amounts from refunds table
instead of incorrectly summing tip_cents from invoices table.
2. Stripe payment_intents.retrieve now expands payment_method
so card.last4 is correctly available instead of null.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Add GET /api/invoices/:id/stripe-details endpoint to fetch card last4 and
payment status from Stripe
- Add getPaymentIntentDetails() to payment service
- Fix stats summary query to filter by startOfMonth
- Add cardLast4, paymentStatus, stripeRefundId transient fields to Invoice type
- Display Stripe details (card last4, payment status, refund status) in modal
- Add stripeRefundId and paymentFailureReason to Invoice schema (was missing in dev types)
Ref: GRO-609
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Both tip split persistence (delete + insert) and the invoice PATCH update
are now inside one db.transaction() block. If the invoice update fails
after splits are written, the entire operation rolls back.
Also removed unnecessary eslint-disable comment on _tipSplits.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Use body.tipCents ?? current.tipCents for validation condition
so that simultaneous status=paid + tipCents=0 skip split validation
- Use body.tipCents (now aliased as tipCents) instead of current.tipCents
inside the atomic transaction for shareCents calculation
- Add explicit check for empty tipSplits array with appropriate error
message ("Tip splits are required when tip amount is greater than zero")
before the sum-to-100% check
- Destructure tipSplits out of body before spreading into update object
to prevent it from leaking into the invoices table SET clause
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- When body.tipSplits is provided in PATCH /invoices/:id, validate sum
first then atomically replace existing splits (delete + insert)
- When no incoming splits, validate existing DB splits with corrected
message: "Tip splits are required when tip amount is greater than zero"
(previously misleading "must sum to 100%" when no splits existed)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- PATCH /invoices/:id returns 400 when tipCents > 0 but no tip splits
exist or splits don't sum to 100%
- POST /invoices/:id/tip-splits now returns 400 (not 422) on validation
failure via router-level ZodError handler
Co-Authored-By: Paperclip <noreply@paperclip.ing>
GRO-778 QA found that the previous commit only added a misleading comment;
the portalRouter.post("/dev-session") handler remained at line ~476, well
after portalRouter.use("/*", validatePortalSession, portalAudit) at line 16.
In Hono, use() applies only to routes registered AFTER it.
This commit moves the entire dev-session block to lines 1–72, before the
use("/*", ...) call, so the exemption actually takes effect.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Raise the Better Auth rate limit from max:10/window:60 to max:100/window:10
to match library defaults, and exempt /get-session from rate limiting entirely
via customRules (returns null = no rate limit check).
Both AUTH_DISABLED and production rateLimit blocks updated.
Co-authored-by: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Route ordering: /dev-session is registered after portalRouter.use("/*")
so it is NOT subject to the validatePortalSession/portalAudit middleware
chain — this is correct Hono behaviour since use() only applies to routes
registered after it.
The /dev-session POST endpoint creates the impersonation session and
cannot have a valid X-Impersonation-Session-Id header at call time.
Without this exemption, POST /api/portal/dev-session returns 401 before
the handler runs, breaking all portal pages when AUTH_DISABLED=true.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(GRO-766): prevent horizontal overflow on portal mobile pages
- Add overflow-x-hidden to main content area in CustomerPortal
- Add w-full overflow-hidden to content wrapper div
- Add flex-wrap to BillingPayments tab button row
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(GRO-769): proxy logo uploads through API server to fix mixed content
The pre-signed URL flow used an internal HTTP endpoint for S3 uploads,
which browsers blocked as mixed content on HTTPS pages. Instead of
generating a pre-signed URL that the browser uploads to directly,
the new /logo/upload endpoint receives the file via multipart POST
and streams it to S3 from the API server using the internal endpoint.
This resolves the mixed content error that was blocking logo uploads
on dev.groombook.dev.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
- Extend updateInvoiceSchema to accept optional tipSplits array in PATCH body
- Validate tip splits sum to 100% (10000 bps) when marking paid with tipCents > 0
- Return 422 if tipSplits not provided and no existing splits in DB
- Save tip splits atomically in same DB transaction as invoice status update
- Update frontend markPaid() to send tipSplits in PATCH body instead of separate POST
- Remove non-atomic POST /tip-splits call from markPaid flow
Co-authored-by: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
- Add idx_appointments_client_id, idx_appointments_staff_id,
idx_appointments_start_time, idx_appointments_status to schema.
Migration 0029 already handles the DB side; this brings schema.ts
in sync so drizzle-kit push is clean going forward.
- Wrap deleteObject calls in try/catch (POST /photo/confirm and
DELETE /:petId/photo endpoints) so S3 failures don't abort the
DB update — orphaned objects are logged as warnings instead.
Co-authored-by: Test User <test@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Defensive validation in /api/branding ensures base64-encoded logo content
matches its declared MIME type by checking image magic bytes (PNG, JPEG,
GIF, WebP). If the content doesn't match, the legacy base64 fields are
nulled out before returning to prevent MIME type confusion attacks.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Use Drizzle's inArray() instead of raw sql template with = ANY()
to avoid PostgreSQL array binding issues in the reminder scheduler
bulk sent-check query.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Move hostname validation to run AFTER OIDC_INTERNAL_BASE replacement
(was checking raw discovery URLs before replacement caused false positives)
- Only validate authorizationUrl hostname against issuer; token/userinfo
are server-to-server and may legitimately use internal hostnames
- Infra: add OIDC_INTERNAL_BASE env var to dev overlay (was missing, matches UAT)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Add SMS opt-in fields to clients schema (smsOptIn, smsConsentDate, smsOptOutDate, smsConsentText)
- Add channel column to reminderLogs with per-channel idempotency
- Create SMS service with Telnyx SDK integration and E.164 validation
- Update reminders service to conditionally send SMS to opted-in clients
- Add TCPA opt-out text to SMS reminders
- Graceful degradation: catch SMS errors without blocking email
- Fix: use clients.phone instead of non-existent clients.phoneE164
- Update clients route to expose SMS fields in API
- Add telnyx dependency to API package
- Create database migration 0028_sms_reminders
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* Fix invoice status transitions, tip-split validation, refund idempotency, and tip-split response format
- Add ALLOWED_TRANSITIONS state machine for invoice status changes (GRO-637)
- Replace floating-point tip-split validation with integer basis-points math
- Add idempotency key support to refund endpoint with new refunds table
- Return full invoice shape from POST /:id/tip-splits matching GET response
- All existing tests pass
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(invoices): wrap refund flow in transaction for idempotency safety
- Wrap idempotency check + processRefund() + db.insert() in db.transaction()
- This prevents duplicate Stripe refunds if the DB insert fails after Stripe processes the refund
- Add migration 0027_refunds for the refunds table (was missing)
- Removes out-of-scope changes from PR #278 (csrf.ts, appointmentGroups, appointments, book, groomingLogs, services, stripe-webhooks)
Fixes GRO-637 per CTO review
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* fix(api): wire up CSRF middleware for protected routes
Register csrfMiddleware in the protected API routes after authMiddleware
and resolveStaffMiddleware to protect against CSRF attacks on state-
changing operations (POST, PUT, PATCH, DELETE).
Addresses CTO review feedback on PR #278.
* fix(api): remove CSRF middleware that breaks POST/PUT/PATCH/DELETE
The CSRF middleware requires x-csrf-token header but the frontend never
sends it, which would break all mutating operations with 403 errors.
CSRF protection should be implemented in a separate coordinated PR with
frontend changes.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Flea Flicker <flea-flicker@groombook.ai>
Auto-link staff records by email when userId is NULL on first authenticated request.
Resolves GRO-667 UAT 403 blocker.
Co-Authored-By: Flea Flicker <noreply@anthropic.com>
Adds Zod validation across 5 API routes:
1. invoices GET / — query param validation (uuid, enum, int bounds)
2. book POST / — future-time refinement on startTime
3. appointments — recurrence series capped at 1 year
4. services — durationMinutes capped at 480 (8 hours)
5. stripe-webhooks — UUID validation on invoice IDs before DB lookup
Closes GRO-636
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Add SQL-level LIMIT/OFFSET pagination to churn risk query
- Add separate COUNT(*) subquery for total without fetching all rows
- Accept page and limit query params with sensible defaults and bounds
- Return page, limit, and churnRiskTotal in response
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- Replace SELECT-then-UPDATE with atomic UPDATE ... WHERE token=? AND status='pending' RETURNING *
to prevent confirmation token replay attacks (TOCTOU race condition)
- Fix requireRoleOrSuperUser() error message: swap the conditional branches so
'Forbidden: super user privileges required' is returned when user lacks role,
and 'Forbidden: role X is not permitted' when user is not superuser
- Add 'and' mock export to confirmation.test.ts and rbac.test.ts for new query patterns
- Update test expectations to match corrected error message semantics