Compare commits

...

19 Commits

Author SHA1 Message Date
Test User daf8a7bd56 fix(GRO-693): add UAT Terraform infrastructure with correct GitRepository
- Add terraform workspace at apps/overlays/uat/terraform/ (backend.tf,
  main.tf, variables.tf, users.tf, imports.tf, terraform.tfvars)
- Add Terraform CRD (authentik-terraform.yaml) with correct path
  ./apps/overlays/uat/terraform relative to groombook/app repo root
- Add GitRepository CRD (gitrepository-groombook.yaml) pointing to
  groombook/app at fix/gro-844-network-policy branch (NOT groombook/infra
  which no longer exists)
- Add kustomization.yaml to tie it together

Root cause: the GitRepository was pointing to https://github.com/groombook/infra
which no longer exists, and the terraform files were not committed to the
current repository at the correct path.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 19:55:35 +00:00
the-dogfather-cto[bot] 3c366ccc46 Merge pull request #346 from groombook/fix/gro-816-portal-pets-crash
fix(GRO-816): fix PetProfiles crash from appointments response shape change
2026-04-19 11:02:07 +00:00
Test User ff149f75dc fix(GRO-816): remove unused 'now' variable from portal.ts appointments handler
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>
2026-04-19 10:52:13 +00:00
Flea Flicker 03bd2d0235 fix(GRO-816): update PetProfiles.tsx to use new appointments response shape
- 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>
2026-04-19 08:13:53 +00:00
the-dogfather-cto[bot] 4f85a4a432 feat(gro-609): add refund handling and payment stats to admin (#341)
feat(gro-609): add refund handling and payment stats to admin
2026-04-19 02:05:06 +00:00
Test User 560d33edf8 fix(gro-609): fix two bugs found by CTO review
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>
2026-04-19 01:55:32 +00:00
Test User 50e9e70935 feat(gro-609): add Stripe details to invoice modal and fix stats date filter
- 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>
2026-04-19 01:02:49 +00:00
Test User d59cb1ab1d feat(gro-609): add refund handling and payment stats to admin
- Add stripePaymentIntentId to Invoice schema and types
- Add POST /api/invoices/:id/refund endpoint (Stripe placeholder)
- Add GET /api/invoices/stats/summary for payment analytics
- Add refund button + dialog (full/partial) to InvoiceDetailModal
- Add payment stats cards to Invoices page (revenue, outstanding, refunds, method breakdown)

Ref: GRO-609
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 00:59:18 +00:00
groombook-engineer[bot] 740e46baf2 Merge pull request #340 from groombook/fix/gro-805-invoices-rbac
Merge groomer RBAC fix into dev. cc @cpfarhood
2026-04-18 11:00:57 +00:00
Test User b1b89966d9 fix: allow groomer role to access invoices endpoint
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 10:36:23 +00:00
the-dogfather-cto[bot] 25fd3308e0 chore(GRO-720): harden .gitignore against agent runtime leaks (#338)
chore(GRO-720): harden .gitignore against agent runtime leaks
2026-04-18 10:23:44 +00:00
lint-roller-qa[bot] be07c8b758 fix(GRO-666): leave staff.user_id NULL in seed so middleware can auto-link by email (#312)
fix(GRO-666): leave staff.user_id NULL in seed so middleware can auto-link by email
2026-04-18 10:18:38 +00:00
Flea Flicker ff2851eda2 chore(GRO-720): harden .gitignore against agent runtime leaks
- Add .gh-token, *.gh-token to block token files
- Add .config/gh/ and **/.config/gh/ to block gh CLI config dirs
- Add infra-repo and infra-repo/ to block infra checkouts
- Add **/instructions/.gh-token to block per-agent token files
- Add **/AGENT_HOME/** and $AGENT_HOME/** to block agent home dirs
- Add .claude/ and .codex/ to block runtime directories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 10:18:29 +00:00
scrubs-mcbarkley-ceo[bot] 460ba78112 Merge pull request #334 from groombook/uat
promote: uat → main (GRO-778, GRO-773, GRO-766, GRO-743)
2026-04-17 22:51:38 +00:00
the-dogfather-cto[bot] 1cc6d53546 promote: dev → uat (GRO-766, GRO-743, GRO-773, GRO-778)
promote: GRO-766, GRO-743, GRO-773, GRO-778 fixes to UAT
2026-04-17 22:09:40 +00:00
groombook-engineer[bot] 1da61fb466 Merge pull request #326 from groombook/dev
promote: dev → uat (GRO-769 S3 mixed content fix)
2026-04-17 17:19:40 +00:00
the-dogfather-cto[bot] e539b6c904 Merge pull request #324 from groombook/dev
promote: dev → uat (GRO-642 modal a11y + GRO-751 tip split validation)
2026-04-17 16:05:37 +00:00
the-dogfather-cto[bot] 9eb86004fc chore(uat): promote dev → uat (GRO-628 + GRO-749 + GRO-639)
chore(uat): promote dev → uat (GRO-628 + batched)
2026-04-17 12:31:53 +00:00
Test User 7f715ecdfc fix(GRO-666): leave staff.user_id NULL in seed so middleware can auto-link by email
The resolveStaffMiddleware auto-links on first API call when staff.user_id
IS NULL. Setting userId at seed time blocks this path since Better-Auth's
user.id is opaque and unknown pre-auth. Remove userId from all staff inserts
so the middleware can populate it on first authenticated call.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-17 02:42:06 +00:00
18 changed files with 643 additions and 14 deletions
+13
View File
@@ -8,3 +8,16 @@ dist/
.turbo/
coverage/
minimax-output/
# Agent runtime artifacts — never commit
.gh-token
*.gh-token
.config/gh/
**/.config/gh/
infra-repo
infra-repo/
**/instructions/.gh-token
**/AGENT_HOME/**
$AGENT_HOME/**
.claude/
.codex/
+1 -1
View File
@@ -202,7 +202,7 @@ api.on(["POST", "PATCH", "DELETE"], "/staff/*", requireRoleOrSuperUser("manager"
api.use("/admin/*", requireRoleOrSuperUser("manager"));
api.use("/admin/settings/*", requireSuperUser());
api.use("/reports/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager"));
api.use("/invoices/*", requireRole("manager", "groomer"));
api.use("/impersonation/*", requireRole("manager"));
// Manager + Receptionist only (groomers have no access): appointment-groups, grooming-logs, waitlist
+66 -1
View File
@@ -422,7 +422,7 @@ invoicesRouter.patch(
// ─── Refund ───────────────────────────────────────────────────────────────────
import { processRefund } from "../services/payment.js";
import { processRefund, getPaymentIntentDetails } from "../services/payment.js";
const refundSchema = z.object({
amountCents: z.number().int().nonnegative().optional(),
@@ -477,3 +477,68 @@ invoicesRouter.post(
});
}
);
// Payment stats for admin dashboard
invoicesRouter.get("/stats/summary", async (c) => {
const db = getDb();
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [revenueResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
const [outstandingResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices)
.where(eq(invoices.status, "pending"));
const [refundsResult] = await db
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
.from(refunds)
.where(sql`${refunds.createdAt} >= ${startOfMonth}`);
const methodBreakdown = await db
.select({
method: invoices.paymentMethod,
total: sql<number>`count(*)`,
})
.from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
.groupBy(invoices.paymentMethod);
return c.json({
revenueThisMonth: revenueResult?.total ?? 0,
outstanding: outstandingResult?.total ?? 0,
refundsThisMonth: refundsResult?.total ?? 0,
methodBreakdown,
});
});
// Get Stripe payment details for an invoice (card last4, payment status, refund status)
invoicesRouter.get("/:id/stripe-details", async (c) => {
const db = getDb();
const id = c.req.param("id");
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
if (!invoice) return c.json({ error: "Not found" }, 404);
let cardLast4: string | null = null;
let paymentStatus: string | null = null;
if (invoice.stripePaymentIntentId) {
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
if (details) {
cardLast4 = details.cardLast4;
paymentStatus = details.paymentStatus;
}
}
return c.json({
stripePaymentIntentId: invoice.stripePaymentIntentId,
stripeRefundId: invoice.stripeRefundId,
cardLast4,
paymentStatus,
});
});
+2 -6
View File
@@ -102,7 +102,6 @@ portalRouter.get("/appointments", async (c) => {
const db = getDb();
const clientId = c.get("portalClientId");
const now = new Date();
const allAppts = await db
.select({
id: appointments.id,
@@ -142,10 +141,7 @@ portalRouter.get("/appointments", async (c) => {
staff: a.staffId ? { id: staffMap[a.staffId]?.id, name: staffMap[a.staffId]?.name } : null,
}));
const upcoming = appts.filter(a => a.startTime > now && a.status !== "cancelled");
const past = appts.filter(a => a.startTime <= now || a.status === "cancelled");
return c.json({ upcoming, past });
return c.json({ appointments: appts });
});
portalRouter.get("/pets", async (c) => {
@@ -153,7 +149,7 @@ portalRouter.get("/pets", async (c) => {
const clientId = c.get("portalClientId");
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes })));
});
portalRouter.get("/invoices", async (c) => {
+1 -1
View File
@@ -9,8 +9,8 @@ const RATE_LIMIT_MAX = 10;
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
const now = Date.now();
const entry = rateLimitMap.get(ip);
const now = Date.now();
if (!entry || now > entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
+16
View File
@@ -162,3 +162,19 @@ export async function createSetupIntent(customerId: string): Promise<{ clientSec
return { clientSecret: setupIntent.client_secret! };
}
export async function getPaymentIntentDetails(
paymentIntentId: string
): Promise<{ cardLast4: string | null; paymentStatus: string | null } | null> {
const stripe = getStripeClient();
if (!stripe) return null;
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["payment_method"] });
const cardLast4 = pi.payment_method
? (pi.payment_method as Stripe.PaymentMethod).card?.last4 ?? null
: null;
return {
cardLast4,
paymentStatus: pi.status ?? null,
};
}
@@ -0,0 +1,53 @@
# =============================================================================
# Terraform CRD for Flux ToFu Controller — Authentik groombook-uat
# =============================================================================
# This CRD tells the Flux ToFu Controller to reconcile the Terraform
# workspace at apps/overlays/uat/terraform/
#
# The ToFu Controller will:
# 1. Clone the groombook/app GitRepository
# 2. Run tofu init + tofu plan/apply in the specified path
# 3. Store Terraform state in a Kubernetes secret (backend.tf)
# 4. Inject TF_VAR_authentik_token from the authentik-credentials secret
# via tf-controller varsFrom (maps secret key to Terraform variable)
#
# ApiVersion: infra.contrib.fluxcd.io/v1alpha2 (tf-controller)
# =============================================================================
apiVersion: infra.contrib.fluxcd.io/v1alpha2
kind: Terraform
metadata:
name: authentik-uat
namespace: groombook-uat
labels:
app.kubernetes.io/name: authentik
app.kubernetes.io/part-of: groombook
app.kubernetes.io/env: uat
spec:
# Reconcile every hour
interval: 1h
# Path within the GitRepository (groombook/app)
path: ./apps/overlays/uat/terraform
# Source reference — must match the GitRepository name watching this repo
sourceRef:
kind: GitRepository
name: groombook
# Auto-approve plans (no manual intervention needed for infrastructure)
approvePlan: "auto"
# Clean up Terraform resources when this CRD is deleted
destroyResourcesOnDeletion: true
# Inject TF_VAR_authentik_token from the sealed secret via tf-controller varsFrom
# (maps secret key "authentik_token" to Terraform var.authentik_token)
varsFrom:
- kind: Secret
name: authentik-credentials
- kind: Secret
name: authentik-uat-users-credentials
runnerPodTemplate:
spec: {}
@@ -0,0 +1,19 @@
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: groombook
namespace: groombook-uat
labels:
app.kubernetes.io/name: groombook
app.kubernetes.io/part-of: groombook
app.kubernetes.io/env: uat
spec:
interval: 15m
provider: github
ref:
branch: fix/gro-844-network-policy
secretRef:
name: cpfarhood-k8s
timeout: 60s
url: https://github.com/groombook/app
+6
View File
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: groombook-uat
resources:
- gitrepository-groombook.yaml
- authentik-terraform.yaml
+21
View File
@@ -0,0 +1,21 @@
# =============================================================================
# Backend configuration for Terraform state
# =============================================================================
# Uses Kubernetes backend with tf-controller managed state secret.
# tf-controller creates a Kubernetes Secret named:
# tfstate-<name>-<secret_suffix>
# i.e. tfstate-authentik-uat-authentik-uat-tf-state
# in the namespace specified by the Terraform CRD metadata.namespace (groombook-uat).
#
# Valid Kubernetes backend attributes for tf-controller:
# secret_suffix, namespace, config_path, cluster_ca_cert, client_certificate,
# client_key, token, exec, host, insecure, username, password,
# in_cluster, load_config, config_paths
# =============================================================================
terraform {
backend "kubernetes" {
secret_suffix = "authentik-uat-tf-state"
namespace = "groombook-uat"
}
}
+12
View File
@@ -0,0 +1,12 @@
# Import existing Authentik resources into Terraform state.
# These blocks are consumed on the first apply and become no-ops thereafter.
import {
to = authentik_oauth2_provider.groombook-uat
id = "284"
}
import {
to = authentik_application.groombook-uat
id = "e77a9c45-bed6-4a23-bc62-178f166f099e"
}
+99
View File
@@ -0,0 +1,99 @@
# =============================================================================
# Terraform configuration for Authentik groombook-uat application
# =============================================================================
# This Terraform workspace manages the Authentik OAuth2 application and provider
# for the groombook-uat environment.
#
# The authentik_token used for authentication is sourced from the
# `authentik-credentials` SealedSecret (injected as TF_VAR_authentik_token
# by the Terraform CRD runnerPodTemplate.spec.varsFrom).
#
# To import existing resources (run via tf-controller exec or locally with
# AUTHENTIK_TOKEN set):
# tofu import authentik_oauth2_provider.groombook-uat pk-284
# tofu import authentik_application.groombook-uat e77a9c45-bed6-4a23-bc62-178f166f099e
# =============================================================================
# -----------------------------------------------------------------------------
# Provider configuration
# -----------------------------------------------------------------------------
terraform {
required_providers {
authentik = {
source = "goauthentik/authentik"
version = "~> 2024.12"
}
}
}
provider "authentik" {
url = var.authentik_url
api_token = var.authentik_token
tls_verify = true
}
# -----------------------------------------------------------------------------
# OAuth2 Provider for groombook-uat
# pk = 284 (existing — imported, not recreated)
# -----------------------------------------------------------------------------
resource "authentik_oauth2_provider" "groombook-uat" {
name = "groombook-uat-provider"
slug = "groombook-uat"
client_id = "" # managed by imported resource; tracked via ignore_changes
client_secret = "" # managed by imported resource; tracked via ignore_changes
client_type = "confidential"
redirect_uris = ["https://uat.groombook.dev/api/auth/oauth2/callback/authentik"]
signing_key = "authentik signing key"
# Keep Terraform from overwriting the client_id, client_secret, and signing_key
# which are managed by the imported existing resource
lifecycle {
ignore_changes = [
client_id,
client_secret,
signing_key,
]
}
}
# -----------------------------------------------------------------------------
# Application for groombook-uat
# pk = e77a9c45-bed6-4a23-bc62-178f166f099e (existing — imported, not recreated)
# -----------------------------------------------------------------------------
resource "authentik_application" "groombook-uat" {
name = "groombook-uat"
slug = "groombook-uat"
group = "groombook"
policy_ids = []
description = "GroomBook UAT application"
# Link to the OAuth2 provider
oauth2_provider = authentik_oauth2_provider.groombook-uat.id
# Track name, slug, group, and oauth2_provider for drift detection;
# ignore policy_ids and description which may be updated out-of-band
lifecycle {
ignore_changes = [
policy_ids,
description,
]
}
}
# -----------------------------------------------------------------------------
# Outputs (for reference / verification)
# -----------------------------------------------------------------------------
output "oauth2_provider_pk" {
description = "Authentik OAuth2 Provider primary key"
value = authentik_oauth2_provider.groombook-uat.pk
}
output "application_pk" {
description = "Authentik Application primary key"
value = authentik_application.groombook-uat.pk
}
output "application_slug" {
description = "Authentik Application slug"
value = authentik_application.groombook-uat.slug
}
@@ -0,0 +1,10 @@
# =============================================================================
# Terraform variable values for groombook-uat
# =============================================================================
# NOTE: authentik_token should be provided via AUTHENTIK_TOKEN env var,
# sourced from the authentik-credentials SealedSecret.
# The placeholder value here is not used when running via tf-controller.
# =============================================================================
authentik_url = "https://auth.farh.net"
# authentik_token = "<set via AUTHENTIK_TOKEN env var from authentik-credentials secret>"
+121
View File
@@ -0,0 +1,121 @@
# =============================================================================
# Authentik UAT user personas — Terraform resources
# =============================================================================
# Creates three Authentik users bound to the groombook-uat application:
# - UAT Super User (manager role, superuser)
# - UAT Groomer (staff/groomer role)
# - UAT Customer (no staff record — auth identity only)
#
# Passwords are sourced from sensitive Terraform variables which are injected
# via tf-controller varsFrom from the authentik-uat-users-credentials SealedSecret.
#
# User PKs are exported as outputs — these are the OIDC sub claims in Authentik.
# =============================================================================
# -----------------------------------------------------------------------------
# Group: groombook-uat-users
# -----------------------------------------------------------------------------
resource "authentik_group" "groombook-uat-users" {
name = "groombook-uat-users"
}
# -----------------------------------------------------------------------------
# User: UAT Super User
# -----------------------------------------------------------------------------
resource "authentik_user" "uat-super" {
name = "UAT Super User"
username = "uat-super"
email = "uat-super@groombook.dev"
password = var.uat_super_password
active = true
# Attributes stored as JSON string per authentik_user schema
attributes_json = jsonencode({
role = "manager"
})
}
# Add uat-super to the group
resource "authentik_group_membership" "uat-super" {
group = authentik_group.groombook-uat-users.id
user = authentik_user.uat-super.pk
}
# Bind the group to the groombook-uat application via policy binding
# This grants group members authentication access to the application
resource "authentik_policy_binding" "uat-super-group-binding" {
policy = authentik_group.groombook-uat-users.id
target = authentik_application.groombook-uat.pk
binding_type = "group_whitelist"
}
# -----------------------------------------------------------------------------
# User: UAT Groomer (Staff)
# -----------------------------------------------------------------------------
resource "authentik_user" "uat-groomer" {
name = "UAT Groomer"
username = "uat-groomer"
email = "uat-groomer@groombook.dev"
password = var.uat_groomer_password
active = true
attributes_json = jsonencode({
role = "groomer"
})
}
# Add uat-groomer to the group
resource "authentik_group_membership" "uat-groomer" {
group = authentik_group.groombook-uat-users.id
user = authentik_user.uat-groomer.pk
}
# Bind the group to the groombook-uat application
resource "authentik_policy_binding" "uat-groomer-group-binding" {
policy = authentik_group.groombook-uat-users.id
target = authentik_application.groombook-uat.pk
binding_type = "group_whitelist"
}
# -----------------------------------------------------------------------------
# User: UAT Customer
# -----------------------------------------------------------------------------
resource "authentik_user" "uat-customer" {
name = "UAT Customer"
username = "uat-customer"
email = "uat-customer@groombook.dev"
password = var.uat_customer_password
active = true
attributes_json = jsonencode({
role = "customer"
})
}
# Add uat-customer to the group
resource "authentik_group_membership" "uat-customer" {
group = authentik_group.groombook-uat-users.id
user = authentik_user.uat-customer.pk
}
# Bind the group to the groombook-uat application
resource "authentik_policy_binding" "uat-customer-group-binding" {
policy = authentik_group.groombook-uat-users.id
target = authentik_application.groombook-uat.pk
binding_type = "group_whitelist"
}
# -----------------------------------------------------------------------------
# Outputs — OIDC sub claims (= user PK in Authentik)
# -----------------------------------------------------------------------------
output "uat_super_user_pk" {
description = "UAT Super User primary key (OIDC sub)"
value = authentik_user.uat-super.pk
}
output "uat_groomer_user_pk" {
description = "UAT Groomer primary key (OIDC sub)"
value = authentik_user.uat-groomer.pk
}
output "uat_customer_user_pk" {
description = "UAT Customer primary key (OIDC sub)"
value = authentik_user.uat-customer.pk
}
+33
View File
@@ -0,0 +1,33 @@
# =============================================================================
# Variables for Authentik groombook-uat Terraform workspace
# =============================================================================
variable "authentik_url" {
description = "Base URL of the Authentik instance"
type = string
default = "https://auth.farh.net"
}
variable "authentik_token" {
description = "API token for Authentik (from authentik-credentials secret via AUTHENTIK_TOKEN env var)"
type = string
sensitive = true
}
variable "uat_super_password" {
description = "Password for the UAT Super User account"
type = string
sensitive = true
}
variable "uat_groomer_password" {
description = "Password for the UAT Groomer staff account"
type = string
sensitive = true
}
variable "uat_customer_password" {
description = "Password for the UAT Customer account"
type = string
sensitive = true
}
+161 -1
View File
@@ -173,6 +173,22 @@ function InvoiceDetailModal({
const [error, setError] = useState<string | null>(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
const [showRefundDialog, setShowRefundDialog] = useState(false);
const [refundType, setRefundType] = useState<"full" | "partial">("full");
const [partialAmount, setPartialAmount] = useState("");
const [stripeDetails, setStripeDetails] = useState<{ cardLast4: string | null; paymentStatus: string | null; stripeRefundId: string | null } | null>(null);
// Fetch Stripe details when modal opens for paid invoices with a payment intent
useEffect(() => {
if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
fetch(`/api/invoices/${invoice.id}/stripe-details`)
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setStripeDetails(data); })
.catch(() => {});
} else {
setStripeDetails(null);
}
}, [invoice.id, invoice.status, invoice.stripePaymentIntentId]);
// Tip split state: array of {staffId, staffName, pct}
const linkedAppt = invoice.appointmentId
@@ -276,6 +292,35 @@ function InvoiceDetailModal({
}
}
async function issueRefund() {
const amountCents = refundType === "partial"
? Math.round(parseFloat(partialAmount) * 100)
: undefined;
if (refundType === "partial" && (!amountCents || amountCents <= 0)) {
setError("Enter a valid refund amount");
return;
}
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(amountCents ? { amountCents } : {}),
});
if (!res.ok) {
const err = (await res.json()) as { error?: string };
throw new Error(err.error ?? `HTTP ${res.status}`);
}
setShowRefundDialog(false);
onUpdated();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to issue refund");
} finally {
setSaving(false);
}
}
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading</p></Modal>;
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
@@ -335,6 +380,19 @@ function InvoiceDetailModal({
/>
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
{stripeDetails && (
<>
{stripeDetails.cardLast4 && (
<SummaryRow label="Card" value={`•••• ${stripeDetails.cardLast4}`} />
)}
{stripeDetails.paymentStatus && (
<SummaryRow label="Stripe status" value={stripeDetails.paymentStatus} />
)}
{stripeDetails.stripeRefundId && (
<SummaryRow label="Refund" value="Refunded" />
)}
</>
)}
</div>
{/* ── Tip Distribution ── */}
@@ -452,10 +510,76 @@ function InvoiceDetailModal({
</div>
)}
{(invoice.status === "paid" || invoice.status === "void") && (
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
{invoice.status === "paid" && invoice.stripePaymentIntentId && (
<button
onClick={() => setShowRefundDialog(true)}
style={{ ...btnStyle, color: "#b45309", borderColor: "#b45309" }}
>
Refund
</button>
)}
<button onClick={onClose} style={btnStyle}>Close</button>
</div>
)}
{/* Refund Dialog */}
{showRefundDialog && (
<Modal onClose={() => setShowRefundDialog(false)}>
<h2 style={{ marginTop: 0 }}>Issue Refund</h2>
<p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
Invoice total: <strong>{fmtMoney(invoice.totalCents)}</strong>
</p>
<div style={{ marginBottom: "0.75rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600, marginBottom: "0.5rem" }}>
<input
type="radio"
name="refundType"
value="full"
checked={refundType === "full"}
onChange={() => setRefundType("full")}
/>
Full refund
</label>
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600 }}>
<input
type="radio"
name="refundType"
value="partial"
checked={refundType === "partial"}
onChange={() => setRefundType("partial")}
/>
Partial refund
</label>
</div>
{refundType === "partial" && (
<div style={{ marginBottom: "1rem" }}>
<input
type="number"
min="0.01"
step="0.01"
placeholder="0.00"
value={partialAmount}
onChange={(e) => setPartialAmount(e.target.value)}
style={{ ...inputStyle, width: 120 }}
/>
</div>
)}
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
<button
onClick={issueRefund}
disabled={saving}
style={{ ...btnStyle, backgroundColor: "#b45309", color: "#fff", borderColor: "#b45309" }}
>
{saving ? "Processing…" : "Issue Refund"}
</button>
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>
Cancel
</button>
</div>
</Modal>
)}
</Modal>
);
}
@@ -497,9 +621,17 @@ export function InvoicesPage() {
const [createLoading, setCreateLoading] = useState(false);
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
const LIMIT = 50;
useEffect(() => {
fetch("/api/invoices/stats/summary")
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setPaymentStats(data); })
.catch(() => {});
}, []);
async function loadInvoices(newOffset: number) {
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(newOffset) });
if (statusFilter) params.set("status", statusFilter);
@@ -578,6 +710,34 @@ export function InvoicesPage() {
</button>
</div>
{/* Payment Stats Summary */}
{paymentStats && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1.25rem" }}>
<div style={{ background: "#f0fdf4", border: "1px solid #bbf7d0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#166534", fontWeight: 600, marginBottom: "0.25rem" }}>Revenue (paid)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#15803d" }}>{fmtMoney(paymentStats.revenueThisMonth)}</div>
</div>
<div style={{ background: "#fefce8", border: "1px solid #fde047", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#854d0e", fontWeight: 600, marginBottom: "0.25rem" }}>Outstanding</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#a16207" }}>{fmtMoney(paymentStats.outstanding)}</div>
</div>
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#991b1b", fontWeight: 600, marginBottom: "0.25rem" }}>Refunds (this mo.)</div>
<div style={{ fontSize: 20, fontWeight: 700, color: "#dc2626" }}>{fmtMoney(paymentStats.refundsThisMonth)}</div>
</div>
{paymentStats.methodBreakdown.length > 0 && (
<div style={{ background: "#f8fafc", border: "1px solid #e2e8f0", borderRadius: 8, padding: "0.75rem 1rem" }}>
<div style={{ fontSize: 12, color: "#475569", fontWeight: 600, marginBottom: "0.25rem" }}>By method</div>
<div style={{ fontSize: 13, color: "#64748b" }}>
{paymentStats.methodBreakdown.map((b) => (
<div key={b.method ?? "unknown"}>{b.method ?? "other"}: {b.total}</div>
))}
</div>
</div>
)}
</div>
)}
{invoiceList.length === 0 ? (
<p style={{ color: "#6b7280" }}>
No invoices yet. Create one from a completed appointment.
+3 -4
View File
@@ -27,8 +27,7 @@ interface Appointment {
}
interface AppointmentsResponse {
upcoming: Appointment[];
past: Appointment[];
appointments: Appointment[];
}
interface Props {
@@ -46,7 +45,7 @@ function buildHeaders(sessionId: string | null): Record<string, string> {
export function PetProfiles({ sessionId, readOnly }: Props) {
const [pets, setPets] = useState<Pet[]>([]);
const [appointments, setAppointments] = useState<AppointmentsResponse>({ upcoming: [], past: [] });
const [appointments, setAppointments] = useState<AppointmentsResponse>({ appointments: [] });
const [selectedPetId, setSelectedPetId] = useState<string>("");
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "history">("info");
const [editingPetId, setEditingPetId] = useState<string | null>(null);
@@ -90,7 +89,7 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
}, [sessionId]);
const selectedPet = pets.find(p => p.id === selectedPetId) ?? null;
const petHistory = appointments.past.filter(a => a.pet?.id === selectedPetId);
const petHistory = appointments.appointments.filter(a => a.pet?.id === selectedPetId && new Date(a.startTime) <= new Date());
const editingPet = editingPetId ? pets.find(p => p.id === editingPetId) ?? null : null;
function handlePetSave(updatedPet: Pet) {
+6
View File
@@ -152,10 +152,16 @@ export interface Invoice {
status: InvoiceStatus;
paymentMethod: PaymentMethod | null;
paidAt: string | null;
stripePaymentIntentId: string | null;
stripeRefundId: string | null;
paymentFailureReason: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
lineItems?: InvoiceLineItem[];
// Transient fields populated from Stripe API (not stored in DB)
cardLast4?: string | null;
paymentStatus?: string | null;
tipSplits?: InvoiceTipSplit[];
}