Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Lead with **Rachel** (fastest close, no FedRAMP blocker). Use **Jordan** wins as

**Stage 1 (checkpoint lapsed 2026-06-25; pricing unchanged and still current):**
- **CMMC AI Risk Assessment Report — $499 one-time** (primary product). Run the proxy 14 days in the customer's environment → SHA-256-signed PDF risk-scoring every AI prompt event against NIST 800-171. No subscription, no MSA.
- Co-branded RPO version — **$299 wholesale** (RPO charges client $499–$999, keeps the margin).
- Co-branded RPO version — **$399 wholesale** = a flat **$100 off** retail (RPO charges client $499–$999 and keeps the margin: 20% at $499, 60% at $999). It is a DISCOUNT, not a payout — no money ever leaves. Canonical: `lib/pricing/plans.ts`.

**Stage 2 (Jul–Sep 2026, only after Stage 1 triggers hit):**
- Starter **$299/mo** — quarterly gap report, basic monitoring (this replaces the old $199 Pro tier)
Expand All @@ -105,7 +105,7 @@ Annual discount 17%. 30-day money-back. ONE pricing grid. No Federal tier until

## Channel Priority

1. **RPO / MSP partnerships (primary — fastest path to volume).** Target 50 RPOs from the Cyber AB Marketplace. Offer 40–50% revenue share on the co-branded $499 report. Top targets: Summit 7, MAD Security, CyberSheath, CompliancePoint, BEMO, Steel Root, Etactics. **RPOs/MSPs, NOT C3PAOs** — C3PAOs are legally prohibited (32 CFR Part 170, ISO 17020 cooling-off) from recommending products to clients they assess.
1. **RPO / MSP partnerships (primary — fastest path to volume).** Target 50 RPOs from the Cyber AB Marketplace. Offer the co-branded $499 report at **$399 wholesale — a flat $100 off**. The partner pays us $399, bills their own client $499–$999, and keeps the spread. **We never pay a partner anything**: it is a discount, not a revenue share, so there is no payout to track. State it in DOLLARS, never a percentage — a percentage forces a rounding call ($499 × 0.80 = $399.20) and every rounding is a new number to drift. The retired figures (40–50%, and a separate "20% revenue share" on the same page) matched neither and are deleted. Canonical: `lib/pricing/plans.ts`, guarded by `lib/pricing/__tests__/partner-offer-coherence.test.ts`. Top targets: Summit 7, MAD Security, CyberSheath, CompliancePoint, BEMO, Steel Root, Etactics. **RPOs/MSPs, NOT C3PAOs** — C3PAOs are legally prohibited (32 CFR Part 170, ISO 17020 cooling-off) from recommending products to clients they assess.
2. **Direct outreach — HIPAA-first** (parallel, faster validation): healthcare Privacy Officers/CISOs, then law-firm IT directors, then defense (longer cycle — build pipeline now).
3. **SEO + content** (builds over 3–6 months): "GCC High Copilot vs third-party AI firewall" is the highest-value article. Publish `llms.txt`, FAQ schema, write for Perplexity citations (AEO).

Expand All @@ -117,7 +117,7 @@ Annual discount 17%. 30-day money-back. ONE pricing grid. No Federal tier until
|-------------|--------|-----------------|
| Supabase auth + DB | ✅ Wired | Migrations through 034 in repo. Applied to prod: 001–027, plus **028 (rate-limit buckets), 031 (auth lockouts) and 032 (auth audit trail) applied 2026-08-12** — shared rate limiting, lockout, CAPTCHA escalation and the auth audit trail are now live. **Unapplied: 029 + 030** (seed-anchor chain — separate subsystem), **033** (restrictive deny-all on the Better Auth tables) and **034** (marketing opt-out column — CAN-SPAM). `/api/health` now reports the rate-limit and lockout stores as degraded when a migration is missing, instead of reporting green. |
| Stripe checkout | ✅ Wired | Add a **$499 one-time** report SKU (Stage 1 primary product) |
| Stripe webhook | ⚠️ Verify URL | Confirm `https://houndshield.com/api/stripe/webhook` |
| Stripe webhook | ⚠️ Verify URL | Confirm `https://www.houndshield.com/api/stripe/webhook` |
| STRIPE_WEBHOOK_SECRET | ❌ Verify | Confirm set in Vercel dashboard |
| OpenRouter / Brain AI | ❌ Missing key | Set `OPENROUTER_API_KEY`; Brain AI CUI warning must be live regardless |
| Resend (email) | ✅ Configured | — |
Expand Down
9 changes: 7 additions & 2 deletions compliance-firewall-agent/app/api/stripe/portal/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Stripe from 'stripe';
import { getStripeSecretKey } from '@/lib/stripe/env';
import { STRIPE_API_VERSION } from '@/lib/stripe/api-version';
import { createClient } from '@/lib/supabase/server';
import { SITE_URL } from '@/lib/site-url';

function getStripe() {
return new Stripe(getStripeSecretKey()!, {
Expand Down Expand Up @@ -46,11 +47,15 @@ export async function POST() {
}

const stripe = getStripe();
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';

// SITE_URL, not `NEXT_PUBLIC_APP_URL || localhost`. That variable is UNSET in
// production (measured 2026-08-14 — see lib/site-url.ts), so this route was
// handing Stripe a `return_url` of `http://localhost:3000/command-center/settings`:
// a paying customer finishes managing their billing, clicks back, and lands on
// their own machine. SITE_URL still honours the override where it is set.
const session = await stripe.billingPortal.sessions.create({
customer: profile.stripe_customer_id,
return_url: `${appUrl}/command-center/settings`,
return_url: `${SITE_URL}/command-center/settings`,
});

return NextResponse.json({ url: session.url });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ vi.mock("@/lib/supabase/client", () => ({

import { POST } from "@/app/api/stripe/report-checkout/route";
import { NextRequest } from "next/server";
import { RISK_REPORT_WHOLESALE_CENTS } from "@/lib/pricing/plans";

const APPROVED_UUID = "11111111-1111-4111-8111-111111111111";

Expand Down Expand Up @@ -150,14 +151,14 @@ describe("POST /api/stripe/report-checkout", () => {
expect(args.metadata.wholesale).toBe("false");
});

it("grants $299 wholesale only for a verified approved partner (H3)", async () => {
it("grants wholesale pricing only for a verified approved partner (H3)", async () => {
process.env.STRIPE_SECRET_KEY = "sk_test";
mockIsConfigured.mockReturnValue(true);
mockPartnerLookup.mockResolvedValue({ data: { id: APPROVED_UUID, status: "approved" } });

await POST(makeRequest({ wholesale: true, partner_ref: APPROVED_UUID }));
const args = mockSessionsCreate.mock.calls[0][0];
expect(args.line_items[0].price_data.unit_amount).toBe(29900);
expect(args.line_items[0].price_data.unit_amount).toBe(RISK_REPORT_WHOLESALE_CENTS);
expect(args.metadata.wholesale).toBe("true");
expect(args.metadata.partner_ref).toBe(APPROVED_UUID);
});
Expand Down
19 changes: 12 additions & 7 deletions compliance-firewall-agent/app/api/stripe/report-checkout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getStripeSecretKey } from '@/lib/stripe/env';
import { STRIPE_API_VERSION } from '@/lib/stripe/api-version';
import { isSupabaseConfigured, createServiceClient } from '@/lib/supabase/client';
import { REPORT_VERTICALS, reportPaymentLinkUrl } from '@/lib/stripe/report-payment-link';
import { RISK_REPORT_RETAIL_CENTS, RISK_REPORT_WHOLESALE_CENTS } from '@/lib/pricing/plans';
import { SITE_URL } from '@/lib/site-url';

/**
Expand All @@ -18,7 +19,8 @@ import { SITE_URL } from '@/lib/site-url';
* and sends fulfillment instructions.
*
* Pricing is anchored at $499 and must never drop below it (it anchors value).
* RPO/MSP co-brand wholesale is $299 — passed via `partner_ref` + `wholesale`.
* RPO/MSP co-brand wholesale is retail less the partner cut (see
* lib/pricing/plans.ts) — passed via `partner_ref` + `wholesale`.
*
* Env:
* STRIPE_SECRET_KEY (required for dynamic checkout — see fallback)
Expand All @@ -29,12 +31,15 @@ import { SITE_URL } from '@/lib/site-url';
* Fallback rail: when the key is missing/unusable or the Stripe call fails,
* RETAIL buyers get the Stripe-hosted Payment Link for the same $499 price
* (lib/stripe/report-payment-link.ts) instead of an error — a bad env paste
* must never turn a buyer away. Wholesale ($299) cannot be served by the
* must never turn a buyer away. Wholesale cannot be served by the
* $499 link, so it keeps the honest error.
*/

const RETAIL_CENTS = 49900; // $499 — never lower (anchors value)
const WHOLESALE_CENTS = 29900; // $299 — RPO/MSP co-brand wholesale only
// Both DERIVED from lib/pricing/plans.ts. Hardcoding them here is how the
// wholesale price and the published partner percentage drifted apart: the page
// said one thing and the card was charged another.
const RETAIL_CENTS = RISK_REPORT_RETAIL_CENTS; // $499 — never lower (anchors value)
const WHOLESALE_CENTS = RISK_REPORT_WHOLESALE_CENTS; // retail less the partner cut

/**
* A key that cannot possibly authenticate (the classic pastes: publishable
Expand All @@ -49,7 +54,7 @@ function usableSecretKey(): string | null {
const VALID_VERTICALS = new Set<string>(REPORT_VERTICALS);

/**
* Wholesale ($299) is only valid for a real, approved partner (audit H3).
* Wholesale is only valid for a real, approved partner (audit H3).
* `partner_ref` must be the id of a partner_applications row whose status is
* 'approved' or 'active'. Any unverified ref falls back to the $499 retail price
* so the $499 anchor can never be self-served away.
Expand Down Expand Up @@ -84,7 +89,7 @@ export async function POST(request: NextRequest) {
wholesale = false,
} = body as { vertical?: string; partner_ref?: string; wholesale?: boolean };

// Wholesale ($299) is only valid for a verified, approved partner — the
// Wholesale is only valid for a verified, approved partner — the
// ref is checked against the DB server-side (audit H3). A client cannot
// self-serve the wholesale price by passing an arbitrary partner_ref.
const isWholesale = Boolean(wholesale) && (await isApprovedPartner(partner_ref));
Expand Down Expand Up @@ -160,7 +165,7 @@ export async function POST(request: NextRequest) {
} catch (err) {
// A plausibly-usable key still failed (revoked key, Stripe outage…).
// Same rescue: retail rides the payment link; wholesale stays an
// honest error rather than silently upcharging $299$499.
// honest error rather than silently upcharging wholesaleretail.
console.error('[Stripe Report Checkout] session create failed:', err);
if (!isWholesale) {
return NextResponse.json({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,7 @@ describe("POST /api/stripe/webhook — report order idempotency", () => {

// Wire the report_orders idempotency probe to a specific result. `existing`
// truthy = the order was already recorded by a prior webhook delivery.
function setupWithExistingOrder(existing: { id: string } | null) {
function setupWithExistingOrder(existing: { id: string; status?: string } | null) {
mockFrom.mockReturnValue({
upsert: mockUpsert,
update: mockUpdate,
Expand Down Expand Up @@ -646,6 +646,86 @@ describe("POST /api/stripe/webhook — report order idempotency", () => {
expect(recipients).toContain("rachel@clinic.com");
expect(recipients).toContain("founder@houndshield.com");
});

// ── delayed-notification payment methods ────────────────────────────────
//
// `checkout.session.completed` does NOT mean the money arrived. ACH, Bacs, SEPA
// and Klarna fire it on AUTHORISATION, with `payment_status: 'unpaid'` and funds
// days away. Recording that as 'paid' starts a 14-day fulfillment engagement and
// books revenue for money that may never land.

const unpaidReportEvent = (id: string) => ({
type: "checkout.session.completed",
id,
data: {
object: {
id: "cs_report_dup",
mode: "payment",
payment_status: "unpaid",
customer_details: { email: "rachel@clinic.com", name: "Rachel H" },
amount_total: 49900,
currency: "usd",
metadata: { product: "cmmc_ai_risk_report", vertical: "healthcare", wholesale: "false" },
},
},
});

it("records an UNPAID session as pending_payment and sends nothing", async () => {
setupWithExistingOrder(null);
mockConstructEvent.mockReturnValueOnce(unpaidReportEvent("evt_unpaid"));

const res = await POST(makeRequest());
expect(res.status).toBe(200);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({ status: "pending_payment" }),
expect.any(Object),
);
expect(mockResendSend).not.toHaveBeenCalled();
});

it("promotes pending_payment → paid and sends the emails when the funds land", async () => {
// The async success re-delivers the same session; the row already exists.
setupWithExistingOrder({ id: "existing-order-1", status: "pending_payment" });
mockConstructEvent.mockReturnValueOnce({
...reportEvent("evt_async_ok"),
type: "checkout.session.async_payment_succeeded",
});

const res = await POST(makeRequest());
expect(res.status).toBe(200);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({ status: "paid" }),
expect.any(Object),
);
// First time this order is actually funded → notify now, not before.
expect(mockResendSend).toHaveBeenCalledTimes(2);
});

it("never walks a status BACKWARDS on a late retry", async () => {
// Fulfillment already advanced; a delayed Stripe retry must not reset it to 'paid'.
setupWithExistingOrder({ id: "existing-order-1", status: "report_delivered" });
mockConstructEvent.mockReturnValueOnce(reportEvent("evt_late_retry"));

const res = await POST(makeRequest());
expect(res.status).toBe(200);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({ status: "report_delivered" }),
expect.any(Object),
);
expect(mockResendSend).not.toHaveBeenCalled();
});

it("does not resurrect a refunded order", async () => {
setupWithExistingOrder({ id: "existing-order-1", status: "refunded" });
mockConstructEvent.mockReturnValueOnce(reportEvent("evt_after_refund"));

const res = await POST(makeRequest());
expect(res.status).toBe(200);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({ status: "refunded" }),
expect.any(Object),
);
});
});

// ── customer.subscription.updated ─────────────────────────────────────────
Expand Down Expand Up @@ -747,10 +827,50 @@ describe("POST /api/stripe/webhook — invoice.payment_failed", () => {
vi.clearAllMocks();
});

it("sets subscription status to past_due", async () => {
// THE SHAPE STRIPE ACTUALLY SENDS. `invoice.subscription` was removed in API
// version 2025-04-30.basil; this integration pins 2026-07-29.dahlia and the live
// endpoint runs 2026-02-25.clover, both past basil. The previous version of this
// test asserted the pre-basil shape, so it passed green over a handler that could
// never fire in production. If someone reverts `invoiceSubscriptionId`, THIS fails.
it("sets subscription status to past_due (post-basil parent shape)", async () => {
mockConstructEvent.mockReturnValueOnce({
type: "invoice.payment_failed",
id: "evt_failed",
data: {
object: {
parent: {
type: "subscription_details",
subscription_details: { subscription: "sub_123" },
},
},
},
});

const res = await POST(makeRequest());
expect(res.status).toBe(200);
expect(mockUpdate).toHaveBeenCalledWith({ status: "past_due" });
});

it("reads an EXPANDED subscription object under parent", async () => {
mockConstructEvent.mockReturnValueOnce({
type: "invoice.payment_failed",
id: "evt_expanded",
data: {
object: {
parent: { subscription_details: { subscription: { id: "sub_123" } } },
},
},
});

const res = await POST(makeRequest());
expect(res.status).toBe(200);
expect(mockUpdate).toHaveBeenCalledWith({ status: "past_due" });
});

it("still reads the legacy pre-basil top-level field", async () => {
mockConstructEvent.mockReturnValueOnce({
type: "invoice.payment_failed",
id: "evt_legacy",
data: { object: { subscription: "sub_123" } },
});

Expand Down Expand Up @@ -789,7 +909,11 @@ describe("POST /api/stripe/webhook — invoice.paid", () => {
mockConstructEvent.mockReturnValueOnce({
type: "invoice.paid",
id: "evt_paid",
data: { object: { subscription: "sub_123" } },
data: {
object: {
parent: { subscription_details: { subscription: "sub_123" } },
},
},
});

const res = await POST(makeRequest());
Expand All @@ -798,6 +922,73 @@ describe("POST /api/stripe/webhook — invoice.paid", () => {
});
});

// ── charge.refunded / charge.dispute.created ──────────────────────────────
//
// Money leaving again. Without these, a refunded $499 order stays at status
// 'paid' and keeps counting as revenue and as a paying customer in
// lib/admin/founder-metrics.ts — the numbers the Sep 1 kill-criteria review reads.

describe("POST /api/stripe/webhook — refunds and disputes", () => {
beforeEach(() => {
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test";
setupSupabase();
mockEq.mockResolvedValue({ error: null });
});

afterEach(() => {
delete process.env.STRIPE_WEBHOOK_SECRET;
vi.clearAllMocks();
});

it("marks a fully refunded order 'refunded'", async () => {
mockConstructEvent.mockReturnValueOnce({
type: "charge.refunded",
id: "evt_refund",
data: { object: { payment_intent: "pi_123", refunded: true, amount: 49900, amount_refunded: 49900 } },
});

const res = await POST(makeRequest());
expect(res.status).toBe(200);
expect(mockUpdate).toHaveBeenCalledWith({ status: "refunded" });
});

it("leaves a PARTIAL refund counted — a goodwill credit is not a reversal", async () => {
mockConstructEvent.mockReturnValueOnce({
type: "charge.refunded",
id: "evt_partial",
data: { object: { payment_intent: "pi_123", refunded: false, amount: 49900, amount_refunded: 5000 } },
});

const res = await POST(makeRequest());
expect(res.status).toBe(200);
expect(mockUpdate).not.toHaveBeenCalled();
});

it("marks a disputed order 'disputed'", async () => {
mockConstructEvent.mockReturnValueOnce({
type: "charge.dispute.created",
id: "evt_dispute",
data: { object: { payment_intent: "pi_123" } },
});

const res = await POST(makeRequest());
expect(res.status).toBe(200);
expect(mockUpdate).toHaveBeenCalledWith({ status: "disputed" });
});

it("acknowledges (200) when there is no payment_intent to match on", async () => {
mockConstructEvent.mockReturnValueOnce({
type: "charge.dispute.created",
id: "evt_nopi",
data: { object: {} },
});

const res = await POST(makeRequest());
expect(res.status).toBe(200);
expect(mockUpdate).not.toHaveBeenCalled();
});
});

// ── Unhandled event type ──────────────────────────────────────────────────

describe("POST /api/stripe/webhook — unhandled events", () => {
Expand Down
Loading
Loading