From 90041ce2f4515183dd255a31b9078cd5a50d6865 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:16:47 +0000 Subject: [PATCH 01/10] Fix KiloClaw early bird checkout to use server-side Stripe Checkout Session Replace the static Stripe Payment Link with a server-side Stripe Checkout Session that passes the user's existing stripe_customer_id. This prevents duplicate/guest Stripe customer accounts from being created during early bird purchases. --- .env.test | 3 ++ src/app/(app)/claw/earlybird/page.tsx | 41 +++++++++++---------- src/routers/kiloclaw-router.ts | 53 +++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 19 deletions(-) diff --git a/.env.test b/.env.test index 5d6f1470b..29d3e048f 100644 --- a/.env.test +++ b/.env.test @@ -73,6 +73,9 @@ STRIPE_KILO_PASS_TIER_49_YEARLY_PRICE_ID=price_test_tier_49_yearly STRIPE_KILO_PASS_TIER_199_MONTHLY_PRICE_ID=price_test_tier_199_monthly STRIPE_KILO_PASS_TIER_199_YEARLY_PRICE_ID=price_test_tier_199_yearly +# Stripe - KiloClaw Early Bird +STRIPE_KILOCLAW_EARLYBIRD_PRICE_ID=price_test_kiloclaw_earlybird + # Stripe publishable key for client-side 3DS authentication NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_invalid_mock_key diff --git a/src/app/(app)/claw/earlybird/page.tsx b/src/app/(app)/claw/earlybird/page.tsx index 5f17f7cbc..480308d40 100644 --- a/src/app/(app)/claw/earlybird/page.tsx +++ b/src/app/(app)/claw/earlybird/page.tsx @@ -1,25 +1,29 @@ 'use client'; -import { useUser } from '@/hooks/useUser'; +import { useTRPC } from '@/lib/trpc/utils'; +import { useMutation } from '@tanstack/react-query'; import { PageLayout } from '@/components/PageLayout'; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; - -const STRIPE_PAYMENT_LINK = 'https://buy.stripe.com/00wcN64ot27OaIK0K4dAk00'; -const PROMO_CODE = 'KILOCLAWEARLYBIRD'; - -function buildStripeUrl(email: string | undefined) { - const url = new URL(STRIPE_PAYMENT_LINK); - if (email) { - url.searchParams.set('prefilled_email', email); - } - url.searchParams.set('prefilled_promo_code', PROMO_CODE); - return url.toString(); -} +import { toast } from 'sonner'; export default function EarlybirdPage() { - const { data: user } = useUser(); - const stripeUrl = buildStripeUrl(user?.google_user_email); + const trpc = useTRPC(); + + const checkoutMutation = useMutation( + trpc.kiloclaw.createEarlybirdCheckoutSession.mutationOptions({ + onSuccess: result => { + if (!result.url) { + toast.error('Failed to create checkout session'); + return; + } + window.location.href = result.url; + }, + onError: error => { + toast.error(error.message || 'Failed to start checkout'); + }, + }) + ); return ( @@ -60,11 +64,10 @@ export default function EarlybirdPage() { diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index 2f8fc6343..6497739cb 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -18,6 +18,9 @@ import { markActiveInstanceDestroyed, restoreDestroyedInstance, } from '@/lib/kiloclaw/instance-registry'; +import { client as stripe } from '@/lib/stripe-client'; +import { APP_URL } from '@/lib/constants'; +import { getEnvVariable } from '@/lib/dotenvx'; const kilocodeDefaultModelSchema = z .string() @@ -370,4 +373,54 @@ export const kiloclawRouter = createTRPCRouter({ const client = new KiloClawInternalClient(); return client.restoreConfig(ctx.user.id); }), + + createEarlybirdCheckoutSession: baseProcedure + .output(z.object({ url: z.url().nullable() })) + .mutation(async ({ ctx }) => { + const stripeCustomerId = ctx.user.stripe_customer_id; + if (!stripeCustomerId) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Missing Stripe customer for user.', + }); + } + + const priceId = getEnvVariable('STRIPE_KILOCLAW_EARLYBIRD_PRICE_ID'); + if (!priceId) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Early bird pricing is not configured.', + }); + } + + const session = await stripe.checkout.sessions.create({ + mode: 'subscription', + customer: stripeCustomerId, + allow_promotion_codes: true, + billing_address_collection: 'required', + line_items: [{ price: priceId, quantity: 1 }], + customer_update: { + name: 'auto', + address: 'auto', + }, + tax_id_collection: { + enabled: true, + required: 'never', + }, + success_url: `${APP_URL}/claw?earlybird_checkout=success`, + cancel_url: `${APP_URL}/claw/earlybird?checkout=cancelled`, + subscription_data: { + metadata: { + type: 'kiloclaw-earlybird', + kiloUserId: ctx.user.id, + }, + }, + metadata: { + type: 'kiloclaw-earlybird', + kiloUserId: ctx.user.id, + }, + }); + + return { url: typeof session.url === 'string' ? session.url : null }; + }), }); From 26327fffda4968787e78380db90a3ee395df362f Mon Sep 17 00:00:00 2001 From: Brendan O'Leary Date: Tue, 3 Mar 2026 10:39:38 -0500 Subject: [PATCH 02/10] Add coupon function --- src/routers/kiloclaw-router.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index 6497739cb..93a6241bb 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -393,12 +393,14 @@ export const kiloclawRouter = createTRPCRouter({ }); } + const couponId = getEnvVariable('STRIPE_KILOCLAW_EARLYBIRD_COUPON_ID'); + const session = await stripe.checkout.sessions.create({ - mode: 'subscription', + mode: 'payment', customer: stripeCustomerId, - allow_promotion_codes: true, billing_address_collection: 'required', line_items: [{ price: priceId, quantity: 1 }], + ...(couponId ? { discounts: [{ coupon: couponId }] } : { allow_promotion_codes: true }), customer_update: { name: 'auto', address: 'auto', @@ -409,12 +411,6 @@ export const kiloclawRouter = createTRPCRouter({ }, success_url: `${APP_URL}/claw?earlybird_checkout=success`, cancel_url: `${APP_URL}/claw/earlybird?checkout=cancelled`, - subscription_data: { - metadata: { - type: 'kiloclaw-earlybird', - kiloUserId: ctx.user.id, - }, - }, metadata: { type: 'kiloclaw-earlybird', kiloUserId: ctx.user.id, From 50f662058c46f21076bb03e4b45d2850f5fe4fb6 Mon Sep 17 00:00:00 2001 From: Brendan O'Leary Date: Tue, 3 Mar 2026 10:50:33 -0500 Subject: [PATCH 03/10] Log not waring --- src/lib/stripe.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index 4fa574ee4..f2bc60e97 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -428,6 +428,9 @@ export async function handleSuccessfulChargeWithPayment( // Auto-topup-setup is the initial $15 charge when user enables auto-top-up const isAutoTopUpSetup = paymentIntent?.metadata?.type === 'auto-topup-setup'; + // KiloClaw earlybird purchases are handled separately - not a credit top-up + const isKiloclawEarlybird = paymentIntent?.metadata?.type === 'kiloclaw-earlybird'; + // Invoice-based auto-top-ups are handled by `invoice.payment_succeeded` webhook, // which has direct access to invoice metadata. Skip them here to avoid duplicate processing. const invoiceId = @@ -442,6 +445,9 @@ export async function handleSuccessfulChargeWithPayment( ); } else if (isAutoTopUpSetup) { await handleAutoTopUpSetup(user, paymentIntent, creditAmountInCents, config); + } else if (isKiloclawEarlybird) { + // KiloClaw earlybird purchase - handled by separate flow, not a credit top-up + logExceptInTest(`Skipping kiloclaw-earlybird charge ${charge.id} - handled separately`); } else { // Unknown charge type - log warning but don't process warnExceptInTest( From d5cf94650d567befd95e38eec0c81542f5f017ca Mon Sep 17 00:00:00 2001 From: Brendan O'Leary Date: Tue, 3 Mar 2026 11:29:44 -0500 Subject: [PATCH 04/10] pnpm format --- src/app/(app)/claw/earlybird/page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/(app)/claw/earlybird/page.tsx b/src/app/(app)/claw/earlybird/page.tsx index 480308d40..a798377ea 100644 --- a/src/app/(app)/claw/earlybird/page.tsx +++ b/src/app/(app)/claw/earlybird/page.tsx @@ -67,7 +67,9 @@ export default function EarlybirdPage() { disabled={checkoutMutation.isPending} onClick={() => checkoutMutation.mutate()} > - {checkoutMutation.isPending ? 'Redirecting to checkout...' : '🦞 Get the Early Bird Offer'} + {checkoutMutation.isPending + ? 'Redirecting to checkout...' + : '🦞 Get the Early Bird Offer'} From 45013233b15c31bad2604fea806a85f42b0e048c Mon Sep 17 00:00:00 2001 From: syn Date: Tue, 3 Mar 2026 12:24:25 -0600 Subject: [PATCH 05/10] Add earlybird purchase tracking, duplicate prevention, and webhook fulfillment - Add kiloclaw_earlybird_purchases table to record earlybird payments - Record purchases in charge.succeeded webhook (replaces no-op skip) - Add duplicate purchase check and Stripe idempotency key to mutation - Add payment_intent_data.metadata for reliable webhook traceability - Add getEarlybirdStatus query; hide banner/block checkout after purchase - Update GDPR soft-delete to clean up earlybird purchases - Let webhook DB errors propagate so Stripe retries on failure --- .env.test | 1 + .../db/src/migrations/meta/0039_snapshot.json | 1031 ++++------------- packages/db/src/migrations/meta/_journal.json | 4 +- packages/db/src/schema.ts | 19 + .../(app)/claw/components/ClawDashboard.tsx | 6 +- src/app/(app)/claw/earlybird/page.tsx | 36 +- src/lib/stripe.ts | 17 +- src/lib/user.test.ts | 21 + src/lib/user.ts | 4 + src/routers/kiloclaw-router.ts | 76 +- 10 files changed, 388 insertions(+), 827 deletions(-) diff --git a/.env.test b/.env.test index 29d3e048f..7d0f80561 100644 --- a/.env.test +++ b/.env.test @@ -75,6 +75,7 @@ STRIPE_KILO_PASS_TIER_199_YEARLY_PRICE_ID=price_test_tier_199_yearly # Stripe - KiloClaw Early Bird STRIPE_KILOCLAW_EARLYBIRD_PRICE_ID=price_test_kiloclaw_earlybird +STRIPE_KILOCLAW_EARLYBIRD_COUPON_ID=coupon_test_kiloclaw_earlybird # Stripe publishable key for client-side 3DS authentication NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_invalid_mock_key diff --git a/packages/db/src/migrations/meta/0039_snapshot.json b/packages/db/src/migrations/meta/0039_snapshot.json index 72505e4fc..24bd70387 100644 --- a/packages/db/src/migrations/meta/0039_snapshot.json +++ b/packages/db/src/migrations/meta/0039_snapshot.json @@ -1,5 +1,5 @@ { - "id": "ece4ecc6-d4d7-4519-afbe-b2cb136489dc", + "id": "71b0ed3d-6611-43d0-97bf-f25753e8cc17", "prevId": "f223a1da-6fdb-4664-be8f-861924909fa5", "version": "7", "dialect": "postgresql", @@ -6871,6 +6871,80 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.kiloclaw_earlybird_purchases": { + "name": "kiloclaw_earlybird_purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kiloclaw_earlybird_purchases_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_earlybird_purchases_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_earlybird_purchases", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_earlybird_purchases_user_id_unique": { + "name": "kiloclaw_earlybird_purchases_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "kiloclaw_earlybird_purchases_stripe_charge_id_unique": { + "name": "kiloclaw_earlybird_purchases_stripe_charge_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_charge_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.kiloclaw_image_catalog": { "name": "kiloclaw_image_catalog", "schema": "", @@ -10182,8 +10256,8 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.security_analysis_owner_state": { - "name": "security_analysis_owner_state", + "public.security_audit_log": { + "name": "security_audit_log", "schema": "", "columns": { "id": { @@ -10205,46 +10279,62 @@ "primaryKey": false, "notNull": false }, - "auto_analysis_enabled_at": { - "name": "auto_analysis_enabled_at", - "type": "timestamp with time zone", + "actor_id": { + "name": "actor_id", + "type": "text", "primaryKey": false, "notNull": false }, - "blocked_until": { - "name": "blocked_until", - "type": "timestamp with time zone", + "actor_email": { + "name": "actor_email", + "type": "text", "primaryKey": false, "notNull": false }, - "block_reason": { - "name": "block_reason", + "actor_name": { + "name": "actor_name", "type": "text", "primaryKey": false, "notNull": false }, - "consecutive_actor_resolution_failures": { - "name": "consecutive_actor_resolution_failures", - "type": "integer", + "action": { + "name": "action", + "type": "text", "primaryKey": false, - "notNull": true, - "default": 0 + "notNull": true }, - "last_actor_resolution_failure_at": { - "name": "last_actor_resolution_failure_at", - "type": "timestamp with time zone", + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_state": { + "name": "before_state", + "type": "jsonb", "primaryKey": false, "notNull": false }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", + "after_state": { + "name": "after_state", + "type": "jsonb", "primaryKey": false, - "notNull": true, - "default": "now()" + "notNull": false }, - "updated_at": { - "name": "updated_at", + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, @@ -10252,43 +10342,116 @@ } }, "indexes": { - "UQ_security_analysis_owner_state_org_owner": { - "name": "UQ_security_analysis_owner_state_org_owner", + "IDX_security_audit_log_org_created": { + "name": "IDX_security_audit_log_org_created", "columns": [ { "expression": "owned_by_organization_id", "isExpression": false, "asc": true, "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" } ], - "isUnique": true, - "where": "\"security_analysis_owner_state\".\"owned_by_organization_id\" is not null", + "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, - "UQ_security_analysis_owner_state_user_owner": { - "name": "UQ_security_analysis_owner_state_user_owner", + "IDX_security_audit_log_user_created": { + "name": "IDX_security_audit_log_user_created", "columns": [ { "expression": "owned_by_user_id", "isExpression": false, "asc": true, "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" } ], - "isUnique": true, - "where": "\"security_analysis_owner_state\".\"owned_by_user_id\" is not null", + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_resource": { + "name": "IDX_security_audit_log_resource", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_actor": { + "name": "IDX_security_audit_log_actor", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_action": { + "name": "IDX_security_audit_log_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { - "security_analysis_owner_state_owned_by_organization_id_organizations_id_fk": { - "name": "security_analysis_owner_state_owned_by_organization_id_organizations_id_fk", - "tableFrom": "security_analysis_owner_state", + "security_audit_log_owned_by_organization_id_organizations_id_fk": { + "name": "security_audit_log_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_audit_log", "tableTo": "organizations", "columnsFrom": [ "owned_by_organization_id" @@ -10299,9 +10462,9 @@ "onDelete": "cascade", "onUpdate": "no action" }, - "security_analysis_owner_state_owned_by_user_id_kilocode_users_id_fk": { - "name": "security_analysis_owner_state_owned_by_user_id_kilocode_users_id_fk", - "tableFrom": "security_analysis_owner_state", + "security_audit_log_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_audit_log_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_audit_log", "tableTo": "kilocode_users", "columnsFrom": [ "owned_by_user_id" @@ -10317,19 +10480,19 @@ "uniqueConstraints": {}, "policies": {}, "checkConstraints": { - "security_analysis_owner_state_owner_check": { - "name": "security_analysis_owner_state_owner_check", - "value": "(\n (\"security_analysis_owner_state\".\"owned_by_user_id\" IS NOT NULL AND \"security_analysis_owner_state\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_analysis_owner_state\".\"owned_by_user_id\" IS NULL AND \"security_analysis_owner_state\".\"owned_by_organization_id\" IS NOT NULL)\n )" + "security_audit_log_owner_check": { + "name": "security_audit_log_owner_check", + "value": "(\"security_audit_log\".\"owned_by_user_id\" IS NOT NULL AND \"security_audit_log\".\"owned_by_organization_id\" IS NULL) OR (\"security_audit_log\".\"owned_by_user_id\" IS NULL AND \"security_audit_log\".\"owned_by_organization_id\" IS NOT NULL)" }, - "security_analysis_owner_state_block_reason_check": { - "name": "security_analysis_owner_state_block_reason_check", - "value": "\"security_analysis_owner_state\".\"block_reason\" IS NULL OR \"security_analysis_owner_state\".\"block_reason\" IN ('INSUFFICIENT_CREDITS', 'ACTOR_RESOLUTION_FAILED', 'OPERATOR_PAUSE')" + "security_audit_log_action_check": { + "name": "security_audit_log_action_check", + "value": "\"security_audit_log\".\"action\" IN ('security.finding.created', 'security.finding.status_change', 'security.finding.dismissed', 'security.finding.auto_dismissed', 'security.finding.analysis_started', 'security.finding.analysis_completed', 'security.finding.deleted', 'security.config.enabled', 'security.config.disabled', 'security.config.updated', 'security.sync.triggered', 'security.sync.completed', 'security.audit_log.exported')" } }, "isRLSEnabled": false }, - "public.security_analysis_queue": { - "name": "security_analysis_queue", + "public.security_findings": { + "name": "security_findings", "schema": "", "columns": { "id": { @@ -10339,12 +10502,6 @@ "notNull": true, "default": "pg_catalog.gen_random_uuid()" }, - "finding_id": { - "name": "finding_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, "owned_by_organization_id": { "name": "owned_by_organization_id", "type": "uuid", @@ -10357,721 +10514,57 @@ "primaryKey": false, "notNull": false }, - "queue_status": { - "name": "queue_status", + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", "type": "text", "primaryKey": false, "notNull": true }, - "severity_rank": { - "name": "severity_rank", - "type": "smallint", + "source": { + "name": "source", + "type": "text", "primaryKey": false, "notNull": true }, - "queued_at": { - "name": "queued_at", - "type": "timestamp with time zone", + "source_id": { + "name": "source_id", + "type": "text", "primaryKey": false, "notNull": true }, - "claimed_at": { - "name": "claimed_at", - "type": "timestamp with time zone", + "severity": { + "name": "severity", + "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, - "claimed_by_job_id": { - "name": "claimed_by_job_id", + "ghsa_id": { + "name": "ghsa_id", "type": "text", "primaryKey": false, "notNull": false }, - "claim_token": { - "name": "claim_token", + "cve_id": { + "name": "cve_id", "type": "text", "primaryKey": false, "notNull": false }, - "attempt_count": { - "name": "attempt_count", - "type": "integer", + "package_name": { + "name": "package_name", + "type": "text", "primaryKey": false, - "notNull": true, - "default": 0 + "notNull": true }, - "reopen_requeue_count": { - "name": "reopen_requeue_count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "next_retry_at": { - "name": "next_retry_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "failure_code": { - "name": "failure_code", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "last_error_redacted": { - "name": "last_error_redacted", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "UQ_security_analysis_queue_finding_id": { - "name": "UQ_security_analysis_queue_finding_id", - "columns": [ - { - "expression": "finding_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_security_analysis_queue_claim_path_org": { - "name": "idx_security_analysis_queue_claim_path_org", - "columns": [ - { - "expression": "owned_by_organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", - "asc": true, - "isExpression": true, - "nulls": "last" - }, - { - "expression": "severity_rank", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "queued_at", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"security_analysis_queue\".\"queue_status\" = 'queued'", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_security_analysis_queue_claim_path_user": { - "name": "idx_security_analysis_queue_claim_path_user", - "columns": [ - { - "expression": "owned_by_user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", - "asc": true, - "isExpression": true, - "nulls": "last" - }, - { - "expression": "severity_rank", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "queued_at", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"security_analysis_queue\".\"queue_status\" = 'queued'", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_security_analysis_queue_in_flight_org": { - "name": "idx_security_analysis_queue_in_flight_org", - "columns": [ - { - "expression": "owned_by_organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "queue_status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "claimed_at", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"security_analysis_queue\".\"queue_status\" IN ('pending', 'running')", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_security_analysis_queue_in_flight_user": { - "name": "idx_security_analysis_queue_in_flight_user", - "columns": [ - { - "expression": "owned_by_user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "queue_status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "claimed_at", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"security_analysis_queue\".\"queue_status\" IN ('pending', 'running')", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_security_analysis_queue_lag_dashboards": { - "name": "idx_security_analysis_queue_lag_dashboards", - "columns": [ - { - "expression": "queued_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"security_analysis_queue\".\"queue_status\" = 'queued'", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_security_analysis_queue_pending_reconciliation": { - "name": "idx_security_analysis_queue_pending_reconciliation", - "columns": [ - { - "expression": "claimed_at", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"security_analysis_queue\".\"queue_status\" = 'pending'", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_security_analysis_queue_running_reconciliation": { - "name": "idx_security_analysis_queue_running_reconciliation", - "columns": [ - { - "expression": "updated_at", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"security_analysis_queue\".\"queue_status\" = 'running'", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_security_analysis_queue_failure_trend": { - "name": "idx_security_analysis_queue_failure_trend", - "columns": [ - { - "expression": "failure_code", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "updated_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"security_analysis_queue\".\"failure_code\" IS NOT NULL", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "security_analysis_queue_finding_id_security_findings_id_fk": { - "name": "security_analysis_queue_finding_id_security_findings_id_fk", - "tableFrom": "security_analysis_queue", - "tableTo": "security_findings", - "columnsFrom": [ - "finding_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "security_analysis_queue_owned_by_organization_id_organizations_id_fk": { - "name": "security_analysis_queue_owned_by_organization_id_organizations_id_fk", - "tableFrom": "security_analysis_queue", - "tableTo": "organizations", - "columnsFrom": [ - "owned_by_organization_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "security_analysis_queue_owned_by_user_id_kilocode_users_id_fk": { - "name": "security_analysis_queue_owned_by_user_id_kilocode_users_id_fk", - "tableFrom": "security_analysis_queue", - "tableTo": "kilocode_users", - "columnsFrom": [ - "owned_by_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "security_analysis_queue_owner_check": { - "name": "security_analysis_queue_owner_check", - "value": "(\n (\"security_analysis_queue\".\"owned_by_user_id\" IS NOT NULL AND \"security_analysis_queue\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_analysis_queue\".\"owned_by_user_id\" IS NULL AND \"security_analysis_queue\".\"owned_by_organization_id\" IS NOT NULL)\n )" - }, - "security_analysis_queue_status_check": { - "name": "security_analysis_queue_status_check", - "value": "\"security_analysis_queue\".\"queue_status\" IN ('queued', 'pending', 'running', 'failed', 'completed')" - }, - "security_analysis_queue_claim_token_required_check": { - "name": "security_analysis_queue_claim_token_required_check", - "value": "\"security_analysis_queue\".\"queue_status\" NOT IN ('pending', 'running') OR \"security_analysis_queue\".\"claim_token\" IS NOT NULL" - }, - "security_analysis_queue_attempt_count_non_negative_check": { - "name": "security_analysis_queue_attempt_count_non_negative_check", - "value": "\"security_analysis_queue\".\"attempt_count\" >= 0" - }, - "security_analysis_queue_reopen_requeue_count_non_negative_check": { - "name": "security_analysis_queue_reopen_requeue_count_non_negative_check", - "value": "\"security_analysis_queue\".\"reopen_requeue_count\" >= 0" - }, - "security_analysis_queue_severity_rank_check": { - "name": "security_analysis_queue_severity_rank_check", - "value": "\"security_analysis_queue\".\"severity_rank\" IN (0, 1, 2, 3)" - }, - "security_analysis_queue_failure_code_check": { - "name": "security_analysis_queue_failure_code_check", - "value": "\"security_analysis_queue\".\"failure_code\" IS NULL OR \"security_analysis_queue\".\"failure_code\" IN (\n 'NETWORK_TIMEOUT',\n 'UPSTREAM_5XX',\n 'TEMP_TOKEN_FAILURE',\n 'START_CALL_AMBIGUOUS',\n 'REQUEUE_TEMPORARY_PRECONDITION',\n 'ACTOR_RESOLUTION_FAILED',\n 'GITHUB_TOKEN_UNAVAILABLE',\n 'INVALID_CONFIG',\n 'MISSING_OWNERSHIP',\n 'PERMISSION_DENIED_PERMANENT',\n 'UNSUPPORTED_SEVERITY',\n 'INSUFFICIENT_CREDITS',\n 'STATE_GUARD_REJECTED',\n 'SKIPPED_ALREADY_IN_PROGRESS',\n 'SKIPPED_NO_LONGER_ELIGIBLE',\n 'REOPEN_LOOP_GUARD',\n 'RUN_LOST'\n )" - } - }, - "isRLSEnabled": false - }, - "public.security_audit_log": { - "name": "security_audit_log", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "pg_catalog.gen_random_uuid()" - }, - "owned_by_organization_id": { - "name": "owned_by_organization_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "owned_by_user_id": { - "name": "owned_by_user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "actor_id": { - "name": "actor_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "actor_email": { - "name": "actor_email", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "actor_name": { - "name": "actor_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "action": { - "name": "action", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "resource_type": { - "name": "resource_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "resource_id": { - "name": "resource_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "before_state": { - "name": "before_state", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "after_state": { - "name": "after_state", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "metadata": { - "name": "metadata", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "IDX_security_audit_log_org_created": { - "name": "IDX_security_audit_log_org_created", - "columns": [ - { - "expression": "owned_by_organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "IDX_security_audit_log_user_created": { - "name": "IDX_security_audit_log_user_created", - "columns": [ - { - "expression": "owned_by_user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "IDX_security_audit_log_resource": { - "name": "IDX_security_audit_log_resource", - "columns": [ - { - "expression": "resource_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "resource_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "IDX_security_audit_log_actor": { - "name": "IDX_security_audit_log_actor", - "columns": [ - { - "expression": "actor_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "IDX_security_audit_log_action": { - "name": "IDX_security_audit_log_action", - "columns": [ - { - "expression": "action", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "security_audit_log_owned_by_organization_id_organizations_id_fk": { - "name": "security_audit_log_owned_by_organization_id_organizations_id_fk", - "tableFrom": "security_audit_log", - "tableTo": "organizations", - "columnsFrom": [ - "owned_by_organization_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "security_audit_log_owned_by_user_id_kilocode_users_id_fk": { - "name": "security_audit_log_owned_by_user_id_kilocode_users_id_fk", - "tableFrom": "security_audit_log", - "tableTo": "kilocode_users", - "columnsFrom": [ - "owned_by_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "security_audit_log_owner_check": { - "name": "security_audit_log_owner_check", - "value": "(\"security_audit_log\".\"owned_by_user_id\" IS NOT NULL AND \"security_audit_log\".\"owned_by_organization_id\" IS NULL) OR (\"security_audit_log\".\"owned_by_user_id\" IS NULL AND \"security_audit_log\".\"owned_by_organization_id\" IS NOT NULL)" - }, - "security_audit_log_action_check": { - "name": "security_audit_log_action_check", - "value": "\"security_audit_log\".\"action\" IN ('security.finding.created', 'security.finding.status_change', 'security.finding.dismissed', 'security.finding.auto_dismissed', 'security.finding.analysis_started', 'security.finding.analysis_completed', 'security.finding.deleted', 'security.config.enabled', 'security.config.disabled', 'security.config.updated', 'security.sync.triggered', 'security.sync.completed', 'security.audit_log.exported')" - } - }, - "isRLSEnabled": false - }, - "public.security_findings": { - "name": "security_findings", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "pg_catalog.gen_random_uuid()" - }, - "owned_by_organization_id": { - "name": "owned_by_organization_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "owned_by_user_id": { - "name": "owned_by_user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "platform_integration_id": { - "name": "platform_integration_id", - "type": "uuid", - "primaryKey": false, - "notNull": false - }, - "repo_full_name": { - "name": "repo_full_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "source_id": { - "name": "source_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "severity": { - "name": "severity", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "ghsa_id": { - "name": "ghsa_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cve_id": { - "name": "cve_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "package_name": { - "name": "package_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "package_ecosystem": { - "name": "package_ecosystem", - "type": "text", + "package_ecosystem": { + "name": "package_ecosystem", + "type": "text", "primaryKey": false, "notNull": true }, @@ -11387,50 +10880,6 @@ "concurrently": false, "method": "btree", "with": {} - }, - "idx_security_findings_org_analysis_in_flight": { - "name": "idx_security_findings_org_analysis_in_flight", - "columns": [ - { - "expression": "owned_by_organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "analysis_status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"security_findings\".\"analysis_status\" IN ('pending', 'running')", - "concurrently": false, - "method": "btree", - "with": {} - }, - "idx_security_findings_user_analysis_in_flight": { - "name": "idx_security_findings_user_analysis_in_flight", - "columns": [ - { - "expression": "owned_by_user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "analysis_status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"security_findings\".\"analysis_status\" IN ('pending', 'running')", - "concurrently": false, - "method": "btree", - "with": {} } }, "foreignKeys": { diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 31fe35fe6..5b2fedc28 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -278,8 +278,8 @@ { "idx": 39, "version": "7", - "when": 1772547493233, - "tag": "0039_naive_yellow_claw", + "when": 1772560366928, + "tag": "0039_mushy_darkhawk", "breakpoints": true } ] diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 72b2bca59..f554a3a3a 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -3367,3 +3367,22 @@ export const kiloclaw_version_pins = pgTable('kiloclaw_version_pins', { }); export type KiloClawVersionPin = typeof kiloclaw_version_pins.$inferSelect; + +// KiloClaw Early Bird Purchases — records one-time earlybird payments. +// Unique on user_id enforces at most one purchase per user. +// Unique on stripe_charge_id provides webhook idempotency. +export const kiloclaw_earlybird_purchases = pgTable('kiloclaw_earlybird_purchases', { + id: uuid() + .default(sql`gen_random_uuid()`) + .primaryKey() + .notNull(), + user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade' }) + .unique(), + stripe_charge_id: text().notNull().unique(), + amount_cents: integer().notNull(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}); + +export type KiloClawEarlybirdPurchase = typeof kiloclaw_earlybird_purchases.$inferSelect; diff --git a/src/app/(app)/claw/components/ClawDashboard.tsx b/src/app/(app)/claw/components/ClawDashboard.tsx index d5f34c14b..149453fe5 100644 --- a/src/app/(app)/claw/components/ClawDashboard.tsx +++ b/src/app/(app)/claw/components/ClawDashboard.tsx @@ -17,6 +17,8 @@ import { CreateInstanceCard } from './CreateInstanceCard'; import { InstanceControls } from './InstanceControls'; import { InstanceTab } from './InstanceTab'; import { SettingsTab } from './SettingsTab'; +import { useTRPC } from '@/lib/trpc/utils'; +import { useQuery } from '@tanstack/react-query'; import { ChangelogCard } from './ChangelogCard'; import { EarlybirdBanner } from './EarlybirdBanner'; import { PairingCard } from './PairingCard'; @@ -32,6 +34,7 @@ function hasPopulatedStatus( } export function ClawDashboard({ status }: { status: KiloClawDashboardStatus | undefined }) { + const trpc = useTRPC(); const mutations = useKiloClawMutations(); const gatewayUrl = useGatewayUrl(status); const instanceStatus = hasPopulatedStatus(status) ? status : null; @@ -43,6 +46,7 @@ export function ClawDashboard({ status }: { status: KiloClawDashboardStatus | un } = useKiloClawGatewayStatus(isRunning); const { data: isServiceDegraded } = useKiloClawServiceDegraded(); + const { data: earlybirdStatus } = useQuery(trpc.kiloclaw.getEarlybirdStatus.queryOptions()); const [dirtyChannels, setDirtyChannels] = useState>(new Set()); @@ -142,7 +146,7 @@ export function ClawDashboard({ status }: { status: KiloClawDashboardStatus | un {instanceStatus?.status === 'running' && } - + {!earlybirdStatus?.purchased && } ); diff --git a/src/app/(app)/claw/earlybird/page.tsx b/src/app/(app)/claw/earlybird/page.tsx index a798377ea..327cf6480 100644 --- a/src/app/(app)/claw/earlybird/page.tsx +++ b/src/app/(app)/claw/earlybird/page.tsx @@ -1,14 +1,17 @@ 'use client'; import { useTRPC } from '@/lib/trpc/utils'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { PageLayout } from '@/components/PageLayout'; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; +import Link from 'next/link'; export default function EarlybirdPage() { const trpc = useTRPC(); + const { data: earlybirdStatus } = useQuery(trpc.kiloclaw.getEarlybirdStatus.queryOptions()); + const alreadyPurchased = earlybirdStatus?.purchased === true; const checkoutMutation = useMutation( trpc.kiloclaw.createEarlybirdCheckoutSession.mutationOptions({ @@ -61,16 +64,27 @@ export default function EarlybirdPage() { - + {alreadyPurchased ? ( +
+

+ You've already purchased the early bird offer. +

+ +
+ ) : ( + + )}
diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index f2bc60e97..da2b2698b 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -11,6 +11,7 @@ import { kilocode_users, auto_top_up_configs, organizations, + kiloclaw_earlybird_purchases, } from '@kilocode/db/schema'; import { and, eq, inArray, isNull, ne, not, or, sql } from 'drizzle-orm'; import { randomUUID } from 'crypto'; @@ -446,8 +447,7 @@ export async function handleSuccessfulChargeWithPayment( } else if (isAutoTopUpSetup) { await handleAutoTopUpSetup(user, paymentIntent, creditAmountInCents, config); } else if (isKiloclawEarlybird) { - // KiloClaw earlybird purchase - handled by separate flow, not a credit top-up - logExceptInTest(`Skipping kiloclaw-earlybird charge ${charge.id} - handled separately`); + await recordKiloclawEarlybirdPurchase(user, charge); } else { // Unknown charge type - log warning but don't process warnExceptInTest( @@ -469,6 +469,19 @@ export async function handleSuccessfulChargeWithPayment( } } +async function recordKiloclawEarlybirdPurchase(user: User, charge: Stripe.Charge) { + await db + .insert(kiloclaw_earlybird_purchases) + .values({ + user_id: user.id, + stripe_charge_id: charge.id, + amount_cents: charge.amount, + }) + .onConflictDoNothing(); + + logExceptInTest(`Recorded kiloclaw-earlybird purchase for user ${user.id}, charge ${charge.id}`); +} + export async function getStripeInvoices( stripeCustomerId: string, dateThreshold?: Date | null diff --git a/src/lib/user.test.ts b/src/lib/user.test.ts index b7905c352..64467fb40 100644 --- a/src/lib/user.test.ts +++ b/src/lib/user.test.ts @@ -29,6 +29,7 @@ import { security_findings, security_analysis_queue, security_analysis_owner_state, + kiloclaw_earlybird_purchases, } from '@kilocode/db/schema'; import { eq, count } from 'drizzle-orm'; import { softDeleteUser, SoftDeletePreconditionError, findUserById, findUsersByIds } from './user'; @@ -787,6 +788,26 @@ describe('User', () => { // Cleanup catalog entry await db.delete(kiloclaw_image_catalog).where(eq(kiloclaw_image_catalog.image_tag, testTag)); }); + + it('should delete kiloclaw_earlybird_purchases for the user', async () => { + const user = await insertTestUser(); + + await db.insert(kiloclaw_earlybird_purchases).values({ + user_id: user.id, + stripe_charge_id: `ch_test_gdpr_${Date.now()}`, + amount_cents: 2500, + }); + + await softDeleteUser(user.id); + + expect( + await db + .select({ count: count() }) + .from(kiloclaw_earlybird_purchases) + .where(eq(kiloclaw_earlybird_purchases.user_id, user.id)) + .then(r => r[0].count) + ).toBe(0); + }); }); describe('forceImmediateExpirationRecomputation', () => { diff --git a/src/lib/user.ts b/src/lib/user.ts index f4598bff1..ad87e2d53 100644 --- a/src/lib/user.ts +++ b/src/lib/user.ts @@ -42,6 +42,7 @@ import { kiloclaw_instances, kiloclaw_access_codes, kiloclaw_version_pins, + kiloclaw_earlybird_purchases, user_period_cache, user_feedback, app_builder_feedback, @@ -548,6 +549,9 @@ export async function softDeleteUser(userId: string) { await tx.delete(kiloclaw_access_codes).where(eq(kiloclaw_access_codes.kilo_user_id, userId)); await tx.delete(kiloclaw_instances).where(eq(kiloclaw_instances.user_id, userId)); await tx.delete(kiloclaw_version_pins).where(eq(kiloclaw_version_pins.user_id, userId)); + await tx + .delete(kiloclaw_earlybird_purchases) + .where(eq(kiloclaw_earlybird_purchases.user_id, userId)); await tx.delete(user_period_cache).where(eq(user_period_cache.kilo_user_id, userId)); await tx .delete(kilo_pass_scheduled_changes) diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index 93a6241bb..3a3930d0e 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -21,6 +21,9 @@ import { import { client as stripe } from '@/lib/stripe-client'; import { APP_URL } from '@/lib/constants'; import { getEnvVariable } from '@/lib/dotenvx'; +import { db } from '@/lib/drizzle'; +import { kiloclaw_earlybird_purchases } from '@kilocode/db/schema'; +import { eq } from 'drizzle-orm'; const kilocodeDefaultModelSchema = z .string() @@ -374,9 +377,33 @@ export const kiloclawRouter = createTRPCRouter({ return client.restoreConfig(ctx.user.id); }), + getEarlybirdStatus: baseProcedure + .output(z.object({ purchased: z.boolean() })) + .query(async ({ ctx }) => { + const rows = await db + .select({ id: kiloclaw_earlybird_purchases.id }) + .from(kiloclaw_earlybird_purchases) + .where(eq(kiloclaw_earlybird_purchases.user_id, ctx.user.id)) + .limit(1); + return { purchased: rows.length > 0 }; + }), + createEarlybirdCheckoutSession: baseProcedure .output(z.object({ url: z.url().nullable() })) .mutation(async ({ ctx }) => { + const existing = await db + .select({ id: kiloclaw_earlybird_purchases.id }) + .from(kiloclaw_earlybird_purchases) + .where(eq(kiloclaw_earlybird_purchases.user_id, ctx.user.id)) + .limit(1); + + if (existing.length > 0) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'You have already purchased the early bird offer.', + }); + } + const stripeCustomerId = ctx.user.stripe_customer_id; if (!stripeCustomerId) { throw new TRPCError({ @@ -395,27 +422,36 @@ export const kiloclawRouter = createTRPCRouter({ const couponId = getEnvVariable('STRIPE_KILOCLAW_EARLYBIRD_COUPON_ID'); - const session = await stripe.checkout.sessions.create({ - mode: 'payment', - customer: stripeCustomerId, - billing_address_collection: 'required', - line_items: [{ price: priceId, quantity: 1 }], - ...(couponId ? { discounts: [{ coupon: couponId }] } : { allow_promotion_codes: true }), - customer_update: { - name: 'auto', - address: 'auto', - }, - tax_id_collection: { - enabled: true, - required: 'never', + const session = await stripe.checkout.sessions.create( + { + mode: 'payment', + customer: stripeCustomerId, + billing_address_collection: 'required', + line_items: [{ price: priceId, quantity: 1 }], + ...(couponId ? { discounts: [{ coupon: couponId }] } : { allow_promotion_codes: true }), + customer_update: { + name: 'auto', + address: 'auto', + }, + tax_id_collection: { + enabled: true, + required: 'never', + }, + payment_intent_data: { + metadata: { + type: 'kiloclaw-earlybird', + kiloUserId: ctx.user.id, + }, + }, + success_url: `${APP_URL}/claw?earlybird_checkout=success`, + cancel_url: `${APP_URL}/claw/earlybird?checkout=cancelled`, + metadata: { + type: 'kiloclaw-earlybird', + kiloUserId: ctx.user.id, + }, }, - success_url: `${APP_URL}/claw?earlybird_checkout=success`, - cancel_url: `${APP_URL}/claw/earlybird?checkout=cancelled`, - metadata: { - type: 'kiloclaw-earlybird', - kiloUserId: ctx.user.id, - }, - }); + { idempotencyKey: `earlybird-checkout-${ctx.user.id}` } + ); return { url: typeof session.url === 'string' ? session.url : null }; }), From 7fe64adb6d8a615e0ac7839ab6e174a76d879c35 Mon Sep 17 00:00:00 2001 From: syn Date: Tue, 3 Mar 2026 12:29:21 -0600 Subject: [PATCH 06/10] Remove static Stripe idempotency key to avoid blocking retries after session expiry --- src/routers/kiloclaw-router.ts | 47 ++++++++++++++++------------------ 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index 3a3930d0e..69344e4ed 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -422,36 +422,33 @@ export const kiloclawRouter = createTRPCRouter({ const couponId = getEnvVariable('STRIPE_KILOCLAW_EARLYBIRD_COUPON_ID'); - const session = await stripe.checkout.sessions.create( - { - mode: 'payment', - customer: stripeCustomerId, - billing_address_collection: 'required', - line_items: [{ price: priceId, quantity: 1 }], - ...(couponId ? { discounts: [{ coupon: couponId }] } : { allow_promotion_codes: true }), - customer_update: { - name: 'auto', - address: 'auto', - }, - tax_id_collection: { - enabled: true, - required: 'never', - }, - payment_intent_data: { - metadata: { - type: 'kiloclaw-earlybird', - kiloUserId: ctx.user.id, - }, - }, - success_url: `${APP_URL}/claw?earlybird_checkout=success`, - cancel_url: `${APP_URL}/claw/earlybird?checkout=cancelled`, + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + customer: stripeCustomerId, + billing_address_collection: 'required', + line_items: [{ price: priceId, quantity: 1 }], + ...(couponId ? { discounts: [{ coupon: couponId }] } : { allow_promotion_codes: true }), + customer_update: { + name: 'auto', + address: 'auto', + }, + tax_id_collection: { + enabled: true, + required: 'never', + }, + payment_intent_data: { metadata: { type: 'kiloclaw-earlybird', kiloUserId: ctx.user.id, }, }, - { idempotencyKey: `earlybird-checkout-${ctx.user.id}` } - ); + success_url: `${APP_URL}/claw?earlybird_checkout=success`, + cancel_url: `${APP_URL}/claw/earlybird?checkout=cancelled`, + metadata: { + type: 'kiloclaw-earlybird', + kiloUserId: ctx.user.id, + }, + }); return { url: typeof session.url === 'string' ? session.url : null }; }), From 4c4a30eefe857dc73f715abe50be4562757c046c Mon Sep 17 00:00:00 2001 From: syn Date: Tue, 3 Mar 2026 12:31:54 -0600 Subject: [PATCH 07/10] Fix earlybird banner flash and button state during loading --- src/app/(app)/claw/components/ClawDashboard.tsx | 2 +- src/app/(app)/claw/earlybird/page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/(app)/claw/components/ClawDashboard.tsx b/src/app/(app)/claw/components/ClawDashboard.tsx index 149453fe5..1dff0408e 100644 --- a/src/app/(app)/claw/components/ClawDashboard.tsx +++ b/src/app/(app)/claw/components/ClawDashboard.tsx @@ -146,7 +146,7 @@ export function ClawDashboard({ status }: { status: KiloClawDashboardStatus | un {instanceStatus?.status === 'running' && } - {!earlybirdStatus?.purchased && } + {earlybirdStatus && !earlybirdStatus.purchased && } ); diff --git a/src/app/(app)/claw/earlybird/page.tsx b/src/app/(app)/claw/earlybird/page.tsx index 327cf6480..15b1b9b69 100644 --- a/src/app/(app)/claw/earlybird/page.tsx +++ b/src/app/(app)/claw/earlybird/page.tsx @@ -77,7 +77,7 @@ export default function EarlybirdPage() {