Compare commits

..

1 Commits

Author SHA1 Message Date
Flea Flicker 3d45582609 fix(GRO-874): add requireSuperUser() to GET /api/admin/settings/logo
The logo proxy route was missing auth middleware, allowing any
unauthenticated caller to receive the presigned S3 URL and exposing
the internal Ceph RGW hostname. Matches auth pattern used by all
other /api/admin/* routes in this file.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-22 03:42:29 +00:00
13 changed files with 146 additions and 223 deletions
+1 -1
View File
@@ -340,7 +340,7 @@ jobs:
name: Update Infra Image Tags name: Update Infra Image Tags
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [docker] needs: [docker]
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
+4 -4
View File
@@ -58,7 +58,7 @@ jobs:
TAG: ${{ inputs.tag }} TAG: ${{ inputs.tag }}
run: | run: |
cd /tmp/infra cd /tmp/infra
PROD_KUST="apps/overlays/prod/kustomization.yaml" PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml"
SHORT_SHA="${TAG##*-}" SHORT_SHA="${TAG##*-}"
export SHORT_SHA export SHORT_SHA
@@ -70,14 +70,14 @@ jobs:
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST"
# Update migrate Job name to include short SHA (immutable template fix) # Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/base/migrate-job.yaml" MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
if [ -f "$MIGRATE_JOB" ]; then if [ -f "$MIGRATE_JOB" ]; then
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB" yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
fi fi
# Update seed Job name to include short SHA (immutable template fix) # Update seed Job name to include short SHA (immutable template fix)
SEED_JOB="apps/base/seed-job.yaml" SEED_JOB="apps/groombook/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
@@ -94,7 +94,7 @@ jobs:
git config user.name "groombook-engineer[bot]" git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com" git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "release/promote-prod-${TAG}" git checkout -b "release/promote-prod-${TAG}"
git add apps/overlays/prod/ apps/base/migrate-job.yaml apps/base/seed-job.yaml git add apps/groombook/overlays/prod/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git commit -m "release: promote ${TAG} to production" git commit -m "release: promote ${TAG} to production"
git push -u origin "release/promote-prod-${TAG}" git push -u origin "release/promote-prod-${TAG}"
gh pr create \ gh pr create \
+4 -4
View File
@@ -38,7 +38,7 @@ jobs:
run: | run: |
echo "Updating UAT overlay image tags to: $TAG" echo "Updating UAT overlay image tags to: $TAG"
cd /tmp/infra cd /tmp/infra
UAT_KUST="apps/overlays/uat/kustomization.yaml" UAT_KUST="apps/groombook/overlays/uat/kustomization.yaml"
if [ ! -f "$UAT_KUST" ]; then if [ ! -f "$UAT_KUST" ]; then
echo "ERROR: UAT overlay not found at $UAT_KUST. Ensure GRO-427 has been completed." echo "ERROR: UAT overlay not found at $UAT_KUST. Ensure GRO-427 has been completed."
@@ -55,7 +55,7 @@ jobs:
yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST"
# Update migrate Job name to include short SHA (immutable template fix) # Update migrate Job name to include short SHA (immutable template fix)
MIGRATE_JOB="apps/base/migrate-job.yaml" MIGRATE_JOB="apps/groombook/base/migrate-job.yaml"
if [ -f "$MIGRATE_JOB" ]; then if [ -f "$MIGRATE_JOB" ]; then
yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB" yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB"
@@ -64,7 +64,7 @@ jobs:
# Update seed Job name to include short SHA (immutable template fix) # Update seed Job name to include short SHA (immutable template fix)
# NOTE: Do NOT update the image tag here — let the Kustomize images transformer # NOTE: Do NOT update the image tag here — let the Kustomize images transformer
# in the UAT overlay handle it via newTag. This avoids the immutable template issue. # in the UAT overlay handle it via newTag. This avoids the immutable template issue.
SEED_JOB="apps/base/seed-job.yaml" SEED_JOB="apps/groombook/base/seed-job.yaml"
if [ -f "$SEED_JOB" ]; then if [ -f "$SEED_JOB" ]; then
yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB"
yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB"
@@ -81,7 +81,7 @@ jobs:
git config user.name "groombook-engineer[bot]" git config user.name "groombook-engineer[bot]"
git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com" git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com"
git checkout -b "chore/update-uat-image-tags-${TAG}" git checkout -b "chore/update-uat-image-tags-${TAG}"
git add apps/overlays/uat/ apps/base/migrate-job.yaml apps/base/seed-job.yaml git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml
git commit -m "chore: promote ${TAG} to UAT" git commit -m "chore: promote ${TAG} to UAT"
git push -u origin "chore/update-uat-image-tags-${TAG}" git push -u origin "chore/update-uat-image-tags-${TAG}"
+37 -64
View File
@@ -101,8 +101,6 @@ invoicesRouter.get(
paymentMethod: invoices.paymentMethod, paymentMethod: invoices.paymentMethod,
paidAt: invoices.paidAt, paidAt: invoices.paidAt,
notes: invoices.notes, notes: invoices.notes,
stripePaymentIntentId: invoices.stripePaymentIntentId,
stripeRefundId: invoices.stripeRefundId,
createdAt: invoices.createdAt, createdAt: invoices.createdAt,
updatedAt: invoices.updatedAt, updatedAt: invoices.updatedAt,
}) })
@@ -130,17 +128,7 @@ invoicesRouter.get("/:id", async (c) => {
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)), db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
]); ]);
let cardLast4: string | null = null; return c.json({ ...invoice, lineItems, tipSplits });
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({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
}); });
// Save tip splits for an invoice (replaces existing splits) // Save tip splits for an invoice (replaces existing splits)
@@ -460,6 +448,9 @@ invoicesRouter.post(
if (invoice.status !== "paid") { if (invoice.status !== "paid") {
return c.json({ error: "Refund only allowed on paid invoices" }, 422); return c.json({ error: "Refund only allowed on paid invoices" }, 422);
} }
if (!invoice.stripePaymentIntentId) {
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
}
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
if (body.idempotencyKey) { if (body.idempotencyKey) {
@@ -472,75 +463,57 @@ invoicesRouter.post(
} }
} }
let refundId: string; const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
if (invoice.stripePaymentIntentId) {
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
refundId = result.refundId;
} else {
// Manual refund — no Stripe call needed
refundId = `manual_${id}_${Date.now()}`;
}
await tx.insert(refunds).values({ await tx.insert(refunds).values({
invoiceId: id, invoiceId: id,
stripeRefundId: refundId, stripeRefundId: result.refundId,
idempotencyKey: body.idempotencyKey ?? null, idempotencyKey: body.idempotencyKey ?? null,
amountCents: body.amountCents ?? null, amountCents: body.amountCents ?? null,
}); });
return c.json({ refundId }); return c.json({ refundId: result.refundId });
}); });
} }
); );
// Payment stats for admin dashboard // Payment stats for admin dashboard
invoicesRouter.get("/stats/summary", async (c) => { invoicesRouter.get("/stats/summary", async (c) => {
try { const db = getDb();
const db = getDb(); const now = new Date();
const now = new Date(); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [revenueResult] = await db const [revenueResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` }) .select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices) .from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)); .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`));
const [outstandingResult] = await db const [outstandingResult] = await db
.select({ total: sql<number>`coalesce(sum(total_cents), 0)` }) .select({ total: sql<number>`coalesce(sum(total_cents), 0)` })
.from(invoices) .from(invoices)
.where(eq(invoices.status, "pending")); .where(eq(invoices.status, "pending"));
const [refundsResult] = await db const [refundsResult] = await db
.select({ total: sql<number>`coalesce(sum(amount_cents), 0)` }) .select({ total: sql<number>`coalesce(sum(amount_cents), 0)` })
.from(refunds) .from(refunds)
.where(sql`${refunds.createdAt} >= ${startOfMonth}`); .where(sql`${refunds.createdAt} >= ${startOfMonth}`);
const methodBreakdown = await db const methodBreakdown = await db
.select({ .select({
method: invoices.paymentMethod, method: invoices.paymentMethod,
total: sql<number>`count(*)`, total: sql<number>`count(*)`,
}) })
.from(invoices) .from(invoices)
.where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`)) .where(and(eq(invoices.status, "paid"), sql`${invoices.paidAt} >= ${startOfMonth}`))
.groupBy(invoices.paymentMethod); .groupBy(invoices.paymentMethod);
return c.json({ return c.json({
revenueThisMonth: revenueResult?.total ?? 0, revenueThisMonth: revenueResult?.total ?? 0,
outstanding: outstandingResult?.total ?? 0, outstanding: outstandingResult?.total ?? 0,
refundsThisMonth: refundsResult?.total ?? 0, refundsThisMonth: refundsResult?.total ?? 0,
methodBreakdown, methodBreakdown,
}); });
} catch (err) {
console.error("stats/summary error:", err);
return c.json({
revenueThisMonth: 0,
outstanding: 0,
refundsThisMonth: 0,
methodBreakdown: [],
});
}
}); });
// Get Stripe payment details for an invoice (card last4, payment status, refund status) // Get Stripe payment details for an invoice (card last4, payment status, refund status)
+1 -1
View File
@@ -218,7 +218,7 @@ settingsRouter.post(
* Proxies the logo from S3 so the browser never sees an S3 URL. * Proxies the logo from S3 so the browser never sees an S3 URL.
* Returns the image bytes with proper Content-Type. * Returns the image bytes with proper Content-Type.
*/ */
settingsRouter.get("/logo", async (c) => { settingsRouter.get("/logo", requireSuperUser(), async (c) => {
const db = getDb(); const db = getDb();
const [row] = await db.select().from(businessSettings).limit(1); const [row] = await db.select().from(businessSettings).limit(1);
-26
View File
@@ -112,17 +112,9 @@ export function AppointmentsPage() {
const [viewMode, setViewMode] = useState<"status" | "groomer">("status"); const [viewMode, setViewMode] = useState<"status" | "groomer">("status");
// null key = unassigned; staffId string = that groomer; undefined set = all visible // null key = unassigned; staffId string = that groomer; undefined set = all visible
const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set()); const [hiddenGroomers, setHiddenGroomers] = useState<Set<string | null>>(new Set());
const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null);
const weekEnd = addDays(weekStart, 6); const weekEnd = addDays(weekStart, 6);
useEffect(() => {
fetch("/api/invoices/stats/summary")
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setPaymentStats(data); })
.catch(() => {});
}, []);
const loadAppointments = useCallback(() => { const loadAppointments = useCallback(() => {
const from = weekStart.toISOString(); const from = weekStart.toISOString();
const to = addDays(weekStart, 7).toISOString(); const to = addDays(weekStart, 7).toISOString();
@@ -322,24 +314,6 @@ export function AppointmentsPage() {
</button> </button>
</div> </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" }}>${(paymentStats.revenueThisMonth / 100).toFixed(2)}</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" }}>${(paymentStats.outstanding / 100).toFixed(2)}</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" }}>${(paymentStats.refundsThisMonth / 100).toFixed(2)}</div>
</div>
</div>
)}
{/* ── View Mode + Groomer Filters ── */} {/* ── View Mode + Groomer Filters ── */}
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span> <span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>Color by:</span>
+96 -81
View File
@@ -173,21 +173,22 @@ function InvoiceDetailModal({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2)); const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash"); const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
const [showRefundDialog, setShowRefundDialog] = useState(false); const [showRefundDialog, setShowRefundDialog] = useState(false);
const [refundType, setRefundType] = useState<"full" | "partial">("full"); const [refundType, setRefundType] = useState<"full" | "partial">("full");
const [refundAmount, setRefundAmount] = useState(""); const [partialAmount, setPartialAmount] = useState("");
const [refundError, setRefundError] = useState<string | null>(null); const [stripeDetails, setStripeDetails] = useState<{ cardLast4: string | null; paymentStatus: string | null; stripeRefundId: string | null } | null>(null);
const [refunding, setRefunding] = useState(false);
// Fetch current staff role to determine manager access // Fetch Stripe details when modal opens for paid invoices with a payment intent
const [staffMe, setStaffMe] = useState<{ role: string; isSuperUser: boolean } | null>(null);
useEffect(() => { useEffect(() => {
fetch("/api/staff/me") if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
.then((r) => r.json()) fetch(`/api/invoices/${invoice.id}/stripe-details`)
.then((d) => setStaffMe(d)) .then((r) => r.ok ? r.json() : null)
.catch(() => setStaffMe(null)); .then((data) => { if (data) setStripeDetails(data); })
}, []); .catch(() => {});
const isManager = staffMe && (staffMe.role === "manager" || staffMe.isSuperUser); } else {
setStripeDetails(null);
}
}, [invoice.id, invoice.status, invoice.stripePaymentIntentId]);
// Tip split state: array of {staffId, staffName, pct} // Tip split state: array of {staffId, staffName, pct}
const linkedAppt = invoice.appointmentId const linkedAppt = invoice.appointmentId
@@ -291,6 +292,35 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
} }
} }
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>; if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading</p></Modal>;
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0; const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
@@ -350,15 +380,15 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
/> />
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />} {invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />} {invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
{invoice.stripePaymentIntentId && ( {stripeDetails && (
<> <>
{invoice.cardLast4 && ( {stripeDetails.cardLast4 && (
<SummaryRow label="Card" value={`•••• ${invoice.cardLast4}`} /> <SummaryRow label="Card" value={`•••• ${stripeDetails.cardLast4}`} />
)} )}
{invoice.paymentStatus && ( {stripeDetails.paymentStatus && (
<SummaryRow label="Stripe status" value={invoice.paymentStatus} /> <SummaryRow label="Stripe status" value={stripeDetails.paymentStatus} />
)} )}
{invoice.stripeRefundId && ( {stripeDetails.stripeRefundId && (
<SummaryRow label="Refund" value="Refunded" /> <SummaryRow label="Refund" value="Refunded" />
)} )}
</> </>
@@ -480,92 +510,77 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
</div> </div>
)} )}
{(invoice.status === "paid" || invoice.status === "void") && ( {(invoice.status === "paid" || invoice.status === "void") && (
<div style={{ marginTop: "1rem", borderTop: "1px solid #e2e8f0", paddingTop: "1rem" }}> <div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
{invoice.stripeRefundId && ( {invoice.status === "paid" && invoice.stripePaymentIntentId && (
<div style={{ marginBottom: "0.75rem", display: "flex", alignItems: "center", gap: "0.5rem" }}> <button
<span style={{ background: "#fef3c7", color: "#92400e", padding: "0.2rem 0.6rem", borderRadius: 4, fontSize: 13, fontWeight: 600 }}>Refunded</span> onClick={() => setShowRefundDialog(true)}
</div> style={{ ...btnStyle, color: "#b45309", borderColor: "#b45309" }}
>
Refund
</button>
)} )}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}> <button onClick={onClose} style={btnStyle}>Close</button>
{invoice.status === "paid" && !invoice.stripeRefundId && isManager && (
<button onClick={() => setShowRefundDialog(true)} style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}>
Refund
</button>
)}
<button onClick={onClose} style={btnStyle}>Close</button>
</div>
</div> </div>
)} )}
{/* Refund Dialog */}
{showRefundDialog && ( {showRefundDialog && (
<div style={{ marginTop: "1rem", border: "1px solid #e2e8f0", borderRadius: 8, padding: "1rem", background: "#f9fafb" }}> <Modal onClose={() => setShowRefundDialog(false)}>
<p style={{ fontWeight: 600, margin: "0 0 0.75rem" }}>Process Refund</p> <h2 style={{ marginTop: 0 }}>Issue Refund</h2>
<div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}> <p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}> Invoice total: <strong>{fmtMoney(invoice.totalCents)}</strong>
<input type="radio" checked={refundType === "full"} onChange={() => setRefundType("full")} /> </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 Full refund
</label> </label>
<label style={{ display: "flex", alignItems: "center", gap: "0.25rem", cursor: "pointer" }}> <label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontWeight: 600 }}>
<input type="radio" checked={refundType === "partial"} onChange={() => setRefundType("partial")} /> <input
type="radio"
name="refundType"
value="partial"
checked={refundType === "partial"}
onChange={() => setRefundType("partial")}
/>
Partial refund Partial refund
</label> </label>
</div> </div>
{refundType === "partial" && ( {refundType === "partial" && (
<div style={{ marginBottom: "0.75rem" }}> <div style={{ marginBottom: "1rem" }}>
<input <input
type="number" type="number"
min="0.01" min="0.01"
step="0.01" step="0.01"
placeholder="Amount ($)" placeholder="0.00"
value={refundAmount} value={partialAmount}
onChange={(e) => setRefundAmount(e.target.value)} onChange={(e) => setPartialAmount(e.target.value)}
style={{ ...inputStyle, width: 100 }} style={{ ...inputStyle, width: 120 }}
/> />
</div> </div>
)} )}
{refundError && <p style={{ color: "red", margin: "0 0 0.5rem", fontSize: 13 }}>{refundError}</p>} {error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
<div style={{ display: "flex", gap: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}>
<button <button
onClick={async () => { onClick={issueRefund}
setRefunding(true); disabled={saving}
setRefundError(null); style={{ ...btnStyle, backgroundColor: "#b45309", color: "#fff", borderColor: "#b45309" }}
try {
if (refundType === "partial") {
const parsed = parseFloat(refundAmount);
if (isNaN(parsed) || parsed <= 0) {
setRefundError("Please enter a valid amount greater than zero.");
setRefunding(false);
return;
}
}
const body = refundType === "partial" ? { amountCents: Math.round(parseFloat(refundAmount) * 100) } : {};
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
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) {
setRefundError(e instanceof Error ? e.message : "Refund failed");
} finally {
setRefunding(false);
}
}}
disabled={refunding}
style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}
> >
{refunding ? "Processing…" : "Process Refund"} {saving ? "Processing…" : "Issue Refund"}
</button>
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>
Cancel
</button> </button>
<button onClick={() => { setShowRefundDialog(false); setRefundError(null); }} style={btnStyle}>Cancel</button>
</div> </div>
</div> </Modal>
)} )}
</Modal> </Modal>
); );
} }
+1 -1
View File
@@ -326,7 +326,7 @@ export function CustomerPortal() {
)} )}
{/* Main Content */} {/* Main Content */}
<main className="flex-1 min-h-screen overflow-hidden"> <main className="flex-1 min-h-screen overflow-x-hidden">
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white"> <div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
<div> <div>
<h1 className="text-lg font-semibold text-stone-800"> <h1 className="text-lg font-semibold text-stone-800">
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</div> </div>
)} )}
<div className="flex gap-2 flex-wrap overflow-x-auto"> <div className="flex gap-2 flex-wrap">
{([ {([
{ id: "invoices" as const, label: "Invoices", icon: DollarSign }, { id: "invoices" as const, label: "Invoices", icon: DollarSign },
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard }, { id: "payment" as const, label: "Payment Methods", icon: CreditCard },
-7
View File
@@ -119,10 +119,3 @@ uri
database-url database-url
{{- end -}} {{- end -}}
{{- end }} {{- end }}
{{/*
Auth secret name always use groombook-auth (sealed secret name)
*/}}
{{- define "groombook.authSecretName" -}}
{{- printf "%s" "groombook-auth" }}
{{- end }}
@@ -50,27 +50,6 @@ spec:
- name: OIDC_AUDIENCE - name: OIDC_AUDIENCE
value: {{ .Values.api.env.oidcAudience | quote }} value: {{ .Values.api.env.oidcAudience | quote }}
{{- end }} {{- end }}
{{- if .Values.api.env.internalBaseUrl }}
- name: OIDC_INTERNAL_BASE
value: {{ .Values.api.env.internalBaseUrl | quote }}
{{- end }}
- name: BETTER_AUTH_URL
value: {{ .Values.api.env.betterAuthUrl | quote }}
- name: OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ include "groombook.authSecretName" . }}
key: OIDC_CLIENT_ID
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "groombook.authSecretName" . }}
key: OIDC_CLIENT_SECRET
- name: BETTER_AUTH_SECRET
valueFrom:
secretKeyRef:
name: {{ include "groombook.authSecretName" . }}
key: BETTER_AUTH_SECRET
- name: DATABASE_URL - name: DATABASE_URL
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
-2
View File
@@ -18,8 +18,6 @@ api:
corsOrigin: "" corsOrigin: ""
oidcIssuer: "" oidcIssuer: ""
oidcAudience: groombook oidcAudience: groombook
betterAuthUrl: ""
internalBaseUrl: ""
port: "3000" port: "3000"
service: service:
type: ClusterIP type: ClusterIP
+1 -10
View File
@@ -883,7 +883,6 @@ async function seed() {
let appointmentCount = 0; let appointmentCount = 0;
let invoiceCount = 0; let invoiceCount = 0;
let visitLogCount = 0; let visitLogCount = 0;
let paidInvoiceCounter = 0;
// Process in batches per client to keep memory manageable // Process in batches per client to keep memory manageable
const apptBatchSize = 100; const apptBatchSize = 100;
@@ -978,10 +977,6 @@ async function seed() {
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const; const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null; const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
paidInvoiceCounter++;
const stripePaymentIntentId = invoiceStatus === "paid"
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
: null;
invoiceBatch.push({ invoiceBatch.push({
id: invoiceId, id: invoiceId,
@@ -994,7 +989,6 @@ async function seed() {
status: invoiceStatus, status: invoiceStatus,
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null, paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
paidAt, paidAt,
stripePaymentIntentId,
notes: rand() < 0.05 ? "Added extra service at checkout" : null, notes: rand() < 0.05 ? "Added extra service at checkout" : null,
}); });
@@ -1098,16 +1092,13 @@ async function seed() {
const taxCents = Math.round(effectivePrice * 0.08); const taxCents = Math.round(effectivePrice * 0.08);
const totalCents = effectivePrice + taxCents + tipCents; const totalCents = effectivePrice + taxCents + tipCents;
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000); const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
paidInvoiceCounter++;
invoiceBatch.push({ invoiceBatch.push({
id: invoiceId, appointmentId: apptId, clientId, id: invoiceId, appointmentId: apptId, clientId,
subtotalCents: effectivePrice, taxCents, tipCents, totalCents, subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
status: "paid" as const, status: "paid" as const,
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check", paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
paidAt, paidAt, notes: null,
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
notes: null,
}); });
lineItemBatch.push({ lineItemBatch.push({
id: uuid(), invoiceId, description: svc.name, quantity: 1, id: uuid(), invoiceId, description: svc.name, quantity: 1,