Brings the working codebase (Next.js app, auth system, Stripe billing, Docker/deploy config, tests, docs) into version control on top of the placeholder initial commit, and adds account self-deletion (Danger Zone in Settings, password + typed-email confirmation, cascading DB cleanup, Stripe cancellation) per GDPR right-to-erasure. Excludes local build caches, node_modules, and internal agent scratch files; .gitignore hardened to keep those out going forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
178 lines
8.1 KiB
TypeScript
178 lines
8.1 KiB
TypeScript
/**
|
|
* Webhook Verification Suite
|
|
*
|
|
* Pure decision logic for the Stripe webhook policy: event-type allowlist,
|
|
* payment-status gating, the amount cross-check against the REAL shared price
|
|
* catalog, plan validation and expiry derivation. Signature verification
|
|
* itself requires live Stripe secrets (HMAC of the raw body), so it is only
|
|
* ever exercised end-to-end by the running app — everything that decides
|
|
* whether a license may be granted is covered here, no database needed.
|
|
*/
|
|
|
|
import { describe, test, expect } from "./runner";
|
|
import {
|
|
ALLOWED_EVENT_TYPES,
|
|
isAllowedEventType,
|
|
isPaymentConfirmed,
|
|
sessionModeMatchesPlan,
|
|
subscriptionExpiresAt,
|
|
verifyPaidAmount,
|
|
} from "../../src/lib/billing/webhookPolicy";
|
|
import { getPlanConfig, isPlanId, resolvePlan } from "../../src/lib/billing/pricing";
|
|
|
|
describe("WebhookVerification — event type allowlist", () => {
|
|
test("all handled event types are allowed (including the fixed invoice.payment_succeeded)", () => {
|
|
expect(isAllowedEventType("checkout.session.completed")).toBe(true);
|
|
expect(isAllowedEventType("checkout.session.expired")).toBe(true);
|
|
expect(isAllowedEventType("invoice.payment_failed")).toBe(true);
|
|
expect(isAllowedEventType("invoice.payment_succeeded")).toBe(true);
|
|
expect(isAllowedEventType("customer.subscription.updated")).toBe(true);
|
|
expect(isAllowedEventType("customer.subscription.deleted")).toBe(true);
|
|
});
|
|
|
|
test("unhandled and unknown event types are rejected", () => {
|
|
expect(isAllowedEventType("charge.succeeded")).toBe(false);
|
|
expect(isAllowedEventType("payment_intent.succeeded")).toBe(false);
|
|
expect(isAllowedEventType("customer.created")).toBe(false);
|
|
expect(isAllowedEventType("invoice.created")).toBe(false);
|
|
expect(isAllowedEventType("")).toBe(false);
|
|
});
|
|
|
|
test("the allowlist is not accidentally empty", () => {
|
|
expect(ALLOWED_EVENT_TYPES.size).toBeGreaterThan(0);
|
|
// Every allowlisted type is a real Stripe event name pattern, never a wildcard.
|
|
expect(ALLOWED_EVENT_TYPES.has("*")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("WebhookVerification — payment confirmation gating", () => {
|
|
test("payment-mode session is confirmed only when payment_status is paid", () => {
|
|
expect(isPaymentConfirmed({ mode: "payment", payment_status: "paid" }, null)).toBe(true);
|
|
expect(isPaymentConfirmed({ mode: "payment", payment_status: "unpaid" }, null)).toBe(false);
|
|
expect(isPaymentConfirmed({ mode: "payment", payment_status: "no_payment_required" }, null)).toBe(
|
|
false
|
|
);
|
|
expect(isPaymentConfirmed({ mode: "payment", payment_status: null }, null)).toBe(false);
|
|
});
|
|
|
|
test("subscription is confirmed while active or trialing", () => {
|
|
expect(isPaymentConfirmed({ mode: "subscription" }, { status: "active" })).toBe(true);
|
|
expect(isPaymentConfirmed({ mode: "subscription" }, { status: "trialing" })).toBe(true);
|
|
});
|
|
|
|
test("subscription is rejected when canceled, unpaid, past_due or missing", () => {
|
|
expect(isPaymentConfirmed({ mode: "subscription" }, { status: "canceled" })).toBe(false);
|
|
expect(isPaymentConfirmed({ mode: "subscription" }, { status: "unpaid" })).toBe(false);
|
|
expect(isPaymentConfirmed({ mode: "subscription" }, { status: "past_due" })).toBe(false);
|
|
expect(isPaymentConfirmed({ mode: "subscription" }, { status: "incomplete" })).toBe(false);
|
|
expect(isPaymentConfirmed({ mode: "subscription" }, null)).toBe(false);
|
|
expect(isPaymentConfirmed({ mode: "subscription" }, undefined)).toBe(false);
|
|
});
|
|
|
|
test("unknown or missing session modes are never confirmed", () => {
|
|
expect(isPaymentConfirmed({ mode: null, payment_status: "paid" }, null)).toBe(false);
|
|
expect(isPaymentConfirmed({ mode: "setup", payment_status: "paid" }, null)).toBe(false);
|
|
expect(isPaymentConfirmed({}, null)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("WebhookVerification — amount cross-check against the real catalog", () => {
|
|
test("lifetime amount matching the catalog exactly is accepted", () => {
|
|
const session = {
|
|
mode: "payment",
|
|
payment_status: "paid",
|
|
amount_total: getPlanConfig("lifetime").unitAmountMinor,
|
|
};
|
|
expect(verifyPaidAmount(session, "lifetime")).toBe(true);
|
|
expect(verifyPaidAmount(session, getPlanConfig("lifetime").id)).toBe(true);
|
|
// The catalog really is the source: 5999 minor units for lifetime.
|
|
expect(getPlanConfig("lifetime").unitAmountMinor).toBe(5999);
|
|
});
|
|
|
|
test("one cent less than the catalog amount is rejected", () => {
|
|
expect(
|
|
verifyPaidAmount(
|
|
{ mode: "payment", payment_status: "paid", amount_total: getPlanConfig("lifetime").unitAmountMinor - 1 },
|
|
"lifetime"
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
test("a missing amount_total is rejected for one-time payments", () => {
|
|
expect(
|
|
verifyPaidAmount({ mode: "payment", payment_status: "paid", amount_total: null }, "lifetime")
|
|
).toBe(false);
|
|
});
|
|
|
|
test("subscription sessions are not amount-checked (trial amount may be 0)", () => {
|
|
expect(verifyPaidAmount({ mode: "subscription", amount_total: 0 }, "weekly")).toBe(true);
|
|
expect(verifyPaidAmount({ mode: "subscription", amount_total: 0 }, "annual")).toBe(true);
|
|
expect(verifyPaidAmount({ mode: "subscription", amount_total: 1 }, "weekly")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("WebhookVerification — plan validation", () => {
|
|
test("known plan ids validate", () => {
|
|
expect(isPlanId("weekly")).toBe(true);
|
|
expect(isPlanId("annual")).toBe(true);
|
|
expect(isPlanId("lifetime")).toBe(true);
|
|
});
|
|
|
|
test("unknown and missing plans are rejected by isPlanId", () => {
|
|
expect(isPlanId("enterprise")).toBe(false);
|
|
expect(isPlanId("free")).toBe(false);
|
|
expect(isPlanId("")).toBe(false);
|
|
expect(isPlanId(null)).toBe(false);
|
|
expect(isPlanId(undefined)).toBe(false);
|
|
});
|
|
|
|
test("resolvePlan falls back to annual for unknown or absent values", () => {
|
|
expect(resolvePlan("lifetime")).toBe("lifetime");
|
|
expect(resolvePlan("weekly")).toBe("weekly");
|
|
expect(resolvePlan("annual")).toBe("annual");
|
|
expect(resolvePlan("bogus")).toBe("annual");
|
|
expect(resolvePlan(null)).toBe("annual");
|
|
expect(resolvePlan(undefined)).toBe("annual");
|
|
expect(resolvePlan("")).toBe("annual");
|
|
});
|
|
|
|
test("the session mode must match the plan's billing mode", () => {
|
|
expect(sessionModeMatchesPlan("payment", "lifetime")).toBe(true);
|
|
expect(sessionModeMatchesPlan("subscription", "weekly")).toBe(true);
|
|
expect(sessionModeMatchesPlan("subscription", "annual")).toBe(true);
|
|
expect(sessionModeMatchesPlan("subscription", "lifetime")).toBe(false);
|
|
expect(sessionModeMatchesPlan("payment", "annual")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("WebhookVerification — expiry derivation from the subscription", () => {
|
|
test("expiresAt comes from current_period_end, never Date.now()", () => {
|
|
const periodEnd = 1_700_000_000; // fixed point in the past — must not shift
|
|
const expires = subscriptionExpiresAt({ status: "trialing", current_period_end: periodEnd });
|
|
expect(expires?.getTime()).toBe(periodEnd * 1000);
|
|
// A fixed period end must yield a fixed expiry regardless of when the test runs.
|
|
expect(Date.now()).toBeGreaterThan(periodEnd * 1000);
|
|
});
|
|
|
|
test("missing period end or missing subscription yields no expiry", () => {
|
|
expect(subscriptionExpiresAt({ status: "active", current_period_end: null })).toBeNull();
|
|
expect(subscriptionExpiresAt({ status: "active" })).toBeNull();
|
|
expect(subscriptionExpiresAt(null)).toBeNull();
|
|
expect(subscriptionExpiresAt(undefined)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("WebhookVerification — catalog contract used by the webhook", () => {
|
|
test("lifetime is a one-time payment; weekly and annual are subscriptions", () => {
|
|
expect(getPlanConfig("lifetime").mode).toBe("payment");
|
|
expect(getPlanConfig("weekly").mode).toBe("subscription");
|
|
expect(getPlanConfig("annual").mode).toBe("subscription");
|
|
});
|
|
|
|
test("catalog amounts match the pricing guide (EUR minor units)", () => {
|
|
expect(getPlanConfig("lifetime").unitAmountMinor).toBe(5999);
|
|
expect(getPlanConfig("weekly").unitAmountMinor).toBe(499);
|
|
expect(getPlanConfig("annual").unitAmountMinor).toBe(3999);
|
|
});
|
|
});
|