Compare commits

..

6 Commits

Author SHA1 Message Date
Paperclip 0d73532054 GRO-607: Replace mock payment flow with real Stripe Elements
- Install @stripe/stripe-js and @stripe/react-stripe-js
- Replace BillingPayments mock delay with real Stripe Elements:
  - Fetch publishableKey from GET /api/portal/config
  - Lazy load Stripe via loadStripe()
  - Wrap payment modal in <Elements> with PaymentElement
  - Use stripe.confirmPayment() with clientSecret from pay/pay-multiple endpoints
  - Support multi-invoice selection and single invoice payment
- Add "Save card for future payments" checkbox (setup_future_usage)
- Add payment method management: list saved cards, delete via DELETE endpoint
- Proper error handling for payment failures
- Autopay toggle (UI-only, Phase 2 backend pending)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:48:23 +00:00
Paperclip dcf5740489 GRO-606: Add payment API endpoints (pay invoice, payment methods, refunds)
Portal routes (client-facing):
- POST /api/portal/invoices/:id/pay - create PaymentIntent for single invoice
- POST /api/portal/invoices/pay-multiple - create PaymentIntent for multiple invoices
- GET /api/portal/payment-methods - list saved payment methods
- POST /api/portal/payment-methods - create SetupIntent for saving new card
- DELETE /api/portal/payment-methods/:id - detach payment method
- GET /api/portal/config - return Stripe publishable key

Admin routes:
- POST /api/invoices/:id/refund - manager-only refund endpoint

Validation:
- Cannot pay draft, void, or already-paid invoices
- Multi-invoice: all must belong to same client and be pending
- Refund requires invoice to be paid with stripePaymentIntentId

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:46:09 +00:00
Paperclip e15977ba82 GRO-605: Stripe SDK integration + payment service
- Add stripe dependency to @groombook/api
- Implement payment.ts service with:
  - getOrCreateStripeCustomer: look up or create Stripe customer
  - createPaymentIntent: create payment intent for invoice(s)
  - processRefund: full or partial refund
  - listPaymentMethods: list saved cards
  - attachPaymentMethod / detachPaymentMethod: manage saved cards
- Lazy Stripe client initialization; graceful null return when keys not set

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:41:29 +00:00
Paperclip 4079ed9d26 GRO-600: Extend reminder scheduler to send SMS alongside email
- Import smsSend from ./sms.js
- Add TCPA opt-out constant
- Check email+SMS logs separately to allow independent sends
- Add smsOptIn and phoneE164 to client query
- Conditionally send SMS for opted-in clients with valid E.164 phone
- SMS message: pet name, service, groomer, confirm/cancel links, TCPA text
- SMS failures logged but don't block email delivery
- Feature flag: only attempts SMS when SMS service is initialized
- Idempotency: per-channel reminder log prevents duplicate sends
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:24:56 +00:00
Paperclip 1cc708e6b4 feat(gro-194): SMS provider service with Telnyx SDK integration
- Added telnyx npm package
- Created sms.ts with SmsProvider interface
- Implemented TelnyxProvider with sendSms() and validateWebhookSignature()
- Added createSmsProvider() factory function
- Added smsSend() convenience function that skips when SMS_ENABLED=false
- Provider abstraction allows future Twilio or other providers
- E.164 phone validation on send
- Webhook signature verification using HMAC-SHA256

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:22:28 +00:00
Paperclip 6b300626a0 GRO-598/GRO-194 Phase 1.1: SMS schema - add consent fields to clients, channel to reminderLogs, E.164 phone validation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-12 23:18:32 +00:00
2 changed files with 5 additions and 13 deletions
+2 -7
View File
@@ -35,12 +35,6 @@ portalRouter.get("/me", async (c) => {
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
});
portalRouter.get("/config", async (c) => {
return c.json({
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
});
});
portalRouter.get("/services", async (c) => {
const db = getDb();
const allServices = await db.select().from(services).where(eq(services.active, true));
@@ -129,7 +123,7 @@ portalRouter.get("/invoices", async (c) => {
id: inv.id,
status: inv.status,
totalCents: inv.totalCents,
date: inv.createdAt,
createdAt: inv.createdAt,
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
})));
});
@@ -459,6 +453,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
import {
createPaymentIntent,
listPaymentMethods,
attachPaymentMethod,
detachPaymentMethod,
createSetupIntent,
getOrCreateStripeCustomer,
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { loadStripe } from "@stripe/stripe-js";
import { loadStripe, type Stripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
@@ -27,7 +27,7 @@ interface BillingPaymentsProps {
function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
const [invoices, setInvoices] = useState<Invoice[]>([]);
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [packages] = useState<{ name: string; remaining: number }[]>([]);
const [packages, setPackages] = useState<{ name: string; remaining: number }[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
@@ -398,10 +398,7 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
const { error: stripeError } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: `${window.location.origin}/portal/billing`,
...(saveCard ? { setup_future_usage: "off_session" } : {}),
},
confirmParams: saveCard ? { setup_future_usage: "off_session" } : undefined,
});
if (stripeError) {