Compare commits

..

4 Commits

Author SHA1 Message Date
Test User e82c232b44 fix(GRO-876): remove dead issueRefund function from InvoiceDetailModal
The inline async onClick handler already calls the refund API directly. The
separate issueRefund function was defined but never called, causing
@typescript-eslint/no-unused-vars CI failure on PR #351.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 23:11:45 +00:00
Test User 6893676a93 fix(GRO-876): wire up refund button in invoice detail modal
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>
2026-04-23 23:10:47 +00:00
Test User 625fadd4eb 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>
2026-04-23 19:22:46 +00:00
Test User a1941e8acf 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>
2026-04-23 19:22:46 +00:00
10 changed files with 14 additions and 389 deletions
+8 -23
View File
@@ -130,17 +130,7 @@ invoicesRouter.get("/:id", async (c) => {
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
]);
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({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
return c.json({ ...invoice, lineItems, tipSplits });
});
// Save tip splits for an invoice (replaces existing splits)
@@ -460,6 +450,9 @@ invoicesRouter.post(
if (invoice.status !== "paid") {
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) => {
if (body.idempotencyKey) {
@@ -472,25 +465,17 @@ invoicesRouter.post(
}
}
let refundId: string;
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()}`;
}
const result = await processRefund(id, body.amountCents);
if (!result) return c.json({ error: "Refund failed" }, 500);
await tx.insert(refunds).values({
invoiceId: id,
stripeRefundId: refundId,
stripeRefundId: result.refundId,
idempotencyKey: body.idempotencyKey ?? null,
amountCents: body.amountCents ?? null,
});
return c.json({ refundId });
return c.json({ refundId: result.refundId });
});
}
);
+3 -10
View File
@@ -487,7 +487,7 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
</div>
)}
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
{invoice.status === "paid" && !invoice.stripeRefundId && isManager && (
{invoice.status === "paid" && invoice.stripePaymentIntentId && !invoice.stripeRefundId && isManager && (
<button onClick={() => setShowRefundDialog(true)} style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}>
Refund
</button>
@@ -530,14 +530,6 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
setRefunding(true);
setRefundError(null);
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",
@@ -565,7 +557,8 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
</div>
</div>
)}
</Modal>
</Modal>
);
}
+1 -1
View File
@@ -326,7 +326,7 @@ export function CustomerPortal() {
)}
{/* 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>
<h1 className="text-lg font-semibold text-stone-800">
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
</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: "payment" as const, label: "Payment Methods", icon: CreditCard },
-7
View File
@@ -119,10 +119,3 @@ uri
database-url
{{- 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
value: {{ .Values.api.env.oidcAudience | quote }}
{{- 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
valueFrom:
secretKeyRef:
-2
View File
@@ -18,8 +18,6 @@ api:
corsOrigin: ""
oidcIssuer: ""
oidcAudience: groombook
betterAuthUrl: ""
internalBaseUrl: ""
port: "3000"
service:
type: ClusterIP
-309
View File
@@ -1,309 +0,0 @@
# 10DLC Pilot Tenant Registration Runbook
Authored for [GRO-106](/GRO/issues/GRO-106) Phase 1.
---
## Pre-Flight Checklist
Before starting Telnyx registration, collect the following:
| Item | Details |
|------|---------|
| Legal business name | Exact name on EIN / business registration |
| EIN (Employer Identification Number) | 9-digit IRS format: XX-XXXXXXX |
| Business type | Sole Proprietor / LLC / Corporation |
| Primary contact email | General contact address (postmaster@, info@, etc.) |
| Primary contact phone | Direct line for carrier verification |
| Website URL | Must be live and contain privacy policy |
| Sample message templates | See [Sample Templates](#sample-message-templates) below |
| Messaging use case | Customer Care / Account Notification |
---
## Step 1 — Telnyx Account Requirements
- Active Telnyx account with billing configured.
- Role required: **Admin** or **Super User** to register brands and campaigns.
---
## Step 2 — Brand Registration
### Via Telnyx Console
1. Log in to [Telnyx Portal](https://portal.telnyx.com).
2. Navigate to **Messaging → A2P 10DLC → Brands**.
3. Click **Register Brand**.
4. Fill in:
- **Brand Name**: Legal business name
- **Legal Company Name**: Exact EIN name
- **Company Type**: Select from dropdown
- **EIN**: XX-XXXXXXX
- **Primary Contact**: Name, email, phone
- **Website**: Must be accessible
- **BusinessVertical**: Select appropriate vertical
5. Acknowledge the **Terms of Service**.
6. Submit.
### Via API
```bash
curl -X POST https://api.telnyx.com/v2/10dlc/brands \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Your Legal Business Name",
"legal_company_name": "Your Legal Business Name",
"company_type": "llc",
"ein": "XX-XXXXXXX",
"primary_contact": {
"name": "Jane Doe",
"email": "compliance@example.com",
"phone": "+13125551000"
},
"website": "https://www.example.com",
"business_vertical": "FINANCE_INSURANCE_BANKING"
}'
```
**Response fields to record:**
- `brand_id` — required for campaign registration
- `brand_score` — affects campaign vetting speed
### Expected Fees
| Fee Type | Amount |
|----------|--------|
| Brand registration fee | ~$0 (no direct fee from Telnyx) |
| Campaign registration fee | ~$15$25 per campaign (Telnyx fee, subject to change) |
| Carrier fees | Passed through from T-Mobile/AT&T/Verizon |
### Expected Approval Window
- **Vetting by Telnyx**: 13 business days after submission.
- **Carrier (T-Mobile/AT&T/Verizon) review**: 25 business days after Telnyx approval.
- Total end-to-end: **38 business days**.
---
## Step 3 — Campaign Registration
### Use Case Selection
- **Primary**: Customer Care
- **Secondary**: Account Notification
### Via Telnyx Console
1. Navigate to **Messaging → A2P 10DLC → Campaigns**.
2. Click **Register Campaign**.
3. Select **Brand** (use the brand registered in Step 2).
4. Fill in:
- **Campaign Name**: e.g., `groombook-pilot-customer-care`
- **Use Case**: Customer Care / Account Notification
- **Sample Messages**: Paste exactly the templates from [Sample Templates](#sample-message-templates) below.
- **Description**: Brief description of messaging program
- **Estimated Volume**: Enter monthly estimate (e.g., 500)
5. Submit.
### Via API
```bash
curl -X POST https://api.telnyx.com/v2/10dlc/campaigns \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"brand_id": "YOUR_BRAND_ID",
"name": "groombook-pilot-customer-care",
"use_case": "CUSTOMER_CARE",
"sample_messages": [
"Hi {{first_name}}, this is a reminder from {{business_name}} that your appointment is scheduled for {{date}} at {{time}}. Reply STOP to opt out.",
"Your appointment with {{business_name}} is confirmed for {{date}}. Need to reschedule? Reply HELP or call us at {{phone}}."
],
"description": "Appointment reminders and account notifications for grooming clients",
"estimated_monthly_volume": 500
}'
```
**Response fields to record:**
- `campaign_id` — required for messaging profile
- `status` — initially `PENDING`, transitions to `ACTIVE` after carrier approval
### Campaign Vetting — STOP/HELP Language Requirements
Every campaign **must** include compliant STOP/HELP messaging. The following must appear in your sample messages or be included in your terms of service:
- **STOP**: Users can text `STOP` to opt out of all messages.
- **HELP**: Users can text `HELP` to receive contact information.
Example STOP/HELP block:
```
Text STOP to opt out. Text HELP for help. Msg & data rates may apply.
```
---
## Step 4 — Messaging Profile + Phone Number Provisioning
### Create Messaging Profile
1. In Telnyx Portal, navigate to **Messaging → Messaging Profiles**.
2. Click **Create Messaging Profile**.
3. Name it (e.g., `groombook-pilot-prod`).
4. Copy the **Messaging Profile ID** (`messaging_profile_id`) — record this in the DB.
### Provision a 10DLC Phone Number
1. Navigate to **Messaging → Phone Numbers**.
2. Search for a number in your desired area code.
3. Confirm the number is 10DLC-capable.
4. Purchase the number.
### Associate Number with Messaging Profile
```bash
# Assign number to messaging profile
curl -X PATCH https://api.telnyx.com/v2/phone_numbers/YOUR_PHONE_NUMBER_ID \
-H "Authorization: Bearer $TELNYX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"messaging_profile_id": "YOUR_MESSAGING_PROFILE_ID"
}'
```
---
## Step 5 — Record in Database
Once [GRO-981](/GRO/issues/GRO-981) lands, record the following against the business record:
### SQL Path (when GRO-981 is complete)
```sql
UPDATE businesses
SET
messaging_phone_number = '+13125551000',
telnyx_messaging_profile_id = 'YOUR_MESSAGING_PROFILE_ID',
telnyx_brand_id = 'YOUR_BRAND_ID',
telnyx_campaign_id = 'YOUR_CAMPAIGN_ID',
telnyx_brand_status = 'APPROVED',
telnyx_campaign_status = 'ACTIVE',
updated_at = NOW()
WHERE id = 'pilot_business_id';
```
### Manual Admin Path (before GRO-981)
Until GRO-981 is complete, use the Telnyx Portal to verify and record values manually in your internal ops sheet:
| Field | Value |
|-------|-------|
| `messagingPhoneNumber` | +1XXXXXXXXXX |
| `telnyxMessagingProfileId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `telnyxBrandId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `telnyxCampaignId` | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| `brandStatus` | APPROVED / PENDING |
| `campaignStatus` | ACTIVE / PENDING |
---
## Sample Message Templates
These must match exactly what your system will send. Vetting reviewers compare templates against actual traffic.
### Transactional Appointment Reminder
```
Hi {{first_name}}, this is a reminder from {{business_name}} that your appointment is scheduled for {{date}} at {{time}}. Reply STOP to opt out. Msg & data rates may apply.
```
### Manual Staff Message
```
Your appointment with {{business_name}} is confirmed for {{date}}. Need to reschedule? Reply HELP for assistance or call us at {{phone}}. Msg & data rates may apply.
```
---
## Failure Modes + Retry Guidance
### Vetting Rejection — Brand
| Rejection Reason | Common Fix |
|-----------------|------------|
| Legal name mismatch with EIN | Ensure exact EIN name matches legal company name exactly |
| Website not accessible / missing privacy policy | Add privacy policy page to website before resubmitting |
| Incomplete primary contact | Provide direct phone and real email (no noreply) |
| High-risk business vertical | Contact Telnyx support for pre-screening before resubmitting |
### Campaign Rejection
| Rejection Reason | Common Fix |
|-----------------|------------|
| Sample messages do not match actual traffic | Update sample messages to match exactly what the system sends |
| Missing STOP/HELP language | Add compliant STOP/HELP block to sample messages |
| Volume estimate too low/high | Revise estimate to be realistic |
| Use case mismatch | Re-select use case that matches actual messaging |
### Re-submission
After fixing the rejection reason, re-submit via the same API endpoint. Telnyx will re-run vetting (typically 2448 hours).
---
## Cost Summary
### Telnyx Fees (as of 2026)
| Fee Type | Amount | Notes |
|----------|--------|-------|
| 10DLC number (monthly) | ~$1.00$2.50/number | Varies by type and area code |
| Outbound message | $0.005$0.015/message | Depends on destination carrier |
| Inbound message | Included | No charge for received messages |
| Campaign registration | ~$15$25 one-time | Per campaign, subject to change |
### Carrier Fees (T-Mobile / AT&T / Verizon)
| Carrier | Outbound Fee | Notes |
|---------|-------------|-------|
| T-Mobile | ~$0.005$0.01/message | Varies by message size (segment) |
| AT&T | ~$0.005$0.015/message | Varies by message size (segment) |
| Verizon | ~$0.005$0.01/message | Varies by message size (segment) |
**Note**: Carrier fees are subject to change. Check [Telnyx pricing page](https://telnyx.com/pricing) and carrier fee schedules for current rates.
### Example Monthly Cost (Pilot — 500 messages/month)
| Line Item | Cost |
|-----------|------|
| 1x 10DLC number | ~$2.00 |
| 500 outbound messages | ~$5.00$7.50 |
| Carrier pass-through | ~$2.50$7.50 |
| **Estimated Monthly Total** | **~$9.50$17.00** |
---
## Rollback / De-provisioning
If the pilot tenant must be de-provisioned:
1. Release the phone number: Telnyx Portal → Phone Numbers → Release.
2. Archive the campaign: set status to `INACTIVE` via API or console.
3. Remove DB record: clear `messagingPhoneNumber`, `telnyxMessagingProfileId`, `telnyxCampaignId` fields in the business record.
4. Brand can remain registered (no harm) but will not be used.
---
## Contacts
| Resource | Contact |
|----------|---------|
| Telnyx Support | support@telnyx.com |
| Telnyx Dashboard | portal.telnyx.com |
| Internal Engineering | Raise issue in [GRO-106](/GRO/issues/GRO-106) |
---
_Last updated: 2026-05-04_
-11
View File
@@ -1,11 +0,0 @@
# GroomBook Runbooks
Operational runbooks for GroomBook staff and operators.
| Runbook | Description | Status |
|---------|-------------|--------|
| [10DLC Pilot Registration](./10dlc-pilot-registration.md) | Register a pilot grooming business as an A2P 10DLC brand + campaign on Telnyx | Active |
---
_To add a runbook, create a markdown file in this directory and update this table._
+1 -4
View File
@@ -978,7 +978,6 @@ async function seed() {
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 stripePaymentIntentId = invoiceStatus === "paid" && rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
invoiceBatch.push({
id: invoiceId,
appointmentId: apptId,
@@ -990,7 +989,6 @@ async function seed() {
status: invoiceStatus,
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
paidAt,
stripePaymentIntentId,
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
});
@@ -1094,14 +1092,13 @@ async function seed() {
const taxCents = Math.round(effectivePrice * 0.08);
const totalCents = effectivePrice + taxCents + tipCents;
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
const stripePaymentIntentId = rand() < 0.2 ? `pi_test_${uuid().replace(/-/g, "").slice(0, 24)}` : null;
invoiceBatch.push({
id: invoiceId, appointmentId: apptId, clientId,
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
status: "paid" as const,
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
paidAt, stripePaymentIntentId, notes: null,
paidAt, notes: null,
});
lineItemBatch.push({
id: uuid(), invoiceId, description: svc.name, quantity: 1,