Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
e037b00
feat(14-01): install @dodopayments/convex and register component
SebastienMelki Mar 21, 2026
03885f7
feat(14-01): extend schema with 6 payment tables for Dodo integration
SebastienMelki Mar 21, 2026
9c3c3b2
feat(14-01): add auth stub, env helper, and Dodo env var docs
SebastienMelki Mar 21, 2026
1200df4
feat(14-02): add seed mutation for product-to-plan mappings
SebastienMelki Mar 21, 2026
3f83e3d
feat(14-02): populate seed mutation with real Dodo product IDs
SebastienMelki Mar 21, 2026
10c0851
feat(15-02): add plan-to-features entitlements config map
SebastienMelki Mar 21, 2026
b443903
feat(15-01): add webhook HTTP endpoint with signature verification
SebastienMelki Mar 21, 2026
eb7fefb
feat(15-02): add subscription lifecycle handlers and entitlement upsert
SebastienMelki Mar 21, 2026
d9275d0
feat(15-01): add idempotent webhook event processor with dispatch ske…
SebastienMelki Mar 21, 2026
0173262
docs(15-01): complete webhook endpoint plan
SebastienMelki Mar 21, 2026
d8f0b0b
feat(15-03): wire subscription handlers into webhook dispatch
SebastienMelki Mar 21, 2026
ed83871
chore(15-04): install convex-test, vitest, and edge-runtime; configur…
SebastienMelki Mar 21, 2026
81df8f1
test(15-04): add 10 contract tests for webhook event processing pipeline
SebastienMelki Mar 21, 2026
a528840
fix(15-04): exclude __tests__ from convex typecheck
SebastienMelki Mar 21, 2026
33f5544
feat(16-01): add tier levels to PLAN_FEATURES and create entitlement …
SebastienMelki Mar 21, 2026
a57544f
feat(16-01): create Redis cache sync action and wire upsertEntitlements
SebastienMelki Mar 21, 2026
20d3f28
feat(16-02): add entitlement enforcement to API gateway
SebastienMelki Mar 21, 2026
c935f9c
feat(16-03): create frontend entitlement service with reactive Convex…
SebastienMelki Mar 21, 2026
576f9aa
feat(16-03): evolve panel gating to use entitlement feature flags
SebastienMelki Mar 21, 2026
9b8a871
test(16-04): add 6 contract tests for Convex entitlement query
SebastienMelki Mar 21, 2026
6198b72
test(16-04): add 6 unit tests for gateway entitlement enforcement
SebastienMelki Mar 21, 2026
313e973
feat(17-01): create Convex checkout session action
SebastienMelki Mar 21, 2026
033ddab
feat(17-03): create PricingSection component with tier cards and bill…
SebastienMelki Mar 21, 2026
93e6d24
refactor(17-01): extract shared ConvexClient singleton, refactor enti…
SebastienMelki Mar 21, 2026
b40a97a
feat(17-03): integrate PricingSection into App.tsx with referral code…
SebastienMelki Mar 21, 2026
8603039
Merge remote-tracking branch 'origin/main' into dodo_payments
SebastienMelki Mar 21, 2026
1db592b
chore: update generated files after main merge and pro-test build
SebastienMelki Mar 21, 2026
8ad87fa
fix: prefix unused ctx param in auth stub to pass typecheck
SebastienMelki Mar 21, 2026
548ea0c
test(17-04): add 4 E2E contract tests for checkout-to-entitlement flow
SebastienMelki Mar 21, 2026
3d285dd
feat(17-02): install dodopayments-checkout SDK and create checkout ov…
SebastienMelki Mar 21, 2026
8666fde
feat(17-02): wire locked panel CTAs and post-checkout return handling
SebastienMelki Mar 21, 2026
f4ef80b
feat(17-02): add Upgrade to Pro section in UnifiedSettings modal
SebastienMelki Mar 21, 2026
a76e3f5
fix: remove unused imports in entitlement-check test to pass typechec…
SebastienMelki Mar 21, 2026
bf7b25a
fix(17-01): guard missing DODO_PAYMENTS_API_KEY with warning instead …
SebastienMelki Mar 21, 2026
16c56fd
fix(17-01): use DODO_API_KEY env var name matching Convex dashboard c…
SebastienMelki Mar 21, 2026
a5ba742
fix(17-02): add Dodo checkout domains to CSP frame-src directive
SebastienMelki Mar 21, 2026
1fb198e
fix(17-03): use test checkout domain for test-mode Dodo products
SebastienMelki Mar 21, 2026
db16e74
chore: remove stale convex generated files
SebastienMelki Mar 21, 2026
3774801
feat(18-01): shared DodoPayments config and customer upsert in webhook
SebastienMelki Mar 21, 2026
05c85d3
feat(18-01): billing queries and actions for subscription management
SebastienMelki Mar 21, 2026
076af24
feat(18-02): add frontend billing service with reactive subscription …
SebastienMelki Mar 21, 2026
87f5a4b
feat(18-02): add subscription status display and Manage Billing butto…
SebastienMelki Mar 21, 2026
c09dcf3
feat(18-02): add persistent payment failure banner for on_hold subscr…
SebastienMelki Mar 21, 2026
c2d4c18
Merge remote-tracking branch 'origin/main' into dodo_payments
SebastienMelki Mar 22, 2026
f46c099
fix: address code review — identity bridge, entitlement gating, fail-…
SebastienMelki Mar 22, 2026
0c1b1ef
fix(payments): security audit hardening — auth gates, webhook retry, …
SebastienMelki Mar 22, 2026
5c9fa8b
Merge remote-tracking branch 'origin/main' into dodo_payments
SebastienMelki Mar 22, 2026
ff07cf0
fix: address code review — identity bridge, entitlement gating, fail-…
SebastienMelki Mar 22, 2026
721402d
Merge remote-tracking branch 'origin/main' into dodo_payments
SebastienMelki Mar 22, 2026
1e8f294
Merge branch 'main' into dodo_payments
koala73 Mar 22, 2026
63db64a
fix: address P0 access control + P1 identity bridge + P1 entitlement …
SebastienMelki Mar 22, 2026
280aa2b
fix: address P1 billing flow + P1 anon ID claim path + P2 daily-marke…
SebastienMelki Mar 22, 2026
2ffd844
fix: P0 lock down billing write actions + P2 fix claimSubscription logic
SebastienMelki Mar 22, 2026
da953f3
Merge branch 'main' into dodo_payments
SebastienMelki Mar 22, 2026
4ffdbc5
fix(billing): strip Dodo vendor IDs from public query response
SebastienMelki Mar 22, 2026
a42238a
Merge branch 'main' into dodo_payments
SebastienMelki Mar 23, 2026
52e19c6
fix(billing): address koala review cleanup items (P2/P3)
SebastienMelki Mar 23, 2026
fe186d4
fix: add missing isProUser import and allow trusted origins for tier-…
SebastienMelki Mar 23, 2026
0c7a92d
fix(gateway): require credentials for premium endpoints regardless of…
SebastienMelki Mar 23, 2026
28f6c6a
fix(tests): add catch-all route to legacy endpoint allowlist
SebastienMelki Mar 23, 2026
c91f50b
revert: remove [[...path]].js from legacy endpoint allowlist
SebastienMelki Mar 23, 2026
2f14dfe
fix(payment): apply design system to payment UI (light/dark mode)
koala73 Mar 23, 2026
85b0eef
fix(checkout): remove invalid theme prop from CheckoutOptions
koala73 Mar 23, 2026
1b47c83
merge: update dodo_payments with latest main
SebastienMelki Mar 25, 2026
75835a0
Merge branch 'dodo_payments' of ssh://github.com/koala73/worldmonitor…
SebastienMelki Mar 25, 2026
ea39e3a
merge: update dodo_payments with latest main (includes Clerk auth)
SebastienMelki Mar 26, 2026
8a907cc
fix: regenerate package-lock.json with npm 10 (matches CI Node 22)
SebastienMelki Mar 26, 2026
ce27b63
fix(gateway): enforce pro role check for authenticated free users on …
SebastienMelki Mar 26, 2026
a042b72
fix: resolve merge conflicts with main + fix gateway bearer role check
SebastienMelki Mar 26, 2026
e187bb2
fix: remove unused getSecretState import from data-loader
SebastienMelki Mar 26, 2026
6934968
feat: gate Dodo Payments init behind isProUser() (same as Clerk)
SebastienMelki Mar 27, 2026
9c0c0e1
ci: retrigger workflows
SebastienMelki Mar 27, 2026
7f6e6c0
chore: retrigger CI
SebastienMelki Mar 27, 2026
dc0014c
fix: resolve merge conflicts with main (CSP + CSS)
SebastienMelki Mar 27, 2026
4f187c3
fix(redis): use POST method in deleteRedisKey for consistency
SebastienMelki Mar 27, 2026
d6a4803
merge(quick-5): resolve 6 merge conflicts with main
SebastienMelki Mar 29, 2026
8243de7
fix(quick-5): resolve all P1 security issues from koala73 review
SebastienMelki Mar 29, 2026
b57e1bb
fix(quick-5): resolve all P2 review issues from koala73 review
SebastienMelki Mar 29, 2026
4ce29af
fix(convex): resolve TS errors in http.ts after merge
SebastienMelki Mar 29, 2026
268c6d0
fix(tests): update gateway tests for fail-closed entitlement system
SebastienMelki Mar 29, 2026
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
31 changes: 30 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,33 @@ WORLDMONITOR_VALID_KEYS=
# Set up at: https://dashboard.convex.dev/
CONVEX_URL=

# Vite-exposed Convex URL for frontend entitlement service (VITE_ prefix required for client-side access)
VITE_CONVEX_URL=


# ------ Dodo Payments (Convex + Vercel) ------

# Dodo Payments API key (test mode or live mode)
# Canonical name: DODO_API_KEY (used by convex/lib/dodo.ts and billing actions)
# Get yours at: https://app.dodopayments.com/ -> Settings -> API Keys
DODO_API_KEY=

# Dodo Payments webhook secret for signature verification
# NOTE: The @dodopayments/convex library reads DODO_PAYMENTS_WEBHOOK_SECRET internally.
# Set BOTH this value AND DODO_PAYMENTS_WEBHOOK_SECRET to the same secret.
# Get it at: https://app.dodopayments.com/ -> Developers -> Webhooks
DODO_WEBHOOK_SECRET=
DODO_PAYMENTS_WEBHOOK_SECRET=

# Dodo Payments business ID
# Found at: https://app.dodopayments.com/ -> Settings
DODO_BUSINESS_ID=

# Dodo Payments environment for client-side checkout overlay
# Values: "test_mode" (default) or "live_mode"
VITE_DODO_ENVIRONMENT=test_mode


# ------ Auth (Clerk) ------

# Clerk publishable key (browser-side, safe to expose)
Expand All @@ -273,7 +300,9 @@ VITE_CLERK_PUBLISHABLE_KEY=
# Get from: Clerk Dashboard -> API Keys
CLERK_SECRET_KEY=

# Clerk JWT issuer domain (for Convex auth config)
# Clerk JWT issuer domain — enables bearer token auth in the API gateway.
# When set, the gateway verifies Authorization: Bearer <token> headers using
# Clerk's JWKS endpoint and extracts the userId for entitlement checks.
# Format: https://your-clerk-app.clerk.accounts.dev
CLERK_JWT_ISSUER_DOMAIN=

Expand Down
214 changes: 214 additions & 0 deletions convex/__tests__/checkout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { convexTest } from "convex-test";
import { expect, test, describe } from "vitest";
import schema from "../schema";
import { api, internal } from "../_generated/api";

const modules = import.meta.glob("../**/*.ts");

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const BASE_TIMESTAMP = new Date("2026-03-21T10:00:00Z").getTime();
const TEST_USER_ID = "user_checkout_test_001";
const TEST_CUSTOMER_ID = "cust_checkout_e2e";

/**
* Helper to call the seedProductPlans mutation and return plans list.
*/
async function seedAndListPlans(t: ReturnType<typeof convexTest>) {
await t.mutation(api.payments.seedProductPlans.seedProductPlans, {});
return t.query(api.payments.seedProductPlans.listProductPlans, {});
}

/**
* Helper to seed a customer record that maps dodoCustomerId to userId.
* This mirrors the production flow where checkout metadata or a prior
* subscription.active event populates the customers table.
*/
async function seedCustomer(t: ReturnType<typeof convexTest>) {
await t.run(async (ctx) => {
await ctx.db.insert("customers", {
userId: TEST_USER_ID,
dodoCustomerId: TEST_CUSTOMER_ID,
email: "test@example.com",
createdAt: BASE_TIMESTAMP,
updatedAt: BASE_TIMESTAMP,
});
});
}

/**
* Helper to simulate a subscription webhook event.
* Includes wm_user_id in metadata (matching production checkout flow).
*/
async function simulateSubscriptionWebhook(
t: ReturnType<typeof convexTest>,
opts: {
webhookId: string;
subscriptionId: string;
productId: string;
customerId?: string;
previousBillingDate?: string;
nextBillingDate?: string;
timestamp?: number;
},
) {
await t.mutation(
internal.payments.webhookMutations.processWebhookEvent,
{
webhookId: opts.webhookId,
eventType: "subscription.active",
rawPayload: {
type: "subscription.active",
data: {
subscription_id: opts.subscriptionId,
product_id: opts.productId,
customer: {
customer_id: opts.customerId ?? TEST_CUSTOMER_ID,
},
previous_billing_date:
opts.previousBillingDate ?? new Date().toISOString(),
next_billing_date:
opts.nextBillingDate ??
new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
metadata: {
wm_user_id: TEST_USER_ID,
},
},
},
timestamp: opts.timestamp ?? BASE_TIMESTAMP,
},
);
}

// ---------------------------------------------------------------------------
// E2E Contract Tests: Checkout -> Webhook -> Entitlements
// ---------------------------------------------------------------------------

describe("E2E checkout-to-entitlement contract", () => {
test("product plans can be seeded and queried", async () => {
const t = convexTest(schema, modules);

const plans = await seedAndListPlans(t);

// Should have at least 5 plans: pro_monthly, pro_annual, api_starter, api_business, enterprise
expect(plans.length).toBeGreaterThanOrEqual(5);

// Verify key plans exist
const proMonthly = plans.find((p) => p.planKey === "pro_monthly");
expect(proMonthly).toBeDefined();
expect(proMonthly!.displayName).toBe("Pro Monthly");

const proAnnual = plans.find((p) => p.planKey === "pro_annual");
expect(proAnnual).toBeDefined();
expect(proAnnual!.displayName).toBe("Pro Annual");

const apiStarter = plans.find((p) => p.planKey === "api_starter");
expect(apiStarter).toBeDefined();

const apiBusiness = plans.find((p) => p.planKey === "api_business");
expect(apiBusiness).toBeDefined();

const enterprise = plans.find((p) => p.planKey === "enterprise");
expect(enterprise).toBeDefined();
});

test("checkout -> subscription.active webhook -> entitlements granted for pro_monthly", async () => {
const t = convexTest(schema, modules);

// Step 1: Seed product plans + customer mapping
const plans = await seedAndListPlans(t);
await seedCustomer(t);
const proMonthly = plans.find((p) => p.planKey === "pro_monthly");
expect(proMonthly).toBeDefined();

// Step 2: Simulate subscription.active webhook (with wm_user_id metadata)
const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await simulateSubscriptionWebhook(t, {
webhookId: "wh_checkout_e2e_001",
subscriptionId: "sub_checkout_e2e_001",
productId: proMonthly!.dodoProductId,
nextBillingDate: futureDate.toISOString(),
});

// Step 3: Query entitlements for the real user (not fallback)
const entitlements = await t.query(
api.entitlements.getEntitlementsForUser,
{ userId: TEST_USER_ID },
);

// Step 4: Assert pro_monthly entitlements
expect(entitlements.planKey).toBe("pro_monthly");
expect(entitlements.features.tier).toBe(1);
expect(entitlements.features.apiAccess).toBe(false);
expect(entitlements.features.maxDashboards).toBe(10);
});

test("checkout -> subscription.active webhook -> entitlements granted for api_starter", async () => {
const t = convexTest(schema, modules);

// Step 1: Seed product plans + customer mapping
const plans = await seedAndListPlans(t);
await seedCustomer(t);
const apiStarter = plans.find((p) => p.planKey === "api_starter");
expect(apiStarter).toBeDefined();

// Step 2: Simulate subscription.active webhook
const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await simulateSubscriptionWebhook(t, {
webhookId: "wh_checkout_e2e_002",
subscriptionId: "sub_checkout_e2e_002",
productId: apiStarter!.dodoProductId,
nextBillingDate: futureDate.toISOString(),
});

// Step 3: Query entitlements
const entitlements = await t.query(
api.entitlements.getEntitlementsForUser,
{ userId: TEST_USER_ID },
);

// Step 4: Assert api_starter entitlements
expect(entitlements.planKey).toBe("api_starter");
expect(entitlements.features.tier).toBe(2);
expect(entitlements.features.apiAccess).toBe(true);
expect(entitlements.features.apiRateLimit).toBeGreaterThan(0);
expect(entitlements.features.apiRateLimit).toBe(60);
expect(entitlements.features.maxDashboards).toBe(25);
});

test("expired entitlements fall back to free tier", async () => {
const t = convexTest(schema, modules);

// Step 1: Seed product plans + customer mapping
const plans = await seedAndListPlans(t);
await seedCustomer(t);
const proMonthly = plans.find((p) => p.planKey === "pro_monthly");
expect(proMonthly).toBeDefined();

// Step 2: Simulate webhook with billing dates both in the past (expired)
const pastStart = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000); // 60 days ago
const pastEnd = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago

await simulateSubscriptionWebhook(t, {
webhookId: "wh_checkout_e2e_003",
subscriptionId: "sub_checkout_e2e_003",
productId: proMonthly!.dodoProductId,
previousBillingDate: pastStart.toISOString(),
nextBillingDate: pastEnd.toISOString(),
});

// Step 3: Query entitlements -- should return free tier (expired)
const entitlements = await t.query(
api.entitlements.getEntitlementsForUser,
{ userId: TEST_USER_ID },
);

// Step 4: Assert free tier defaults
expect(entitlements.planKey).toBe("free");
expect(entitlements.features.tier).toBe(0);
expect(entitlements.features.apiAccess).toBe(false);
expect(entitlements.validUntil).toBe(0);
});
});
133 changes: 133 additions & 0 deletions convex/__tests__/entitlements.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { convexTest } from "convex-test";
import { expect, test, describe } from "vitest";
import schema from "../schema";
import { api } from "../_generated/api";
import { getFeaturesForPlan } from "../lib/entitlements";

const modules = import.meta.glob("../**/*.ts");

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const NOW = Date.now();
const FUTURE = NOW + 86400000 * 30; // 30 days from now
const PAST = NOW - 86400000; // 1 day ago

async function seedEntitlement(
t: ReturnType<typeof convexTest>,
overrides: {
userId?: string;
planKey?: string;
validUntil?: number;
updatedAt?: number;
} = {},
) {
const planKey = overrides.planKey ?? "pro_monthly";
await t.run(async (ctx) => {
await ctx.db.insert("entitlements", {
userId: overrides.userId ?? "user-test",
planKey,
features: getFeaturesForPlan(planKey),
validUntil: overrides.validUntil ?? FUTURE,
updatedAt: overrides.updatedAt ?? NOW,
});
});
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("entitlement query", () => {
test("returns free-tier defaults for unknown userId", async () => {
const t = convexTest(schema, modules);

const result = await t.query(api.entitlements.getEntitlementsForUser, {
userId: "user-nonexistent",
});

expect(result.planKey).toBe("free");
expect(result.features.tier).toBe(0);
expect(result.features.apiAccess).toBe(false);
expect(result.validUntil).toBe(0);
});

test("returns active entitlements for subscribed user", async () => {
const t = convexTest(schema, modules);

await seedEntitlement(t, {
userId: "user-pro",
planKey: "pro_monthly",
validUntil: FUTURE,
});

const result = await t.query(api.entitlements.getEntitlementsForUser, {
userId: "user-pro",
});

expect(result.planKey).toBe("pro_monthly");
expect(result.features.tier).toBe(1);
expect(result.features.apiAccess).toBe(false);
});

test("returns free-tier for expired entitlements", async () => {
const t = convexTest(schema, modules);

await seedEntitlement(t, {
userId: "user-expired",
planKey: "pro_monthly",
validUntil: PAST,
});

const result = await t.query(api.entitlements.getEntitlementsForUser, {
userId: "user-expired",
});

expect(result.planKey).toBe("free");
expect(result.features.tier).toBe(0);
expect(result.features.apiAccess).toBe(false);
expect(result.validUntil).toBe(0);
});

test("returns correct tier for api_starter plan", async () => {
const t = convexTest(schema, modules);

await seedEntitlement(t, {
userId: "user-api",
planKey: "api_starter",
validUntil: FUTURE,
});

const result = await t.query(api.entitlements.getEntitlementsForUser, {
userId: "user-api",
});

expect(result.features.tier).toBe(2);
expect(result.features.apiAccess).toBe(true);
});

test("returns correct tier for enterprise plan", async () => {
const t = convexTest(schema, modules);

await seedEntitlement(t, {
userId: "user-enterprise",
planKey: "enterprise",
validUntil: FUTURE,
});

const result = await t.query(api.entitlements.getEntitlementsForUser, {
userId: "user-enterprise",
});

expect(result.features.tier).toBe(3);
expect(result.features.apiAccess).toBe(true);
expect(result.features.prioritySupport).toBe(true);
});

test("getFeaturesForPlan throws on unknown plan key", () => {
expect(() => getFeaturesForPlan("nonexistent_plan")).toThrow(
/Unknown planKey "nonexistent_plan"/,
);
});
});
Loading
Loading