From 6546a4289dbb41de011dea50dc8c54c97a086aee Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 21 Apr 2026 11:15:34 +0200 Subject: [PATCH 01/32] docs(kiloclaw): add referral program spec --- .specs/kiloclaw-referrals.md | 495 +++++++++++++++++++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 .specs/kiloclaw-referrals.md diff --git a/.specs/kiloclaw-referrals.md b/.specs/kiloclaw-referrals.md new file mode 100644 index 0000000000..9d00e80742 --- /dev/null +++ b/.specs/kiloclaw-referrals.md @@ -0,0 +1,495 @@ +# KiloClaw Referral Program + +## Role of This Document + +This spec defines the business rules and invariants for the KiloClaw referral program powered by Impact Advocate. It is +the source of truth for _what_ the system must guarantee -- who is eligible, how referral attribution competes with +affiliate attribution, when referral conversions occur, how rewards are granted and fulfilled, and how the system behaves +when Impact Advocate or billing integrations are unavailable. It deliberately does not prescribe _how_ to implement those +guarantees: handler names, column layouts, retry strategies, and other implementation choices belong in plan documents +and code, not here. + +## Status + +Draft -- created 2026-04-21. + +## Conventions + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", +"NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC 2119] +[RFC 8174] when, and only when, they appear in all capitals, as shown here. + +## Definitions + +- **Impact Advocate**: The Impact.com referral product used to generate share links, register referral participants, + attribute referred users, and report referral lifecycle and reward events. +- **Impact Performance Program**: The existing Impact.com affiliate/conversion program for KiloClaw, CampaignId `50754`. +- **Advocate Program**: The Impact Advocate referral program for KiloClaw, ProgramId `51699`. +- **UTT (Universal Tracking Tag)**: A JavaScript snippet provided by Impact.com that enables client-side tracking, + first-party cookies, and identity bridging. +- **Advocate widget**: The Impact Verified Access in-app widget `p/51699/w/referrerWidget` used by logged-in users to + access referral share links and referral status. +- **Referrer**: An existing user who shares a referral link and may earn a referral reward when an eligible referee + converts. +- **Referee**: A referred user who arrives through a referral link, creates a Kilo account, and may earn a referral + reward after their first eligible paid KiloClaw conversion. +- **Referral touch**: A captured Impact Advocate attribution interaction, including `_saasquatch` and related referral + parameters or cookies. The value is opaque to Kilo. +- **Valid referral touch**: A referral touch with a non-empty `_saasquatch` value, associated with the converting user's + pre-signup session or user record, where `conversion_time < touched_at + 30 * 24 hours` using server UTC timestamps. +- **Affiliate touch**: A captured Impact affiliate interaction, including the `im_ref` click identifier. The value is + opaque to Kilo. +- **Sale-attributed affiliate touch**: An affiliate touch already used to report a specific SALE conversion to Impact. + This protects that specific reported SALE from retroactive referral override, but it does not make the affiliate touch + win unrelated future referral-priority decisions. +- **Attribution touch**: Either a referral touch or affiliate touch considered by KiloClaw conversion-time attribution + resolution. +- **Valid touch**: An attribution touch that has not expired, belongs to the converting user or their pre-signup session, + and is eligible for the conversion being evaluated. +- **Referral-priority attribution**: The attribution model for KiloClaw referral/affiliate conflict resolution: at + conversion time, a valid referral touch wins over an affiliate touch unless that affiliate touch is sale-attributed for + the same SALE conversion being evaluated. +- **First paid KiloClaw conversion**: The referee's first confirmed paid personal KiloClaw subscription payment period, + whether funded by Stripe settlement, hybrid settlement, or pure-credit deduction. Trial start does not qualify. +- **Monetized KiloClaw payment period**: A KiloClaw billing period with positive Stripe-settled value, positive hybrid + settled value, or positive credit deduction. Zero-dollar invoices, fully comped periods, and admin adjustments are not + monetized payment periods. +- **Free-month reward**: A local KiloClaw billing reward that delays the beneficiary's next KiloClaw renewal by one + calendar month. It is not a general account credit. +- **Calendar month**: A billing-period extension that preserves the day-of-month semantics of the current KiloClaw billing + calendar, clamping to the last valid day of the target month when necessary. +- **Reward beneficiary**: A user who may receive a free-month reward. Beneficiary roles are `referrer` and `referee`. +- **Reward state**: A durable lifecycle state for a reward. Required states are `pending`, `earned`, `applied`, + `reversed`, `expired`, `canceled`, and `review_required`. +- **Active eligible personal KiloClaw subscription**: A personal KiloClaw subscription that is active, not canceling at + period end, not suspended, and not past due. +- **Personal KiloClaw subscription**: A KiloClaw subscription owned by an individual user. Organization/team-scoped + KiloClaw subscriptions are not eligible. +- **Brand-new Kilo account**: A user identity with no current or historical Kilo user identity under the configured + identity key before the referral touch. Adding an auth provider to an existing user is not brand-new. +- **Reward-bearing referral configuration**: The environment configuration required to create referral touches, register + Advocate participants, report Impact conversions, grant local rewards, and apply KiloClaw billing extensions. +- **Chargeback**: A Stripe dispute event for the qualifying Stripe payment. +- **Fraud-marked payment**: A qualifying payment marked fraudulent by Stripe, an internal fraud process, or an authorized + operator. +- **Support review**: A durable `review_required` reward state with the triggering reason, affected billing period, and + source payment or dispute recorded. +- **Impact-facing status field**: Local status retained only to compare Kilo state with Impact dashboard exports or API + reads; it cannot drive eligibility, reward granting, or billing fulfillment. + +## Overview + +The KiloClaw referral program is a double-sided program: when an eligible existing user refers an eligible new KiloClaw +subscriber, the referrer and referee each earn one free KiloClaw month. A reward is earned only after the referee's first +confirmed paid personal KiloClaw subscription payment. The reward is fulfilled by delaying the beneficiary's next +KiloClaw renewal by one calendar month. + +Impact Advocate owns the referral sharing experience, share links, referral cookies, participant registration, and +Advocate program reporting. Kilo owns product eligibility, affiliate/referral attribution conflict resolution, +first-paid-conversion detection, reward grant idempotency, reward caps, and billing fulfillment. + +Impact Advocate conversion state is driven through the existing Impact Performance Program conversion events. The system +uses `Sale (71659)` as the paid-conversion event for paid KiloClaw periods, including renewals. + +This program applies only to personal KiloClaw subscriptions. Organization-scoped KiloClaw instances, team plans, admin +interventions, and non-KiloClaw purchases are out of scope. + +## Rules + +### Program Configuration + +1. The system MUST treat the following identifiers as configuration constants for this integration: + - Impact Account: `7138521` + - Impact Performance CampaignId: `50754` + - Impact Advocate ProgramId: `51699` + - UTT UUID: `A7138521-9724-4b8f-95f4-1db2fbae81141` + - Advocate widget ID: `p/51699/w/referrerWidget` + +2. The system MUST use the existing Impact Performance conversion action tracker IDs for KiloClaw lifecycle reporting: + + | Event | ActionTrackerId | Trigger | + | ----------- | --------------- | --------------------------------------------- | + | VISIT | 71668 | Visitor lands on `kilo.ai` with `im_ref` | + | SIGNUP | 71655 | New user creation with attribution | + | TRIAL_START | 71656 | KiloClaw trial subscription becomes active | + | TRIAL_END | 71658 | KiloClaw trial subscription ends (any reason) | + | SALE | 71659 | Monetized KiloClaw payment period is funded | + +3. The system MUST keep Impact Advocate API credentials server-side. Credentials MUST NOT be exposed to the browser. + +4. If Impact Advocate configuration is absent, referral sharing, participant registration, and Impact reconciliation MAY + be disabled, but the application MUST continue to function normally. + +5. If reward-bearing referral configuration is absent in an environment where the referral program is enabled, the system + MUST fail closed for reward issuance and MUST log the configuration failure. It MUST NOT silently mark rewards or + Impact work as completed. + +6. Referral UTT loading is controlled by the application's public Impact UTT configuration for the active environment. + +### Advocate Experience + +7. Logged-in users MUST access referral sharing through the Impact Verified Access widget. + +8. The system MUST authenticate users to Impact Advocate using the configured Verified Access contract. + +9. The Impact Advocate identity contract for Kilo is: `id = Kilo user ID`, `accountId = Kilo user ID`, and + `email = plain user email`. + +10. The system MUST NOT allow users to alter the identity payload used to establish Advocate identity. + +### Client-Side Tracking and Identity + +11. The system MUST load the Impact UTT script on pages used by the referral program when the UTT identifier is + configured, and MUST NOT load it when the UTT identifier is not configured. + +12. The system MUST invoke Impact `identify` on pages used by the referral program. + +13. Anonymous `identify` calls MUST pass empty string values for unknown `customerId` and `customerEmail`. The system + MUST NOT pass `undefined`, `null`, placeholders, or fake identifiers for unknown users. + +14. Logged-in `identify` calls MUST pass a stable customer identifier and SHA-1 hashed email. + +15. `identify` calls MUST include a stable `customProfileId` derived from the Kilo user ID for logged-in users and a + stable first-party anonymous ID for anonymous users. + +16. The system MUST treat `_saasquatch`, `rsCode`, `rsShareMedium`, `rsEngagementMedium`, `im_ref`, and related tracking + values as opaque. The system MUST NOT parse, validate the internal format of, or assign meaning to these values. + +17. Opaque tracking values MUST have a documented maximum accepted length, MUST be stored as UTF-8 strings, and MUST be + ignored for attribution when they exceed that maximum. Logs MUST redact or truncate opaque tracking values. + +### Referral Touch Capture + +18. When a visitor lands with Impact Advocate referral attribution, the system MUST capture the referral touch before or + during user creation. + +19. A referral touch is valid for attribution only when it contains a non-empty `_saasquatch` value. If `_saasquatch` is + absent, the system MAY preserve related metadata for diagnostics but MUST NOT treat it as a valid referral touch. + +20. A referral touch SHOULD include related opaque metadata when available, including `rsCode`, `rsShareMedium`, + `rsEngagementMedium`, UTM parameters, and sanitized landing path. + +21. Referral touch capture MUST preserve attribution across the authentication flow, including OAuth redirects and + callback URLs. + +22. Referral touches MUST expire 30 days after the touch time. A touch is valid only when + `conversion_time < touched_at + 30 * 24 hours`, using server UTC timestamps. A touch at or after that instant is + expired. + +23. The system MUST associate pre-signup referral touches with the created user during signup or first authenticated + request after signup. + +24. Capturing or associating a referral touch MUST NOT grant a reward. + +25. If a user arrives with multiple referral touches, the system MUST preserve enough chronological information to + resolve referral-priority attribution at conversion time. + +### Affiliate and Referral Attribution Priority + +26. KiloClaw referral rewards and KiloClaw affiliate attribution MUST share a 30-day conversion-time attribution window. + +27. At first paid KiloClaw conversion time, the system MUST evaluate valid affiliate and referral touches together. + +28. For KiloClaw conversions governed by this referral spec, this spec's referral-priority attribution overrides the + permanent first-touch affiliate attribution rules in `.specs/impact-affiliate-tracking.md`. + +29. A valid referral touch MUST win over a valid affiliate touch unless that affiliate touch is sale-attributed for the + same SALE conversion being evaluated. A sale-attributed affiliate touch protects only the already-reported SALE from + retroactive referral override. It MUST NOT be reused to win a later conversion unless that later conversion is also + independently attributed to the affiliate under this spec. + +30. If multiple valid referral touches exist and no same-SALE sale-attributed affiliate touch is present, the oldest valid + referral touch MUST win. + +31. If no valid referral touch exists, the oldest valid affiliate touch MUST win. + +32. If all touches are expired or invalid, neither affiliate attribution nor referral rewards win for that conversion. + +33. If an affiliate touch wins, the system MUST NOT grant referral rewards for that conversion. + +34. If a referral touch wins, the system MUST NOT attribute that first paid KiloClaw conversion to an affiliate for reward + or payout purposes. + +35. The system MUST record when an affiliate touch has been attributed to a SALE conversion so future referral-priority + resolution can preserve that specific sale attribution. + +36. The system MUST implement at least the following attribution outcomes: + +| Scenario | Expected winner | +| ----------------------------------------------------------------------------------- | --------------- | +| Affiliate first, referral second, both valid, affiliate not attributed to this SALE | Referral | +| Affiliate first, referral second, both valid, affiliate attributed to this SALE | Affiliate | +| Referral first, affiliate second, both valid, affiliate not attributed to this SALE | Referral | +| Referral first, affiliate second, both valid, affiliate attributed to this SALE | Affiliate | +| Only affiliate valid | Affiliate | +| Only referral valid | Referral | +| All touches expired or invalid | None | + +37. Attribution resolution for referral rewards MUST happen at conversion time, not only at signup time. + +38. Impact-side attribution MUST NOT override local eligibility, reward caps, or billing fulfillment decisions. + +### Referred Participant Registration + +39. When a new user signs up with `_saasquatch` attribution, the system MUST attempt to register or upsert the user as a + referred participant in Impact Advocate. + +40. Register Participant requests MUST be made server-side. + +41. Register Participant requests MUST pass the captured `_saasquatch` value as opaque cookie attribution. + +42. Register Participant requests SHOULD include locale and country code when available. + +43. If `_saasquatch` is present during signup, referral touch association and participant registration enqueueing MUST + occur before signup is considered complete, but external Impact delivery MUST NOT block user access. + +44. Register Participant failures MUST be recorded for retry or reconciliation. + +45. Transient participant registration failures MUST leave the registration in a retryable state until it succeeds, is + superseded by a corrected payload, or is marked permanently failed by an operator-visible terminal state. + +46. Register Participant requests that fail with client errors MUST be logged and MUST NOT be retried until the request + payload or configuration is corrected. + +47. Register Participant requests MUST use the Kilo user ID for Advocate `id` and `accountId`. + +48. Register Participant requests MUST include plain-text email only as the Advocate contact email. + +### Referee Eligibility + +49. A referee MUST be a brand-new Kilo account to qualify for referral rewards. + +50. Existing users MUST NOT qualify as referees, even if they later click a referral link. + +51. Adding an auth provider to an existing Kilo user MUST NOT qualify as a brand-new Kilo account. + +52. Previously deleted users MUST NOT qualify as referees. Previously deleted user disqualification MUST use a + legal-approved normalized-email hash tombstone. + +53. A referee MUST convert on a personal KiloClaw subscription. Team plans, organization-scoped KiloClaw subscriptions, + and non-KiloClaw subscriptions MUST NOT qualify. + +54. A referee MUST make a first confirmed paid KiloClaw subscription payment before either side earns a reward. + +55. The first confirmed paid KiloClaw subscription payment MUST fund a monetized KiloClaw payment period. + +56. Trial start, trial end, account signup, widget registration, zero-dollar invoices, fully comped periods, admin + adjustments, or referral touch capture MUST NOT qualify as a paid referral conversion. + +57. A referee's renewals after the first paid KiloClaw conversion MUST NOT generate additional referral rewards. + +58. A user MUST NOT refer themselves. The system MUST disqualify a referral when the referrer and referee resolve to the + same Kilo user. + +59. Fraudulent, test, admin-created, or manually adjusted subscriptions MUST NOT qualify for referral rewards unless an + authorized operator explicitly marks the conversion as eligible under a documented support process. + +### Referrer Eligibility + +60. A referrer MUST be a Kilo user registered or registerable as an Impact Advocate participant. + +61. A referrer MUST have an active eligible personal KiloClaw subscription when the reward is earned. + +62. A referrer without an active eligible personal KiloClaw subscription at reward-earn time MUST be disqualified for that + referrer reward. The disqualification MUST be recorded. + +63. A referrer MUST NOT receive more than 12 total free-month rewards from the referral program. + +64. The referrer cap MUST be enforced before granting a referrer reward. + +65. The 12-month referrer cap MUST be enforced atomically across concurrent reward grants. Concurrent processing MUST NOT + produce more than 12 granted referrer reward months. + +66. When a qualified referral occurs after the referrer has reached the 12-month cap, the system MUST record that the + referrer reward was cap-limited and MUST NOT grant another referrer free month. + +67. Referee rewards MUST NOT count against the referrer's 12-month cap. + +### Reward Granting + +68. A qualified referral conversion MUST grant one free-month reward to the referee. + +69. A qualified referral conversion MUST grant one free-month reward to the referrer. The reward MUST be marked + cap-limited instead of granted when the referrer cap has been reached or another referrer eligibility rule prevents + it. + +70. Referral reward granting MUST be idempotent. Processing the same qualifying conversion multiple times MUST NOT create + duplicate rewards for the same beneficiary role. + +71. For a qualified referral, reward grant processing MUST be atomic across both beneficiary reward decisions. Both + beneficiary outcomes MUST be recorded together, including granted, cap-limited, and disqualified outcomes. + +72. Reward records MUST identify the source referral, source conversion, beneficiary user, beneficiary role, number of + months granted, status, and relevant timestamps. + +73. Reward records MUST support the reward states defined in this spec. + +74. A reward MUST NOT be considered fulfilled until the corresponding KiloClaw renewal has been delayed. + +75. Impact Advocate reward state MAY be used for reconciliation, support, or reporting. It MUST NOT be the source of + truth for local free-month fulfillment. + +### Reward Fulfillment and Billing + +76. Free-month rewards MUST be fulfilled by delaying a KiloClaw renewal by one calendar month per reward. + +77. An earned reward applies to the beneficiary's next unpaid renewal boundary after the reward is earned. It MUST NOT + modify already-finalized invoices or already-funded periods. + +78. Free-month rewards MUST NOT be fulfilled as general account credits. + +79. Free-month rewards MUST apply to KiloClaw billing only. They MUST NOT apply to inference usage, Kilo Pass, team plans, + or non-KiloClaw purchases. + +80. Multiple free-month rewards MAY stack. Each applied reward MUST delay renewal by exactly one calendar month. + +81. For month-to-month KiloClaw subscriptions, one reward MUST delay the next monthly renewal by one calendar month. + +82. For six-month commitment KiloClaw subscriptions, one reward MUST delay the next six-month renewal by one calendar + month. The reward MUST NOT convert the subscription to month-to-month and MUST NOT reduce the next invoice by one + sixth. + +83. For pure-credit KiloClaw subscriptions, reward application MUST update local renewal state so the credit renewal sweep + does not deduct KiloClaw hosting credits until the extended renewal time. + +84. For Stripe-funded or hybrid KiloClaw subscriptions, reward application MUST keep local billing state and Stripe + billing state consistent. The system MUST NOT create a local-only renewal delay for a Stripe-funded subscription + while allowing Stripe to charge on the original schedule. + +85. Reward application MUST be idempotent. Retrying reward application MUST NOT extend the same subscription more than + once for the same reward. + +86. Reward application MUST record an audit trail containing the reward, beneficiary, affected subscription, previous + renewal or period boundary, new renewal or period boundary, and any external billing operation identifiers. + +87. Reward application MUST NOT break existing KiloClaw billing invariants for trials, pure-credit renewal, hybrid invoice + settlement, commit plans, plan switching, cancellation, reactivation, past-due recovery, suspension, or destruction. + +88. Reward application MUST respect cancellation state. If a subscription is canceled or canceling before reward + application, the reward MUST remain pending until the beneficiary has an active eligible personal KiloClaw + subscription. + +### Impact Conversion Reporting + +89. Impact Advocate referral conversion MUST be driven by the existing Impact Performance conversion events. + +90. `Sale (71659)` MUST be the paid KiloClaw conversion event used for referral conversion and renewal reporting. + +91. The system MUST NOT dispatch client-side `trackConversion` for referrals while server-side Performance conversion is + the configured reporting mechanism. + +92. When a referral wins attribution and the first paid conversion qualifies, the system MUST ensure Impact receives the + required Performance conversion data for Advocate conversion reporting. + +93. Conversion reporting MUST use deterministic order identifiers where possible so retries do not create duplicate Impact + actions. + +94. Conversion reporting failures MUST NOT block billing settlement, reward ledger creation, or user access. Failures MUST + leave the conversion report in a retryable state until it succeeds, is superseded by a corrected payload, or is marked + permanently failed by an operator-visible terminal state. + +### Impact Reconciliation + +95. The system MUST NOT rely on Impact Advocate webhooks for referral eligibility, reward granting, billing fulfillment, + or reconciliation. + +96. The system MAY use Impact dashboard exports or API reads for manual reconciliation and support investigations. + +97. Impact reconciliation data MAY update local Impact-facing status fields, but it MUST NOT bypass local eligibility, + cap, attribution, or billing fulfillment rules. + +### Refunds, Reversals, and Fraud + +98. Rewards from a qualifying Stripe payment MUST be canceled if Stripe reports a chargeback for that payment. + +99. Pending or earned-but-unapplied rewards MUST be canceled when the qualifying Stripe payment is charged back. + +100. Already-applied rewards from a charged-back Stripe payment MUST be marked for support review and MUST NOT be silently + clawed back. + +101. Rewards from refunded or fraud-marked payments MUST be canceled before application. Already-applied rewards from + refunded or fraud-marked payments MUST be marked for support review and MUST NOT be silently clawed back. + +102. If a qualifying Impact action must be reversed, the system SHOULD use Impact's reverse-action mechanism instead of + creating an unrelated negative conversion. + +103. Reversal and reward-cancellation handling MUST be idempotent. + +### GDPR and PII + +104. Referral tables that store user IDs, emails, referral relationships, IP addresses, referral cookies, Impact IDs, or + reconciliation payloads MUST be included in GDPR soft-delete or anonymization flows. + +105. GDPR deletion MUST delete or anonymize referral participant records, referral touch records, referral relationship + records, reconciliation payloads containing PII, and reward records to the extent required by policy. + +106. Plain email stored for Impact Advocate compatibility MUST be deleted or anonymized during GDPR deletion. + +107. Previously deleted user disqualification MUST use a legal-approved non-PII tombstone or irreversible hash. The system + MUST NOT retain PII solely for this purpose. + +108. Referral tracking values MUST NOT be logged in a way that exposes secrets, auth headers, cookies, or unnecessary PII. + +### Reliability and Isolation + +109. Referral touch capture, participant registration, conversion reporting, reconciliation processing, and reward + fulfillment failures MUST NOT break unrelated product functionality. + +110. Reward ledger operations MUST be transactional where needed to prevent duplicate grants, partial grants, or missing + audit records. + +111. Reward fulfillment failures MUST leave rewards in a retryable state unless the failure is a permanent eligibility or + configuration failure. + +112. The system MUST expose enough operational state to distinguish pending Impact registration, pending Impact conversion + reporting, pending local reward application, applied rewards, reversed rewards, canceled rewards, review-required + rewards, and disqualified referrals. + +113. Admin-only subscription interventions, internal test conversions, and support adjustments MUST NOT emit referral + rewards or Impact referral conversions unless explicitly marked as eligible by an authorized operator. + +### Existing Internal Referral System + +114. The existing internal referral-code system MUST NOT grant additional KiloClaw referral rewards for conversions already + governed by this spec. + +115. Before launch, the existing internal referral system MUST be scoped away from KiloClaw, disabled for KiloClaw, or + migrated into this program's rules to prevent double rewards. + +## Error Handling + +1. If referral touch capture fails, the system SHOULD log the failure and continue the primary request. + +2. If Register Participant delivery fails with a server error or timeout, the system MUST leave the registration in a + retryable state. + +3. If Register Participant delivery fails with a client error, the system MUST log the error and MUST NOT retry unchanged + payloads. + +4. If Impact conversion reporting fails with a server error or timeout, the system MUST leave the report in a retryable + state. + +5. If Impact conversion reporting fails with a client error, the system MUST log the error and MUST NOT retry unchanged + payloads. + +6. If reward grant processing detects an ineligible referee, ineligible referrer, expired attribution, self-referral, + exceeded cap, or non-personal subscription, the system MUST record the disqualification reason when a referral record + exists. + +7. If reward application fails after a reward is earned, the reward MUST remain retryable unless the failure is permanent + and auditable. + +8. If required billing state is ambiguous, the system MUST NOT apply a reward. It MUST leave the reward pending and log + the ambiguity for investigation. + +## Changelog + +### 2026-04-21 -- Initial spec + +Created source-of-truth rules for the KiloClaw referral program using Impact Advocate. Defined program identifiers, +Advocate widget and participant registration requirements, referral-priority attribution over affiliate attribution, +exact 30-day UTC expiration semantics, brand-new and previously deleted user boundaries, first-paid monetized KiloClaw +conversion, double-sided free-month rewards, referrer 12-month cap, atomic reward decisions, inactive referrer +disqualification, next-unpaid-renewal reward application, app-owned billing fulfillment, Impact reconciliation behavior, +no Advocate webhook reliance, retryable failure states, tracking-value limits, support-review state, GDPR handling, Impact +identity mapping, and Stripe chargeback reward cancellation. From 8faf1a6f648c33183e3f7cf4c7fb3d5853432e0f Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 21 Apr 2026 14:52:02 +0200 Subject: [PATCH 02/32] Apply suggestions from code review Co-authored-by: Rietie --- .specs/kiloclaw-referrals.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.specs/kiloclaw-referrals.md b/.specs/kiloclaw-referrals.md index 9d00e80742..11653facac 100644 --- a/.specs/kiloclaw-referrals.md +++ b/.specs/kiloclaw-referrals.md @@ -50,7 +50,7 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S conversion time, a valid referral touch wins over an affiliate touch unless that affiliate touch is sale-attributed for the same SALE conversion being evaluated. - **First paid KiloClaw conversion**: The referee's first confirmed paid personal KiloClaw subscription payment period, - whether funded by Stripe settlement, hybrid settlement, or pure-credit deduction. Trial start does not qualify. + whether funded by Stripe settlement, hybrid settlement, or pure-credit deduction. Trial start does not qualify, nor does a purchase of inference / credits. - **Monetized KiloClaw payment period**: A KiloClaw billing period with positive Stripe-settled value, positive hybrid settled value, or positive credit deduction. Zero-dollar invoices, fully comped periods, and admin adjustments are not monetized payment periods. @@ -80,7 +80,7 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S ## Overview The KiloClaw referral program is a double-sided program: when an eligible existing user refers an eligible new KiloClaw -subscriber, the referrer and referee each earn one free KiloClaw month. A reward is earned only after the referee's first +paying subscriber, the referrer and referee each earn one free KiloClaw month. A reward is earned only after the referee's first confirmed paid personal KiloClaw subscription payment. The reward is fulfilled by delaying the beneficiary's next KiloClaw renewal by one calendar month. From db7ec7500758793895e941d27693d6e6da25cfc2 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 21 Apr 2026 15:28:01 +0200 Subject: [PATCH 03/32] docs(kiloclaw): clarify referral attribution spec --- .specs/kiloclaw-referrals.md | 246 ++++++++++++++++++----------------- 1 file changed, 128 insertions(+), 118 deletions(-) diff --git a/.specs/kiloclaw-referrals.md b/.specs/kiloclaw-referrals.md index 11653facac..f2d73bdeb7 100644 --- a/.specs/kiloclaw-referrals.md +++ b/.specs/kiloclaw-referrals.md @@ -39,16 +39,16 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S pre-signup session or user record, where `conversion_time < touched_at + 30 * 24 hours` using server UTC timestamps. - **Affiliate touch**: A captured Impact affiliate interaction, including the `im_ref` click identifier. The value is opaque to Kilo. -- **Sale-attributed affiliate touch**: An affiliate touch already used to report a specific SALE conversion to Impact. - This protects that specific reported SALE from retroactive referral override, but it does not make the affiliate touch - win unrelated future referral-priority decisions. +- **Sale-attributed affiliate touch**: An affiliate touch already used to report a SALE conversion to Impact. This + protects the initial SALE and subsequent KiloClaw renewals from referral override, so an affiliate who already earned + SALE attribution continues to receive affiliate renewal attribution under the affiliate tracking spec. - **Attribution touch**: Either a referral touch or affiliate touch considered by KiloClaw conversion-time attribution resolution. - **Valid touch**: An attribution touch that has not expired, belongs to the converting user or their pre-signup session, and is eligible for the conversion being evaluated. - **Referral-priority attribution**: The attribution model for KiloClaw referral/affiliate conflict resolution: at - conversion time, a valid referral touch wins over an affiliate touch unless that affiliate touch is sale-attributed for - the same SALE conversion being evaluated. + conversion time, a valid referral touch wins over an affiliate touch unless that affiliate touch has already been + sale-attributed. - **First paid KiloClaw conversion**: The referee's first confirmed paid personal KiloClaw subscription payment period, whether funded by Stripe settlement, hybrid settlement, or pure-credit deduction. Trial start does not qualify, nor does a purchase of inference / credits. - **Monetized KiloClaw payment period**: A KiloClaw billing period with positive Stripe-settled value, positive hybrid @@ -73,7 +73,8 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S - **Fraud-marked payment**: A qualifying payment marked fraudulent by Stripe, an internal fraud process, or an authorized operator. - **Support review**: A durable `review_required` reward state with the triggering reason, affected billing period, and - source payment or dispute recorded. + source payment or dispute recorded. Kilo team review is required before an already-applied reward can be canceled, + clawed back, or otherwise adjusted. - **Impact-facing status field**: Local status retained only to compare Kilo state with Impact dashboard exports or API reads; it cannot drive eligibility, reward granting, or billing fulfillment. @@ -85,8 +86,9 @@ confirmed paid personal KiloClaw subscription payment. The reward is fulfilled b KiloClaw renewal by one calendar month. Impact Advocate owns the referral sharing experience, share links, referral cookies, participant registration, and -Advocate program reporting. Kilo owns product eligibility, affiliate/referral attribution conflict resolution, -first-paid-conversion detection, reward grant idempotency, reward caps, and billing fulfillment. +Advocate program reporting. Impact may mirror referral priority and reward settings for reporting, but Kilo owns the +authoritative product eligibility, affiliate/referral attribution conflict resolution, first-paid-conversion detection, +reward grant idempotency, reward caps, and billing fulfillment. Impact Advocate conversion state is driven through the existing Impact Performance Program conversion events. The system uses `Sale (71659)` as the paid-conversion event for paid KiloClaw periods, including renewals. @@ -160,8 +162,8 @@ interventions, and non-KiloClaw purchases are out of scope. ### Referral Touch Capture -18. When a visitor lands with Impact Advocate referral attribution, the system MUST capture the referral touch before or - during user creation. +18. When a visitor opens an Impact Advocate referral link, the system MUST recognize that referral before signup and + preserve it through account creation so the referral can be associated with the newly created user. 19. A referral touch is valid for attribution only when it contains a non-empty `_saasquatch` value. If `_saasquatch` is absent, the system MAY preserve related metadata for diagnostics but MUST NOT treat it as a valid referral touch. @@ -193,267 +195,275 @@ interventions, and non-KiloClaw purchases are out of scope. 28. For KiloClaw conversions governed by this referral spec, this spec's referral-priority attribution overrides the permanent first-touch affiliate attribution rules in `.specs/impact-affiliate-tracking.md`. -29. A valid referral touch MUST win over a valid affiliate touch unless that affiliate touch is sale-attributed for the - same SALE conversion being evaluated. A sale-attributed affiliate touch protects only the already-reported SALE from - retroactive referral override. It MUST NOT be reused to win a later conversion unless that later conversion is also - independently attributed to the affiliate under this spec. +29. A valid referral touch MUST win over a valid affiliate touch unless the affiliate touch has already been + sale-attributed before the referral touch occurred. Initial attribution for a not-yet-attributed SALE MUST prefer the + valid referral touch. -30. If multiple valid referral touches exist and no same-SALE sale-attributed affiliate touch is present, the oldest valid - referral touch MUST win. +30. A sale-attributed affiliate touch MUST keep affiliate attribution for the initial SALE and subsequent KiloClaw renewals + only when that initial SALE occurred before the referral touch. Referral touches MUST NOT retroactively override those + affiliate-attributed SALE events. -31. If no valid referral touch exists, the oldest valid affiliate touch MUST win. +31. If multiple valid referral touches exist and no sale-attributed affiliate touch is present, the oldest valid referral + touch MUST win. -32. If all touches are expired or invalid, neither affiliate attribution nor referral rewards win for that conversion. +32. If no valid referral touch exists, the oldest valid affiliate touch MUST win. -33. If an affiliate touch wins, the system MUST NOT grant referral rewards for that conversion. +33. If all touches are expired or invalid, neither affiliate attribution nor referral rewards win for that conversion. -34. If a referral touch wins, the system MUST NOT attribute that first paid KiloClaw conversion to an affiliate for reward +34. If an affiliate touch wins, the system MUST NOT grant referral rewards for that conversion. + +35. If a referral touch wins, the system MUST NOT attribute that first paid KiloClaw conversion to an affiliate for reward or payout purposes. -35. The system MUST record when an affiliate touch has been attributed to a SALE conversion so future referral-priority - resolution can preserve that specific sale attribution. +36. The system MUST record when an affiliate touch has been attributed to a SALE conversion so affiliate attribution can be + preserved for that initial sale and subsequent KiloClaw renewals. -36. The system MUST implement at least the following attribution outcomes: +37. The system MUST implement at least the following attribution outcomes. -| Scenario | Expected winner | -| ----------------------------------------------------------------------------------- | --------------- | -| Affiliate first, referral second, both valid, affiliate not attributed to this SALE | Referral | -| Affiliate first, referral second, both valid, affiliate attributed to this SALE | Affiliate | -| Referral first, affiliate second, both valid, affiliate not attributed to this SALE | Referral | -| Referral first, affiliate second, both valid, affiliate attributed to this SALE | Affiliate | -| Only affiliate valid | Affiliate | -| Only referral valid | Referral | -| All touches expired or invalid | None | +| Scenario | Expected winner | +| ---------------------------------------------------------------------------- | --------------- | +| Affiliate first, referral second, both valid, no prior affiliate SALE | Referral | +| Affiliate first, referral second, both valid, affiliate SALE before referral | Affiliate | +| Referral first, affiliate second, both valid, no prior affiliate SALE | Referral | +| Only affiliate valid | Affiliate | +| Only referral valid | Referral | +| All touches expired or invalid | None | -37. Attribution resolution for referral rewards MUST happen at conversion time, not only at signup time. +38. Attribution resolution for referral rewards MUST happen at conversion time, not only at signup time. -38. Impact-side attribution MUST NOT override local eligibility, reward caps, or billing fulfillment decisions. +39. Impact-side attribution MUST NOT override local eligibility, reward caps, or billing fulfillment decisions. ### Referred Participant Registration -39. When a new user signs up with `_saasquatch` attribution, the system MUST attempt to register or upsert the user as a +40. When a new user signs up with `_saasquatch` attribution, the system MUST attempt to register or upsert the user as a referred participant in Impact Advocate. -40. Register Participant requests MUST be made server-side. +41. Register Participant requests MUST be made server-side. -41. Register Participant requests MUST pass the captured `_saasquatch` value as opaque cookie attribution. +42. Register Participant requests MUST pass the captured `_saasquatch` value as opaque cookie attribution. -42. Register Participant requests SHOULD include locale and country code when available. +43. Register Participant requests SHOULD include locale and country code when available. -43. If `_saasquatch` is present during signup, referral touch association and participant registration enqueueing MUST +44. If `_saasquatch` is present during signup, referral touch association and participant registration enqueueing MUST occur before signup is considered complete, but external Impact delivery MUST NOT block user access. -44. Register Participant failures MUST be recorded for retry or reconciliation. +45. Register Participant failures MUST be recorded for retry or reconciliation. -45. Transient participant registration failures MUST leave the registration in a retryable state until it succeeds, is +46. Transient participant registration failures MUST leave the registration in a retryable state until it succeeds, is superseded by a corrected payload, or is marked permanently failed by an operator-visible terminal state. -46. Register Participant requests that fail with client errors MUST be logged and MUST NOT be retried until the request +47. Register Participant requests that fail with client errors MUST be logged and MUST NOT be retried until the request payload or configuration is corrected. -47. Register Participant requests MUST use the Kilo user ID for Advocate `id` and `accountId`. +48. Register Participant requests MUST use the Kilo user ID for Advocate `id` and `accountId`. -48. Register Participant requests MUST include plain-text email only as the Advocate contact email. +49. Register Participant requests MUST include plain-text email only as the Advocate contact email. ### Referee Eligibility -49. A referee MUST be a brand-new Kilo account to qualify for referral rewards. +50. A referee MUST be a brand-new Kilo account to qualify for referral rewards. -50. Existing users MUST NOT qualify as referees, even if they later click a referral link. +51. Existing users MUST NOT qualify as referees, even if they later click a referral link. -51. Adding an auth provider to an existing Kilo user MUST NOT qualify as a brand-new Kilo account. +52. Adding an auth provider to an existing Kilo user MUST NOT qualify as a brand-new Kilo account. -52. Previously deleted users MUST NOT qualify as referees. Previously deleted user disqualification MUST use a +53. Previously deleted users MUST NOT qualify as referees. Previously deleted user disqualification MUST use a legal-approved normalized-email hash tombstone. -53. A referee MUST convert on a personal KiloClaw subscription. Team plans, organization-scoped KiloClaw subscriptions, +54. A referee MUST convert on a personal KiloClaw subscription. Team plans, organization-scoped KiloClaw subscriptions, and non-KiloClaw subscriptions MUST NOT qualify. -54. A referee MUST make a first confirmed paid KiloClaw subscription payment before either side earns a reward. +55. A referee MUST make a first confirmed paid KiloClaw subscription payment before either side earns a reward. -55. The first confirmed paid KiloClaw subscription payment MUST fund a monetized KiloClaw payment period. +56. The first confirmed paid KiloClaw subscription payment MUST fund a monetized KiloClaw payment period. -56. Trial start, trial end, account signup, widget registration, zero-dollar invoices, fully comped periods, admin +57. Trial start, trial end, account signup, widget registration, zero-dollar invoices, fully comped periods, admin adjustments, or referral touch capture MUST NOT qualify as a paid referral conversion. -57. A referee's renewals after the first paid KiloClaw conversion MUST NOT generate additional referral rewards. +58. A referee's renewals after the first paid KiloClaw conversion MUST NOT generate additional referral rewards. -58. A user MUST NOT refer themselves. The system MUST disqualify a referral when the referrer and referee resolve to the +59. A user MUST NOT refer themselves. The system MUST disqualify a referral when the referrer and referee resolve to the same Kilo user. -59. Fraudulent, test, admin-created, or manually adjusted subscriptions MUST NOT qualify for referral rewards unless an +60. Fraudulent, test, admin-created, or manually adjusted subscriptions MUST NOT qualify for referral rewards unless an authorized operator explicitly marks the conversion as eligible under a documented support process. ### Referrer Eligibility -60. A referrer MUST be a Kilo user registered or registerable as an Impact Advocate participant. +61. A referrer MUST be a Kilo user registered or registerable as an Impact Advocate participant. + +62. A referrer's current KiloClaw subscription state MUST NOT prevent reward earning. -61. A referrer MUST have an active eligible personal KiloClaw subscription when the reward is earned. +63. If a referrer has no active eligible personal KiloClaw subscription when the reward is earned, the system MUST keep the + reward pending so it can be applied when the referrer starts or reactivates an eligible personal KiloClaw + subscription. -62. A referrer without an active eligible personal KiloClaw subscription at reward-earn time MUST be disqualified for that - referrer reward. The disqualification MUST be recorded. +64. A pending referrer reward MUST NOT apply to a KiloClaw trial. It MUST apply to the next unpaid renewal boundary after + the referrer starts or reactivates a paid personal KiloClaw subscription. -63. A referrer MUST NOT receive more than 12 total free-month rewards from the referral program. +65. A referrer MUST NOT receive more than 12 total free-month rewards from the referral program. -64. The referrer cap MUST be enforced before granting a referrer reward. +66. The referrer cap MUST be enforced before granting a referrer reward. -65. The 12-month referrer cap MUST be enforced atomically across concurrent reward grants. Concurrent processing MUST NOT +67. The 12-month referrer cap MUST be enforced atomically across concurrent reward grants. Concurrent processing MUST NOT produce more than 12 granted referrer reward months. -66. When a qualified referral occurs after the referrer has reached the 12-month cap, the system MUST record that the +68. When a qualified referral occurs after the referrer has reached the 12-month cap, the system MUST record that the referrer reward was cap-limited and MUST NOT grant another referrer free month. -67. Referee rewards MUST NOT count against the referrer's 12-month cap. +69. Referee rewards MUST NOT count against the referrer's 12-month cap. ### Reward Granting -68. A qualified referral conversion MUST grant one free-month reward to the referee. +70. A qualified referral conversion MUST grant one free-month reward to the referee. -69. A qualified referral conversion MUST grant one free-month reward to the referrer. The reward MUST be marked +71. A qualified referral conversion MUST grant one free-month reward to the referrer. The reward MUST be marked cap-limited instead of granted when the referrer cap has been reached or another referrer eligibility rule prevents it. -70. Referral reward granting MUST be idempotent. Processing the same qualifying conversion multiple times MUST NOT create +72. Referral reward granting MUST be idempotent. Processing the same qualifying conversion multiple times MUST NOT create duplicate rewards for the same beneficiary role. -71. For a qualified referral, reward grant processing MUST be atomic across both beneficiary reward decisions. Both +73. For a qualified referral, reward grant processing MUST be atomic across both beneficiary reward decisions. Both beneficiary outcomes MUST be recorded together, including granted, cap-limited, and disqualified outcomes. -72. Reward records MUST identify the source referral, source conversion, beneficiary user, beneficiary role, number of +74. Reward records MUST identify the source referral, source conversion, beneficiary user, beneficiary role, number of months granted, status, and relevant timestamps. -73. Reward records MUST support the reward states defined in this spec. +75. Reward records MUST support the reward states defined in this spec. -74. A reward MUST NOT be considered fulfilled until the corresponding KiloClaw renewal has been delayed. +76. A reward MUST NOT be considered fulfilled until KiloClaw billing state and any required Stripe state have been + successfully updated so the corresponding KiloClaw renewal is delayed. -75. Impact Advocate reward state MAY be used for reconciliation, support, or reporting. It MUST NOT be the source of +77. Impact Advocate reward state MAY be used for reconciliation, support, or reporting. It MUST NOT be the source of truth for local free-month fulfillment. ### Reward Fulfillment and Billing -76. Free-month rewards MUST be fulfilled by delaying a KiloClaw renewal by one calendar month per reward. +78. Free-month rewards MUST be fulfilled by delaying a KiloClaw renewal by one calendar month per reward. -77. An earned reward applies to the beneficiary's next unpaid renewal boundary after the reward is earned. It MUST NOT +79. An earned reward applies to the beneficiary's next unpaid renewal boundary after the reward is earned. It MUST NOT modify already-finalized invoices or already-funded periods. -78. Free-month rewards MUST NOT be fulfilled as general account credits. +80. Free-month rewards MUST NOT be fulfilled as general account credits. -79. Free-month rewards MUST apply to KiloClaw billing only. They MUST NOT apply to inference usage, Kilo Pass, team plans, +81. Free-month rewards MUST apply to KiloClaw billing only. They MUST NOT apply to inference usage, Kilo Pass, team plans, or non-KiloClaw purchases. -80. Multiple free-month rewards MAY stack. Each applied reward MUST delay renewal by exactly one calendar month. +82. Multiple free-month rewards MAY stack. Each applied reward MUST delay renewal by exactly one calendar month. -81. For month-to-month KiloClaw subscriptions, one reward MUST delay the next monthly renewal by one calendar month. +83. For month-to-month KiloClaw subscriptions, one reward MUST delay the next monthly renewal by one calendar month. -82. For six-month commitment KiloClaw subscriptions, one reward MUST delay the next six-month renewal by one calendar +84. For six-month commitment KiloClaw subscriptions, one reward MUST delay the next six-month renewal by one calendar month. The reward MUST NOT convert the subscription to month-to-month and MUST NOT reduce the next invoice by one sixth. -83. For pure-credit KiloClaw subscriptions, reward application MUST update local renewal state so the credit renewal sweep +85. For pure-credit KiloClaw subscriptions, reward application MUST update local renewal state so the credit renewal sweep does not deduct KiloClaw hosting credits until the extended renewal time. -84. For Stripe-funded or hybrid KiloClaw subscriptions, reward application MUST keep local billing state and Stripe +86. For Stripe-funded or hybrid KiloClaw subscriptions, reward application MUST keep local billing state and Stripe billing state consistent. The system MUST NOT create a local-only renewal delay for a Stripe-funded subscription while allowing Stripe to charge on the original schedule. -85. Reward application MUST be idempotent. Retrying reward application MUST NOT extend the same subscription more than +87. Reward application MUST be idempotent. Retrying reward application MUST NOT extend the same subscription more than once for the same reward. -86. Reward application MUST record an audit trail containing the reward, beneficiary, affected subscription, previous +88. Reward application MUST record an audit trail containing the reward, beneficiary, affected subscription, previous renewal or period boundary, new renewal or period boundary, and any external billing operation identifiers. -87. Reward application MUST NOT break existing KiloClaw billing invariants for trials, pure-credit renewal, hybrid invoice +89. Reward application MUST NOT break existing KiloClaw billing invariants for trials, pure-credit renewal, hybrid invoice settlement, commit plans, plan switching, cancellation, reactivation, past-due recovery, suspension, or destruction. -88. Reward application MUST respect cancellation state. If a subscription is canceled or canceling before reward +90. Reward application MUST respect cancellation state. If a subscription is canceled or canceling before reward application, the reward MUST remain pending until the beneficiary has an active eligible personal KiloClaw subscription. ### Impact Conversion Reporting -89. Impact Advocate referral conversion MUST be driven by the existing Impact Performance conversion events. +91. Impact Advocate referral conversion MUST be driven by the existing Impact Performance conversion events. -90. `Sale (71659)` MUST be the paid KiloClaw conversion event used for referral conversion and renewal reporting. +92. `Sale (71659)` MUST be the paid KiloClaw conversion event used for referral conversion and renewal reporting. -91. The system MUST NOT dispatch client-side `trackConversion` for referrals while server-side Performance conversion is +93. The system MUST NOT dispatch client-side `trackConversion` for referrals while server-side Performance conversion is the configured reporting mechanism. -92. When a referral wins attribution and the first paid conversion qualifies, the system MUST ensure Impact receives the +94. When a referral wins attribution and the first paid conversion qualifies, the system MUST ensure Impact receives the required Performance conversion data for Advocate conversion reporting. -93. Conversion reporting MUST use deterministic order identifiers where possible so retries do not create duplicate Impact +95. Conversion reporting MUST use deterministic order identifiers where possible so retries do not create duplicate Impact actions. -94. Conversion reporting failures MUST NOT block billing settlement, reward ledger creation, or user access. Failures MUST +96. Conversion reporting failures MUST NOT block billing settlement, reward ledger creation, or user access. Failures MUST leave the conversion report in a retryable state until it succeeds, is superseded by a corrected payload, or is marked permanently failed by an operator-visible terminal state. ### Impact Reconciliation -95. The system MUST NOT rely on Impact Advocate webhooks for referral eligibility, reward granting, billing fulfillment, +97. The system MUST NOT rely on Impact Advocate webhooks for referral eligibility, reward granting, billing fulfillment, or reconciliation. -96. The system MAY use Impact dashboard exports or API reads for manual reconciliation and support investigations. +98. The system MAY use Impact dashboard exports or API reads for manual reconciliation and support investigations. -97. Impact reconciliation data MAY update local Impact-facing status fields, but it MUST NOT bypass local eligibility, +99. Impact reconciliation data MAY update local Impact-facing status fields, but it MUST NOT bypass local eligibility, cap, attribution, or billing fulfillment rules. ### Refunds, Reversals, and Fraud -98. Rewards from a qualifying Stripe payment MUST be canceled if Stripe reports a chargeback for that payment. +100. Rewards from a qualifying Stripe payment MUST be canceled if Stripe reports a chargeback for that payment. -99. Pending or earned-but-unapplied rewards MUST be canceled when the qualifying Stripe payment is charged back. +101. Pending or earned-but-unapplied rewards MUST be canceled when the qualifying Stripe payment is charged back. -100. Already-applied rewards from a charged-back Stripe payment MUST be marked for support review and MUST NOT be silently - clawed back. +102. Already-applied rewards from a charged-back Stripe payment MUST be marked for support review and MUST NOT be + automatically canceled or clawed back. -101. Rewards from refunded or fraud-marked payments MUST be canceled before application. Already-applied rewards from - refunded or fraud-marked payments MUST be marked for support review and MUST NOT be silently clawed back. +103. Rewards from refunded or fraud-marked payments MUST be canceled before application. Already-applied rewards from + refunded or fraud-marked payments MUST be marked for support review and MUST NOT be automatically canceled or clawed + back. -102. If a qualifying Impact action must be reversed, the system SHOULD use Impact's reverse-action mechanism instead of - creating an unrelated negative conversion. +104. If a qualifying Impact action must be reversed, the system SHOULD use Impact's reverse-action mechanism instead of + creating an unrelated negative conversion. -103. Reversal and reward-cancellation handling MUST be idempotent. +105. Reversal and reward-cancellation handling MUST be idempotent. ### GDPR and PII -104. Referral tables that store user IDs, emails, referral relationships, IP addresses, referral cookies, Impact IDs, or +106. Referral tables that store user IDs, emails, referral relationships, IP addresses, referral cookies, Impact IDs, or reconciliation payloads MUST be included in GDPR soft-delete or anonymization flows. -105. GDPR deletion MUST delete or anonymize referral participant records, referral touch records, referral relationship +107. GDPR deletion MUST delete or anonymize referral participant records, referral touch records, referral relationship records, reconciliation payloads containing PII, and reward records to the extent required by policy. -106. Plain email stored for Impact Advocate compatibility MUST be deleted or anonymized during GDPR deletion. +108. Plain email stored for Impact Advocate compatibility MUST be deleted or anonymized during GDPR deletion. -107. Previously deleted user disqualification MUST use a legal-approved non-PII tombstone or irreversible hash. The system +109. Previously deleted user disqualification MUST use a legal-approved non-PII tombstone or irreversible hash. The system MUST NOT retain PII solely for this purpose. -108. Referral tracking values MUST NOT be logged in a way that exposes secrets, auth headers, cookies, or unnecessary PII. +110. Referral tracking values MUST NOT be logged in a way that exposes secrets, auth headers, cookies, or unnecessary PII. ### Reliability and Isolation -109. Referral touch capture, participant registration, conversion reporting, reconciliation processing, and reward +111. Referral touch capture, participant registration, conversion reporting, reconciliation processing, and reward fulfillment failures MUST NOT break unrelated product functionality. -110. Reward ledger operations MUST be transactional where needed to prevent duplicate grants, partial grants, or missing +112. Reward ledger operations MUST be transactional where needed to prevent duplicate grants, partial grants, or missing audit records. -111. Reward fulfillment failures MUST leave rewards in a retryable state unless the failure is a permanent eligibility or +113. Reward fulfillment failures MUST leave rewards in a retryable state unless the failure is a permanent eligibility or configuration failure. -112. The system MUST expose enough operational state to distinguish pending Impact registration, pending Impact conversion +114. The system MUST expose enough operational state to distinguish pending Impact registration, pending Impact conversion reporting, pending local reward application, applied rewards, reversed rewards, canceled rewards, review-required rewards, and disqualified referrals. -113. Admin-only subscription interventions, internal test conversions, and support adjustments MUST NOT emit referral +115. Admin-only subscription interventions, internal test conversions, and support adjustments MUST NOT emit referral rewards or Impact referral conversions unless explicitly marked as eligible by an authorized operator. ### Existing Internal Referral System -114. The existing internal referral-code system MUST NOT grant additional KiloClaw referral rewards for conversions already +116. The existing internal referral-code system MUST NOT grant additional KiloClaw referral rewards for conversions already governed by this spec. -115. Before launch, the existing internal referral system MUST be scoped away from KiloClaw, disabled for KiloClaw, or +117. Before launch, the existing internal referral system MUST be scoped away from KiloClaw, disabled for KiloClaw, or migrated into this program's rules to prevent double rewards. ## Error Handling @@ -489,7 +499,7 @@ interventions, and non-KiloClaw purchases are out of scope. Created source-of-truth rules for the KiloClaw referral program using Impact Advocate. Defined program identifiers, Advocate widget and participant registration requirements, referral-priority attribution over affiliate attribution, exact 30-day UTC expiration semantics, brand-new and previously deleted user boundaries, first-paid monetized KiloClaw -conversion, double-sided free-month rewards, referrer 12-month cap, atomic reward decisions, inactive referrer -disqualification, next-unpaid-renewal reward application, app-owned billing fulfillment, Impact reconciliation behavior, -no Advocate webhook reliance, retryable failure states, tracking-value limits, support-review state, GDPR handling, Impact +conversion, double-sided free-month rewards, referrer 12-month cap, atomic reward decisions, pending rewards for inactive +referrers, next-unpaid-renewal reward application, app-owned billing fulfillment, Impact reconciliation behavior, no +Advocate webhook reliance, retryable failure states, tracking-value limits, support-review state, GDPR handling, Impact identity mapping, and Stripe chargeback reward cancellation. From d6b34ebc52b11d21eedf86466961a70eb645330a Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Fri, 24 Apr 2026 17:59:06 +0200 Subject: [PATCH 04/32] feat(kiloclaw): implement impact referrals --- .plans/impact-refferal-implementation.md | 712 ++++++++ .plans/impact-refferal-verification.md | 447 +++++ .specs/impact-affiliate-tracking.md | 5 + .specs/kiloclaw-billing.md | 7 + .specs/kiloclaw-referrals.md | 113 +- DEVELOPMENT.md | 45 +- apps/web/src/app/(app)/layout.tsx | 2 - apps/web/src/app/(app)/profile/page.tsx | 3 + .../route.test.ts | 160 ++ .../kiloclaw-referral-eligibility/route.ts | 85 + .../dispatch-affiliate-events/route.test.ts | 79 +- .../cron/dispatch-affiliate-events/route.ts | 24 +- .../app/api/impact-advocate/token/route.ts | 64 + .../billing-side-effects/route.test.ts | 60 + .../kiloclaw/billing-side-effects/route.ts | 61 + apps/web/src/app/layout.tsx | 2 + .../web/src/app/users/after-sign-in/route.tsx | 77 +- apps/web/src/components/ImpactIdentify.tsx | 59 +- .../profile/ImpactAdvocateReferralCard.tsx | 108 ++ apps/web/src/db/empty-database.ts | 38 +- apps/web/src/lib/config.server.ts | 5 + apps/web/src/lib/getSignInCallbackUrl.test.ts | 13 + apps/web/src/lib/getSignInCallbackUrl.ts | 16 + apps/web/src/lib/impact-advocate.test.ts | 85 + apps/web/src/lib/impact-advocate.ts | 191 ++ .../web/src/lib/impact-referral-utils.test.ts | 79 + apps/web/src/lib/impact-referral-utils.ts | 165 ++ apps/web/src/lib/impact-referral.test.ts | 255 +++ apps/web/src/lib/impact-referral.ts | 526 ++++++ apps/web/src/lib/kiloclaw-referrals.test.ts | 1189 +++++++++++++ apps/web/src/lib/kiloclaw-referrals.ts | 1562 +++++++++++++++++ apps/web/src/lib/kiloclaw/credit-billing.ts | 39 +- apps/web/src/lib/kiloclaw/stripe-handlers.ts | 35 +- apps/web/src/lib/referral.ts | 15 +- apps/web/src/lib/referrals.test.ts | 58 +- apps/web/src/lib/stripe.ts | 77 +- apps/web/src/lib/user.server.ts | 55 +- apps/web/src/lib/user.test.ts | 148 ++ apps/web/src/lib/user.ts | 141 +- apps/web/src/types/impact.d.ts | 14 + dev/seed/kiloclaw/referrals-cap-boundary.ts | 277 +++ dev/seed/kiloclaw/referrals-happy-path.ts | 317 ++++ .../kiloclaw/referrals-pending-referrer.ts | 281 +++ .../kiloclaw/referrals-support-override.ts | 172 ++ dev/seed/lib/kiloclaw-referrals.ts | 363 ++++ package.json | 4 +- packages/db/src/schema-types.ts | 85 + packages/db/src/schema.test.ts | 17 + packages/db/src/schema.ts | 441 +++++ scripts/verify-drizzle-bootstrap.sh | 54 + .../kiloclaw-billing/src/lifecycle.test.ts | 46 +- services/kiloclaw-billing/src/lifecycle.ts | 61 +- 52 files changed, 8731 insertions(+), 206 deletions(-) create mode 100644 .plans/impact-refferal-implementation.md create mode 100644 .plans/impact-refferal-verification.md create mode 100644 apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.test.ts create mode 100644 apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.ts create mode 100644 apps/web/src/app/api/impact-advocate/token/route.ts create mode 100644 apps/web/src/components/profile/ImpactAdvocateReferralCard.tsx create mode 100644 apps/web/src/lib/impact-advocate.test.ts create mode 100644 apps/web/src/lib/impact-advocate.ts create mode 100644 apps/web/src/lib/impact-referral-utils.test.ts create mode 100644 apps/web/src/lib/impact-referral-utils.ts create mode 100644 apps/web/src/lib/impact-referral.test.ts create mode 100644 apps/web/src/lib/impact-referral.ts create mode 100644 apps/web/src/lib/kiloclaw-referrals.test.ts create mode 100644 apps/web/src/lib/kiloclaw-referrals.ts create mode 100644 dev/seed/kiloclaw/referrals-cap-boundary.ts create mode 100644 dev/seed/kiloclaw/referrals-happy-path.ts create mode 100644 dev/seed/kiloclaw/referrals-pending-referrer.ts create mode 100644 dev/seed/kiloclaw/referrals-support-override.ts create mode 100644 dev/seed/lib/kiloclaw-referrals.ts create mode 100755 scripts/verify-drizzle-bootstrap.sh diff --git a/.plans/impact-refferal-implementation.md b/.plans/impact-refferal-implementation.md new file mode 100644 index 0000000000..555215faea --- /dev/null +++ b/.plans/impact-refferal-implementation.md @@ -0,0 +1,712 @@ +# Impact Advocate Referral Implementation Plan for KiloClaw + +## Scope + +This plan implements the KiloClaw referral program defined in `.specs/kiloclaw-referrals.md`. +That spec is authoritative for business rules, eligibility, attribution, reward semantics, reversals, and GDPR behavior. This document covers implementation shape only. + +Program scope for implementation: + +- Impact Advocate powers referral sharing, participant registration, and Impact-side reporting. +- Kilo owns the authoritative referral touch capture, affiliate/referral attribution resolution, first-paid conversion detection, reward grant idempotency, reward caps, and billing fulfillment. +- The program is double-sided: one free KiloClaw month for the referee and one free KiloClaw month for the referrer when an eligible referee reaches their first confirmed paid personal KiloClaw conversion. +- Referral rewards apply only to personal KiloClaw subscriptions. +- Rewards are fulfilled by delaying the beneficiary's next unpaid KiloClaw renewal boundary by one calendar month per reward. +- Affiliate and referral attribution are resolved together at conversion time under the spec's referral-priority rules, not generic first-touch rules. + +## Executive Recommendation + +Use a hybrid architecture with app-owned state and Impact-owned sharing UX: + +1. Use the Impact Advocate Verified Access widget `p/51699/w/referrerWidget` as the logged-in referral experience. +2. Load the Impact UTT when configured and invoke `identify` on referral-program pages for both anonymous and logged-in users. +3. Capture affiliate and referral touches in a chronological local ledger, preserve them across auth flows, and associate anonymous touches to the created user. +4. On signup with `_saasquatch`, enqueue a server-side Register Participant upsert using the captured `_saasquatch` value as opaque cookie attribution. +5. On the referee's first monetized personal KiloClaw payment period, resolve attribution using the referral-priority model from the spec: + - valid referral wins over valid affiliate, + - unless an affiliate touch had already been sale-attributed before the referral touch, + - otherwise oldest valid referral wins, then oldest valid affiliate, else none. +6. Atomically record both beneficiary reward decisions for a qualified referral conversion, including granted, cap-limited, and disqualified outcomes. +7. Fulfill granted rewards locally by delaying the next unpaid KiloClaw renewal boundary, keeping local billing state and Stripe state consistent. +8. Continue using the existing Impact Performance conversion pipeline, with `Sale (71659)` as the paid conversion event that drives Impact referral conversion reporting. +9. Keep Impact delivery, retries, and reconciliation out of the critical path for billing settlement and user access. + +The hardest implementation area remains reward fulfillment for Stripe-funded and hybrid subscriptions while preserving current KiloClaw billing invariants. + +## Current State + +### Existing Impact Affiliate Integration + +Relevant files: + +- `apps/web/src/lib/impact.ts` +- `apps/web/src/lib/affiliate-events.ts` +- `apps/web/src/lib/affiliate-attribution.ts` +- `apps/web/src/lib/impact-affiliate-utils.ts` +- `apps/web/src/app/layout.tsx` +- `apps/web/src/components/ImpactIdentify.tsx` +- `apps/web/src/app/users/after-sign-in/route.tsx` +- `apps/web/src/lib/user.ts` +- `packages/db/src/schema.ts` + +Current behavior: + +- UTT is globally loaded when `NEXT_PUBLIC_IMPACT_UTT_ID` is configured. +- Authenticated users are identified with `window.ire('identify', ...)` using `customerId` and SHA-1 hashed email. +- Affiliate touches are captured from `im_ref` or `impact_click_id` and stored as `user_affiliate_attributions`. +- Affiliate events are queued in `user_affiliate_events` and dispatched by cron. +- Existing Impact event IDs include `signup`, `trial_start`, `trial_end`, and `sale`. +- KiloClaw already emits affiliate events from trial start, trial end, and sale paths. + +Current gaps relative to the referral spec: + +- Existing affiliate attribution is not a chronological touch ledger suitable for conversion-time shared attribution resolution. +- Current attribution does not model 30-day expiration or referral-priority override. +- The schema cannot represent referral touches, participant registration state, referral relationships, reward decisions, reward states, or reward-application audit data. +- Current KiloClaw affiliate sale reporting exists, but referral rewards must be first-paid-conversion-only. +- Current flows do not record whether an affiliate touch has already been sale-attributed for later renewal protection. + +### Existing KiloClaw Billing Hooks + +Relevant files: + +- `apps/web/src/routers/kiloclaw-router.ts` +- `apps/web/src/lib/kiloclaw/credit-billing.ts` +- `apps/web/src/lib/kiloclaw/stripe-handlers.ts` +- `apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts` +- `services/kiloclaw-billing/*` +- `.specs/kiloclaw-billing.md` + +Current useful hooks: + +- Trial creation and trial-to-paid conversion paths already exist. +- Stripe invoice settlement is handled centrally. +- Pure-credit and hybrid billing paths already produce billing side effects. +- `services/kiloclaw-billing` calls the web app internal side-effect route instead of contacting Impact directly. + +Referral implementation should reuse these billing hooks to detect the referee's first confirmed paid personal KiloClaw conversion and to apply renewal-boundary extensions idempotently. + +## Authoritative Rules From the Spec + +These rules are already decided and should not be reopened in implementation: + +- Program constants: + - Impact Account `7138521` + - Performance CampaignId `50754` + - Advocate ProgramId `51699` + - UTT UUID `A7138521-9724-4b8f-95f4-1db2fbae81141` + - Widget `p/51699/w/referrerWidget` +- Verified Access identity contract: + - `id = Kilo user ID` + - `accountId = Kilo user ID` + - `email = plain user email` +- Register Participant requests are server-side and use Kilo user ID for `id` and `accountId`, with plain-text email only as the contact email. +- Referral touch validity requires non-empty `_saasquatch` and expires exactly 30 days after touch time using server UTC. +- Affiliate and referral attribution are resolved together at first paid conversion time. +- Referral has priority over affiliate unless the affiliate touch had already been sale-attributed before the referral touch occurred. +- The first paid conversion is the referee's first confirmed paid personal KiloClaw subscription payment period, including Stripe-settled, hybrid-settled, or pure-credit-funded periods. +- Trial start, trial end, signup, zero-dollar invoices, fully comped periods, admin adjustments, and non-KiloClaw purchases are not qualifying conversions. +- Referrer rewards cap at 12 total free months; referee rewards do not count toward that cap. +- Reward states must support `pending`, `earned`, `applied`, `reversed`, `expired`, `canceled`, and `review_required`. +- Reward fulfillment is app-owned and delays the next unpaid renewal boundary by one calendar month per reward. +- Impact webhooks are not a source of truth and must not be required for eligibility, granting, billing fulfillment, or reconciliation. +- Existing internal referral-code logic must not double-reward KiloClaw conversions governed by this program. + +## Impact Advocate Findings + +### Confirmed Program Values + +Impact's technical notes and the spec align on these values: + +- Account: `7138521` +- Performance CampaignId: `50754` +- Advocate ProgramId: `51699` +- UTT script: `https://utt.impactcdn.com/A7138521-9724-4b8f-95f4-1db2fbae81141.js` +- Advocate widget: `p/51699/w/referrerWidget` +- Domain: `kilo.ai` + +Performance action tracker IDs: + +| Event | ActionTrackerId | Trigger | +| ----------- | --------------- | ------------------------------------------- | +| VISIT | `71668` | Visitor lands on `kilo.ai` with `im_ref` | +| SIGNUP | `71655` | New user creation with attribution | +| TRIAL_START | `71656` | KiloClaw trial subscription becomes active | +| TRIAL_END | `71658` | KiloClaw trial subscription ends | +| SALE | `71659` | Monetized KiloClaw payment period is funded | + +### UTT, Identify, and Opaque Tracking Values + +Implementation requirements: + +- Load the UTT only when the public UTT identifier is configured. +- Invoke `identify` on pages used by the referral program. +- Anonymous `identify` calls must pass empty strings for unknown `customerId` and `customerEmail`. +- Logged-in `identify` calls must pass stable customer ID and SHA-1 hashed email. +- `identify` calls must include a stable `customProfileId` derived from the Kilo user ID for logged-in users and a stable first-party anonymous ID for anonymous users. +- Treat `_saasquatch`, `rsCode`, `rsShareMedium`, `rsEngagementMedium`, `im_ref`, and related tracking values as opaque. +- Document and enforce a maximum accepted length for opaque tracking values; values above that limit are stored only as diagnostics or ignored for attribution, and logs must redact or truncate them. + +### Advocate Widget + +Use the Verified Access widget as the launch path. + +Implementation contract: + +- Server issues a short-lived JWT. +- Client sets `window.impactToken`. +- UI renders: + +```html + +
Loading...
+
+``` + +JWT/user payload should include the Impact-required fields where available, but the identity mapping must follow the spec: Kilo user ID for `id` and `accountId`, plain email only in `email`. + +### Referred Participant Registration + +When signup occurs with `_saasquatch` attribution: + +- Associate the referral touch to the user. +- Enqueue server-side Register Participant delivery before signup is considered complete. +- External Impact delivery must not block user access. +- Pass the exact `_saasquatch` value as opaque `cookies` attribution. +- Include locale and country code when available. +- Keep failures retryable unless configuration or payload is permanently invalid. + +### Conversion Reporting + +Implementation contract: + +- Continue using the existing Performance Conversions API integration. +- Use `Sale (71659)` as the paid conversion event for first paid periods and renewals. +- Do not add client-side `trackConversion` for referrals while server-side Performance conversion is the configured mechanism. +- Use deterministic order identifiers where possible. +- Impact delivery failure must not block billing settlement, local reward decisions, or user access. +- If a referral wins attribution, ensure the first qualifying paid conversion is still reported to Impact through the existing server-side pipeline. + +## Product Rules To Encode + +### Eligibility + +Referee eligibility: + +- Must be a brand-new Kilo account. +- Existing users and previously deleted users are disqualified. +- Disqualification for previously deleted users must use the legal-approved normalized-email hash tombstone. +- Must convert on a personal KiloClaw subscription. +- Must reach a first confirmed paid monetized KiloClaw payment period. +- Trial start, trial end, signup, zero-dollar invoices, comped periods, admin adjustments, and later renewals do not qualify. +- Self-referrals are disqualified. +- Fraudulent, test, admin-created, or manually adjusted subscriptions do not qualify unless explicitly overridden through an authorized support process. + +Referrer eligibility: + +- Must be a Kilo user who is registered or registerable as an Advocate participant. +- Current subscription state does not block reward earning. +- If there is no active eligible personal KiloClaw subscription when the reward is earned, keep the reward pending until the referrer starts or reactivates an eligible paid personal KiloClaw subscription. +- If that never happens, cancel/expire the pending inactive-referrer reward 12 months after it was earned. +- Referrer rewards do not apply to trials; they apply to the next unpaid renewal boundary after paid activation/reactivation. +- Referrer can receive at most 12 total free months. + +Reward rules: + +- Qualified referral conversion grants one free-month reward to the referee. +- Qualified referral conversion grants one free-month reward to the referrer unless cap-limited or otherwise disqualified. +- Both beneficiary outcomes must be recorded atomically. +- Fulfillment is not complete until required KiloClaw billing state, and any needed Stripe state, are updated successfully. + +### Attribution + +The implementation must follow the referral-priority model from the spec, not generic first-touch attribution. + +Rules to encode: + +- Referral and affiliate share the same 30-day conversion-time window. +- Attribution is resolved at first paid KiloClaw conversion time. +- A valid referral touch wins over a valid affiliate touch unless the affiliate touch had already been sale-attributed before the referral touch. +- If multiple valid referral touches exist and no preserved sale-attributed affiliate touch blocks them, the oldest valid referral touch wins. +- If no valid referral touch exists, the oldest valid affiliate touch wins. +- If all touches are invalid or expired, no attribution wins. +- If affiliate wins, no referral rewards are granted. +- If referral wins, that first paid conversion must not generate affiliate payout attribution. +- The system must record when an affiliate touch becomes sale-attributed so later renewals can preserve affiliate attribution where required. + +Required scenario tests: + +| Scenario | Expected winner | +| ---------------------------------------------------------------------------- | --------------- | +| Affiliate first, referral second, both valid, no prior affiliate SALE | Referral | +| Affiliate first, referral second, both valid, affiliate SALE before referral | Affiliate | +| Referral first, affiliate second, both valid, no prior affiliate SALE | Referral | +| Only affiliate valid | Affiliate | +| Only referral valid | Referral | +| All touches expired or invalid | None | + +## Data Model Plan + +Add new referral-specific tables rather than overloading current affiliate tables. + +### 1. Attribution Touch Ledger + +Add a table such as `kiloclaw_attribution_touches` with fields along these lines: + +- `id` +- `anonymous_id` nullable +- `user_id` nullable until association +- `touch_type` (`affiliate` | `referral`) +- `provider` (`impact_performance` | `impact_advocate`) +- `opaque_tracking_value` +- `tracking_value_truncated` or length metadata if needed +- referral metadata fields when present: + - `rs_code` + - `rs_share_medium` + - `rs_engagement_medium` +- affiliate metadata fields when present: + - `im_ref` +- shared sanitized metadata: + - `utm_*` + - landing path +- `touched_at` +- `expires_at` +- `sale_attributed_at` nullable for affiliate touches +- `created_at` + +This is the source for KiloClaw conversion-time attribution resolution. + +### 2. Participant Registration and Referral Relationship State + +Add local tables for: + +- Advocate participant registration/upsert attempts and retry state +- local referral relationships between referrer and referee when known +- Impact-facing identifiers and statuses used only for support/reporting +- conversion reporting attempts and retry state + +Suggested separation: + +- `impact_advocate_participants` +- `impact_advocate_registration_attempts` +- `kiloclaw_referrals` +- `impact_conversion_reports` + +Keep Impact-facing fields clearly non-authoritative. + +### 3. Conversion Decision Ledger + +Add a conversion-level table to represent the result of evaluating a candidate first paid conversion, for example `kiloclaw_referral_conversions`: + +- `id` +- `referee_user_id` +- `referrer_user_id` nullable +- `source_touch_id` nullable +- `winning_touch_type` (`referral` | `affiliate` | `none`) +- `source_payment_id` / invoice / billing-period identity +- `qualified` boolean +- disqualification reason nullable +- `converted_at` +- `created_at` + +This lets the system atomically record the conversion evaluation even when no reward is granted. + +### 4. Beneficiary Decision Ledger + +Add a table such as `kiloclaw_referral_reward_decisions` to record both beneficiary outcomes atomically: + +- `id` +- `conversion_id` +- `beneficiary_user_id` +- `beneficiary_role` (`referrer` | `referee`) +- `outcome` (`granted` | `cap_limited` | `disqualified`) +- `reason` nullable +- `months_granted` +- unique key on `conversion_id + beneficiary_role` + +### 5. Reward Ledger + +Add a table such as `kiloclaw_referral_rewards` for granted rewards only: + +- `id` +- `conversion_id` +- `decision_id` +- `beneficiary_user_id` +- `beneficiary_role` +- `months_granted` +- `status` (`pending` | `earned` | `applied` | `reversed` | `expired` | `canceled` | `review_required`) +- `applies_to_subscription_id` nullable +- `earned_at` +- `applied_at` nullable +- `reversed_at` nullable +- `expires_at` nullable +- `review_reason` nullable +- unique key on conversion + beneficiary role + +### 6. Reward Application Audit + +Add a table such as `kiloclaw_referral_reward_applications`: + +- `id` +- `reward_id` +- `beneficiary_user_id` +- `subscription_id` +- previous renewal / period boundary +- new renewal / period boundary +- local billing operation identifiers +- Stripe identifiers / idempotency keys where applicable +- `applied_at` + +## Billing Design + +The free month is a renewal-boundary extension, not an account credit. + +### General Rules + +- Each reward delays the next unpaid renewal boundary by exactly one calendar month. +- Rewards must not modify finalized invoices or already-funded periods. +- Rewards apply only to KiloClaw billing, not inference usage, Kilo Pass, team plans, or non-KiloClaw purchases. +- Multiple rewards may stack. +- Reward application must be idempotent and auditable. +- If the beneficiary is canceled or canceling before application, keep the reward pending until they again have an active eligible personal KiloClaw subscription. + +### Month-to-Month + +- One reward delays the next monthly renewal by one calendar month. +- Stacking delays by one calendar month per reward. + +### Six-Month Commitment + +- One reward delays the next six-month renewal by one calendar month. +- Rewards do not change commitment shape and do not prorate the next invoice. + +### Pure-Credit KiloClaw + +- Update local renewal state so the credit-renewal sweep does not deduct hosting credits until the extended renewal time. +- Keep this entirely in local billing state. + +### Stripe-Funded or Hybrid KiloClaw + +- Reward application must keep local billing state and Stripe billing state consistent. +- Do not allow a local-only renewal delay while Stripe still charges on the original schedule. +- Use deterministic idempotency keys for Stripe operations. +- Design choice for the Stripe mechanism remains an implementation task, but the outcome is fixed by the spec: one calendar-month delay at the next unpaid renewal boundary without breaking current billing invariants. + +## Attribution and Conversion Flow + +### Landing / Touch Capture + +1. Visitor lands from an affiliate or referral link. +2. Capture the touch with `touched_at` and `expires_at = touched_at + 30 days`. +3. Preserve the touch across auth redirects and callback URLs. +4. Associate anonymous touches to the user during signup or first authenticated request after signup. +5. Treat tracking identifiers as opaque; enforce max length and redact logs. +6. Do not grant anything at capture time. + +### Signup + +1. Create the Kilo user. +2. Associate captured touches with the user. +3. If `_saasquatch` is present, enqueue Register Participant delivery before signup completes. +4. Persist registration retry state. +5. Do not block user access on external Impact delivery. +6. Do not grant free months at signup. + +### First Paid KiloClaw Conversion + +1. Detect the referee's first confirmed paid personal KiloClaw payment period. +2. Verify referee eligibility, including brand-new-account checks and previously deleted-user disqualification. +3. Resolve attribution using the referral-priority model. +4. If affiliate wins: + - record the affiliate touch as sale-attributed for future protection, + - emit existing affiliate Performance conversion behavior, + - do not grant referral rewards. +5. If referral wins: + - ensure the qualifying `Sale (71659)` conversion is reported through the existing server-side Performance pipeline, + - create the local conversion record, + - atomically record both beneficiary outcomes, + - create reward ledger rows for granted outcomes, + - leave reward application to the next unpaid renewal boundary. +6. If no touch wins: + - record the evaluation result, + - do not grant referral rewards, + - do not create affiliate payout attribution. + +### Reward Application + +1. A billing job or side-effect handler processes earned/pending rewards. +2. When the beneficiary has an eligible unpaid renewal boundary, extend that boundary by one calendar month. +3. Update reward status and write audit rows. +4. Keep retryable failures pending unless they are permanent and auditable. + +### Refunds, Chargebacks, and Fraud + +1. If the qualifying Stripe payment is charged back, cancel pending or earned-but-unapplied rewards. +2. If a qualifying payment is refunded or fraud-marked before application, cancel the unapplied rewards. +3. If a reward was already applied, move it to `review_required` instead of automatically clawing it back. +4. Reverse Impact actions with Impact's reverse-action mechanism when needed. +5. Make reversal handling idempotent. + +## Impact Integration Details + +### Environment Variables + +Likely required env vars: + +- `IMPACT_ADVOCATE_TENANT_ALIAS` +- `IMPACT_ADVOCATE_PROGRAM_ID=51699` +- `IMPACT_ADVOCATE_ACCOUNT_SID` +- `IMPACT_ADVOCATE_AUTH_TOKEN` +- `IMPACT_ADVOCATE_WIDGET_ID=p/51699/w/referrerWidget` +- `NEXT_PUBLIC_IMPACT_UTT_ID=A7138521-9724-4b8f-95f4-1db2fbae81141` + +Existing Performance values remain in use: + +- `IMPACT_CAMPAIGN_ID=50754` +- `IMPACT_ACTION_TRACKER_*` for `71655`, `71656`, `71658`, and `71659` + +If reward-bearing referral configuration is absent in an environment where the referral program is enabled, fail closed for reward issuance and log the configuration failure. + +### Server-Only Advocate Client + +Add a server-only module such as `apps/web/src/lib/impact-advocate.ts`. + +Responsibilities: + +- build Register Participant requests +- sign Verified Access JWTs +- manage retryable registration state +- optionally fetch support/reconciliation data from Impact APIs +- reverse Impact actions when required +- redact sensitive data in logs + +### Verified Access JWT Issuing + +Add a server route or tRPC procedure to issue short-lived widget JWTs. + +Requirements: + +- include Account SID as `kid` header +- sign with server-side credentials only +- set the `user` payload using the spec's identity contract +- do not let the client alter the identity payload + +### Reconciliation + +Do not make webhooks part of the core design. + +Instead: + +- keep local state authoritative +- use dashboard exports or Impact API reads for manual reconciliation and support investigation +- optionally store Impact-facing status fields only for comparison and support +- never let Impact-facing status override local eligibility, cap, attribution, or billing fulfillment rules + +## Spec Alignment Work + +`.specs/kiloclaw-referrals.md` already exists and is authoritative. + +Implementation follow-up should update sibling specs only where cross-domain behavior now needs explicit references: + +- `.specs/impact-affiliate-tracking.md` + - document that KiloClaw referral-program conversions use the referral-priority override from the referral spec +- `.specs/kiloclaw-billing.md` + - document the billing-extension fulfillment behavior and any new invariants needed for reward application + +Do not restate or redefine referral business rules outside the referral spec. + +## GDPR and PII + +Any new tables storing user IDs, emails, referral relationships, IPs, cookies, or Impact identifiers must be included in GDPR deletion/anonymization flows. + +Required code updates: + +- `apps/web/src/lib/user.ts` +- `apps/web/src/lib/user.test.ts` + +Implementation requirements: + +- anonymize or delete Advocate participant records, touches, referral relationships, reconciliation payloads containing PII, and reward records as required by policy +- delete or anonymize plain email retained for Advocate compatibility +- retain only the legal-approved non-PII tombstone / irreversible hash needed for previously deleted-user disqualification +- never log referral tracking values or sensitive headers in raw form + +## Operational Considerations + +### Configuration Safety + +- Add explicit checks for missing Advocate credentials and required reward-bearing configuration. +- Do not silently mark registration, conversion delivery, or reward application as complete when configuration is missing. +- Expose operator-visible retryable versus terminal failure states. + +### Observability + +Track at minimum: + +- referral touch captured +- affiliate touch captured +- touch associated to user +- participant registration enqueued / succeeded / retrying / terminal failure +- attribution winner at conversion time +- conversion report queued / delivered / failed +- reward decision recorded +- referrer cap limited +- reward applied +- reward canceled +- reward moved to `review_required` + +### Internal Referral System Isolation + +Before launch, ensure the existing internal referral-code system cannot grant additional KiloClaw rewards for conversions governed by this program. + +## Implementation Phases + +### Phase 0 - Confirm External Contract and Launch Inputs + +- Confirm tenant alias, credentials, Verified Access setup, and required dashboard configuration with Impact. +- Confirm the launch uses widget `p/51699/w/referrerWidget`. +- Confirm manual reconciliation path via exports/API reads. +- Confirm launch feature flag and environment gating. +- Confirm operator process for explicit support overrides on otherwise ineligible conversions. + +### Phase 1 - Schema and Spec Cross-References + +- Add referral tables for touches, participant registration state, referrals, conversion decisions, beneficiary decisions, rewards, and reward application audit. +- Update sibling specs to reference the referral spec where needed. +- Generate migrations with `pnpm drizzle generate`. +- Update GDPR deletion flow and tests. + +### Phase 2 - Touch Capture and Identity + +- Capture referral and affiliate touches with exact 30-day expiry. +- Preserve touches across auth flows. +- Associate anonymous touches to users on signup / first authenticated request. +- Update `ImpactIdentify` behavior for anonymous empty strings, logged-in SHA-1 email, and stable `customProfileId`. +- Add max-length enforcement and log redaction for opaque values. + +### Phase 3 - Advocate Widget and Participant Registration + +- Add the server-only Advocate client. +- Add Verified Access JWT issuance. +- Add the referral UI entry point that renders the widget. +- Add Register Participant enqueueing and retry handling for `_saasquatch` signups. +- Add tests for JWT payload/header and registration payload construction. + +### Phase 4 - Conversion-Time Attribution and Reward Decisions + +- Implement first paid personal KiloClaw conversion detection. +- Implement the referral-priority attribution resolver. +- Record sale-attributed affiliate touches. +- Atomically persist the conversion record and both beneficiary decisions. +- Queue or dispatch the corresponding Impact Performance conversion. +- Add tests for all required attribution scenarios and ineligibility paths. + +### Phase 5 - Billing Fulfillment + +- Implement the reward ledger state machine. +- Apply rewards to the next unpaid renewal boundary. +- Handle inactive/canceling beneficiaries by keeping rewards pending. +- Enforce the 12-month referrer cap atomically. +- Implement month-to-month, six-month, pure-credit, Stripe-funded, and hybrid fulfillment paths. +- Add audit trails and idempotency protections. + +### Phase 6 - Reversals and Support Workflows + +- Implement chargeback/refund/fraud handling. +- Move already-applied rewards to `review_required`. +- Add operator-visible support state and reason capture. +- Implement Impact reverse-action support where needed. + +### Phase 7 - Verification and Launch + +- Run `pnpm typecheck` at minimum. +- Run targeted tests for referral, affiliate, billing, and GDPR changes. +- Run `pnpm format`. +- Run `pnpm validate` before launch if the scope warrants it. +- Execute the Impact E2E checklist: + - load widget, + - copy share link, + - open share link in incognito, + - verify `_saasquatch` capture, + - sign up referee, + - confirm participant registration, + - complete first paid personal KiloClaw conversion, + - confirm local conversion decision, + - confirm local reward decisions and billing application, + - confirm Impact reporting landed. + +## Test Plan + +Add tests for: + +- referral parameter capture with `_saasquatch` treated as opaque +- affiliate parameter capture with 30-day expiry +- cross-auth touch preservation +- anonymous-to-user touch association +- `ImpactIdentify` anonymous empty-string behavior +- `ImpactIdentify` logged-in SHA-1 email behavior +- stable anonymous and logged-in `customProfileId` +- Verified Access JWT payload/header generation +- Register Participant payload and retry behavior +- conversion-time attribution resolver +- the six required attribution scenarios from the spec +- brand-new referee eligibility +- previously deleted-user disqualification via tombstone hash +- self-referral disqualification +- personal subscription only +- first paid monetized conversion only +- no reward on trial start, signup, comped periods, or renewals +- atomic dual-beneficiary decision recording +- referrer 12-month cap enforcement under concurrency +- month-to-month reward application +- six-month commitment reward application +- pure-credit reward application +- Stripe-funded / hybrid reward application +- cancellation / pending reward behavior +- chargeback / refund / fraud handling +- `review_required` transitions for already-applied rewards +- GDPR deletion / anonymization of referral data +- internal referral system isolation for KiloClaw conversions + +Regression tests: + +- existing affiliate dispatch flows +- existing KiloClaw trial/start/sale behavior +- existing credit billing lifecycle +- existing Stripe handlers + +## Open Implementation Questions + +These are implementation questions only; business rules remain fixed by the spec. + +- What exact maximum length should be enforced for opaque tracking values? +- Which existing user/session identifier is the best source for the stable anonymous `customProfileId`? +- What is the safest Stripe mechanism for delaying the next unpaid renewal boundary for Stripe-funded and hybrid subscriptions? +- Which worker/job boundary should own reward application retries versus conversion-report retries? +- Do we want a dedicated conversion-decision table plus decision table, or can the same atomic guarantees be achieved cleanly with a narrower schema? +- Which admin surface should expose retryable registration/reporting failures and `review_required` rewards? + +## Rollout Plan + +1. Ship behind a feature flag. +2. Enable in staging/test Impact environment first. +3. Run end-to-end referral and affiliate-vs-referral attribution tests. +4. Enable for internal users. +5. Enable for a small production cohort. +6. Monitor attribution outcomes, reward decision counts, reward application correctness, retry queues, and support volume. +7. Roll out broadly once local state and Impact reporting reconcile cleanly. + +## Final Notes + +The implementation should keep local state authoritative at every critical decision point: + +- touch capture +- user association +- conversion-time attribution +- referee/referrer eligibility +- cap enforcement +- reward decision recording +- billing fulfillment +- reversal handling + +Impact Advocate remains a valuable integration for sharing UX, participant registration, and reporting, but it should not own the product logic or billing effects governed by `.specs/kiloclaw-referrals.md`. diff --git a/.plans/impact-refferal-verification.md b/.plans/impact-refferal-verification.md new file mode 100644 index 0000000000..1680754df0 --- /dev/null +++ b/.plans/impact-refferal-verification.md @@ -0,0 +1,447 @@ +# Referral happy path — human verification script + +## Goal + +Prove that: + +1. an eligible **referrer** can get a referral link, +2. a brand-new **referee** signs up through that link, +3. the referral touch is recorded, +4. the referee’s **first paid personal KiloClaw conversion** is attributed to the referral, +5. both referral rewards are granted, +6. both rewards are applied in the happy path. + +> Use this in staging or any environment with Impact Advocate configured. +> A fully local end-to-end happy path is not sufficient right now because local verification showed `/api/impact-advocate/token` returning `503` when Advocate is unconfigured. + +## Preconditions + +Before starting, confirm all of these are true: + +- Environment has: + - Impact Advocate configured + - Impact conversion reporting configured + - test payments available +- You have read-only DB access for verification +- You can log in as two different test users +- The **referrer already has an active eligible personal KiloClaw subscription** + - this matters so the referrer reward can be **applied immediately** instead of staying `pending` + +## Running locally via ngrok + +If you are testing against a locally running app instead of staging, use an HTTPS ngrok URL rather than `http://localhost:3000`, because the Impact Advocate widget may require an allowlisted non-localhost origin. + +Basic setup: + +1. Start the app locally: + +```bash +pnpm dev:start +``` + +2. Start ngrok in a separate terminal: + +```bash +ngrok http 3000 +``` + +3. Copy the HTTPS forwarding URL from ngrok, then set it as the app base URL and restart the app: + +```bash +export APP_URL_OVERRIDE=https://.ngrok-free.app +pnpm dev:stop +pnpm dev:start +``` + +4. Open the site through the ngrok URL, not localhost. + +Notes: + +- Ask the Impact / SaaSquatch admin to allowlist the exact ngrok origin if the referral widget is blocked by CORS. +- If the ngrok hostname changes, update `APP_URL_OVERRIDE`, restart the app, and re-allowlist the new origin if needed. +- For payment verification, keep the entire flow on the same ngrok origin so auth and Stripe redirects stay consistent. + +## Test accounts + +Use two fresh accounts: + +- **Referrer**: `qa-referrer-@example.com` +- **Referee**: `qa-referee-@example.com` + +Use unique emails each run. + +## Step 1: Prepare the referrer + +1. Sign in as the **referrer** +2. Go to **Profile** +3. Confirm the **Referral Program** section is visible +4. Open the referral widget / referral sharing UI +5. Copy the generated referral link + +### Expected + +- The widget loads successfully +- No error banner is shown +- You can copy a referral link +- The link contains referral params, typically including: + - `_saasquatch` + - `rsCode` + - optionally medium params like: + - `rsShareMedium` + - `rsEngagementMedium` + +### Capture + +- Screenshot of Profile page with Referral Program visible +- Screenshot of the widget or copied link UI +- The copied referral URL + +## Step 2: Sign up the referee through the referral link + +1. Open a fresh incognito/private window +2. Paste the copied referral link +3. Complete signup as the **referee** +4. Complete any required onboarding +5. Land in the signed-in app + +### Expected + +- Signup succeeds normally +- Referral params survive auth/onboarding redirects +- The user reaches the app successfully +- No auth-flow breakage + +### Capture + +- Final app URL after signup +- Screenshot of successful logged-in state + +## Step 3: Verify referral touch capture in the DB + +Look up both users: + +```sql +select id, google_user_email, created_at +from kilocode_users +where google_user_email in ( + 'qa-referrer-@example.com', + 'qa-referee-@example.com' +) +order by google_user_email; +``` + +Save: + +- `` +- `` + +Now verify the referee touch: + +```sql +select + id, + user_id, + touch_type, + provider, + rs_code, + rs_share_medium, + rs_engagement_medium, + touched_at, + expires_at +from kiloclaw_attribution_touches +where user_id = '' +order by touched_at desc; +``` + +### Expected + +At least one row exists with: + +- `touch_type = 'referral'` +- `provider = 'impact_advocate'` +- `rs_code` populated +- `expires_at` populated +- the touch is attached to the referee user + +Optional relationship check: + +```sql +select + referee_user_id, + referrer_user_id, + source_touch_id, + created_at +from kiloclaw_referrals +where referee_user_id = ''; +``` + +### Expected + +- One row linking referee to referrer + +## Step 4: Verify referrer participant exists + +```sql +select + user_id, + advocate_id, + advocate_account_id, + opaque_referral_identifier, + registration_state, + registered_at, + last_error_code +from impact_advocate_participants +where user_id = ''; +``` + +### Expected + +- row exists +- `registration_state = 'registered'` +- `opaque_referral_identifier` is populated + +## Step 5: Purchase the referee’s first paid personal KiloClaw subscription + +1. Stay signed in as the **referee** +2. Go through the normal personal KiloClaw purchase flow +3. Complete the first real/test payment +4. Wait for billing side effects / webhook processing to complete + +### Expected + +- Purchase succeeds +- This is the referee’s **first monetized personal** KiloClaw paid period +- No support override is needed +- No affiliate flow should win over the referral in this happy path + +### Capture + +- Screenshot of successful purchase / active subscription UI +- Any order/payment ID shown in the UI or logs + +## Step 6: Verify the referral conversion in the DB + +```sql +select + id, + referee_user_id, + referrer_user_id, + winning_touch_type, + qualified, + disqualification_reason, + source_payment_id, + converted_at, + created_at +from kiloclaw_referral_conversions +where referee_user_id = '' +order by created_at desc +limit 1; +``` + +Save ``. + +### Expected + +- row exists +- `winning_touch_type = 'referral'` +- `qualified = true` +- `disqualification_reason is null` + +## Step 7: Verify both beneficiary decisions were granted + +```sql +select + beneficiary_role, + outcome, + reason, + months_granted +from kiloclaw_referral_reward_decisions +where conversion_id = '' +order by beneficiary_role; +``` + +### Expected + +Exactly two rows: + +- `referee` -> `outcome = 'granted'` +- `referrer` -> `outcome = 'granted'` + +And: + +- `months_granted = 1` for both +- `reason is null` + +## Step 8: Verify both rewards were created and applied + +```sql +select + id, + beneficiary_user_id, + beneficiary_role, + status, + months_granted, + applied_at, + expires_at +from kiloclaw_referral_rewards +where conversion_id = '' +order by beneficiary_role; +``` + +### Expected + +Exactly two rows: + +- one for the referee +- one for the referrer + +And in the happy path: + +- both have `status = 'applied'` +- both have `months_granted = 1` +- both have `applied_at` populated + +Now verify reward application records: + +```sql +select + reward_id, + subscription_id, + applied_at, + created_at +from kiloclaw_referral_reward_applications +where reward_id in ( + select id + from kiloclaw_referral_rewards + where conversion_id = '' +) +order by created_at; +``` + +### Expected + +- application rows exist for both rewards + +## Step 9: Verify billing moved the renewal boundary forward + +Referee subscription: + +```sql +select + id, + user_id, + status, + plan, + current_period_end, + credit_renewal_at +from kiloclaw_subscriptions +where user_id = '' +order by created_at desc +limit 1; +``` + +Referrer subscription: + +```sql +select + id, + user_id, + status, + plan, + current_period_end, + credit_renewal_at +from kiloclaw_subscriptions +where user_id = '' +order by created_at desc +limit 1; +``` + +### Expected + +- both subscriptions are eligible personal subscriptions +- the next unpaid renewal boundary is delayed by roughly **1 month** +- in practice, you should see `current_period_end` and/or `credit_renewal_at` advanced compared with the pre-reward state + +Optional log check: + +```sql +select + subscription_id, + action, + reason, + created_at +from kiloclaw_subscription_change_log +where reason = 'referral_reward:applied' + and subscription_id in ( + select id from kiloclaw_subscriptions + where user_id in ('', '') + ) +order by created_at desc; +``` + +### Expected + +- entries exist showing reward application side effects + +## Step 10: Verify Impact conversion reporting succeeded + +```sql +select + conversion_id, + state, + response_status_code, + delivered_at, + error +from impact_conversion_reports +where conversion_id = ''; +``` + +### Expected + +- row exists +- `state = 'delivered'` +- `error is null` + +## UI sanity check after reward application + +1. Refresh the **referee** billing/subscription UI +2. Refresh the **referrer** billing/subscription UI + +### Expected + +- both accounts still load normally +- no broken billing state +- next renewal/billing date reflects the added month, if surfaced in UI + +## Pass criteria + +The happy path passes if all of the following are true: + +- referrer can open referral sharing UI and copy a link +- referee can sign up via that link successfully +- referee has a stored referral touch +- conversion row exists for the referee’s first paid personal conversion +- conversion is: + - `winning_touch_type = referral` + - `qualified = true` +- two decision rows exist and both are `granted` +- two reward rows exist and both are `applied` +- reward application rows exist +- billing renewal boundary moved forward by 1 month +- Impact conversion report is `delivered` + +## Fail examples + +Treat the run as failed if any of these happen: + +- referral widget does not load +- signup loses referral attribution through redirects +- no `impact_advocate` referral touch is stored +- conversion is recorded with: + - `winning_touch_type = affiliate` + - `winning_touch_type = none` + - `qualified = false` +- either beneficiary decision is not `granted` +- either reward stays `pending` in this happy-path setup +- no reward application rows are created +- Impact report is `failed` or `retrying` diff --git a/.specs/impact-affiliate-tracking.md b/.specs/impact-affiliate-tracking.md index b805793613..75080de756 100644 --- a/.specs/impact-affiliate-tracking.md +++ b/.specs/impact-affiliate-tracking.md @@ -57,6 +57,11 @@ prevention. This integration applies only to personal KiloClaw subscriptions. Organization-scoped KiloClaw instances are not eligible for affiliate tracking. +For KiloClaw conversions that are also governed by `.specs/kiloclaw-referrals.md`, that referral spec's +conversion-time referral-priority rules override this document's default first-touch affiliate behavior for the initial +paid conversion decision. This document remains authoritative for Impact Performance event shapes, delivery sequencing, +and affiliate renewal reporting after the winning attribution has been established. + ## Rules ### Affiliate Attribution diff --git a/.specs/kiloclaw-billing.md b/.specs/kiloclaw-billing.md index 400338a073..88d98c2389 100644 --- a/.specs/kiloclaw-billing.md +++ b/.specs/kiloclaw-billing.md @@ -11,6 +11,13 @@ layouts, conflict-resolution strategies, null-safety patterns, and other implementation choices belong in plan documents and code, not here. +When `.specs/kiloclaw-referrals.md` grants a KiloClaw free-month +reward, billing fulfillment is still governed by this document's core +subscription invariants. Referral rewards delay the beneficiary's next +unpaid KiloClaw renewal boundary by one calendar month and MUST NOT +break Stripe-funded, hybrid, pure-credit, commit-plan, cancellation, +or reactivation guarantees defined here. + ## Status Draft -- generated from branch `jdp/kiloclaw-billing` on 2026-03-13. diff --git a/.specs/kiloclaw-referrals.md b/.specs/kiloclaw-referrals.md index f2d73bdeb7..b323e120d6 100644 --- a/.specs/kiloclaw-referrals.md +++ b/.specs/kiloclaw-referrals.md @@ -298,172 +298,175 @@ interventions, and non-KiloClaw purchases are out of scope. reward pending so it can be applied when the referrer starts or reactivates an eligible personal KiloClaw subscription. -64. A pending referrer reward MUST NOT apply to a KiloClaw trial. It MUST apply to the next unpaid renewal boundary after +64. A pending inactive-referrer reward MUST expire and be canceled 12 months after it is earned if the referrer has not + started or reactivated an eligible paid personal KiloClaw subscription. + +65. A pending referrer reward MUST NOT apply to a KiloClaw trial. It MUST apply to the next unpaid renewal boundary after the referrer starts or reactivates a paid personal KiloClaw subscription. -65. A referrer MUST NOT receive more than 12 total free-month rewards from the referral program. +66. A referrer MUST NOT receive more than 12 total free-month rewards from the referral program. -66. The referrer cap MUST be enforced before granting a referrer reward. +67. The referrer cap MUST be enforced before granting a referrer reward. -67. The 12-month referrer cap MUST be enforced atomically across concurrent reward grants. Concurrent processing MUST NOT +68. The 12-month referrer cap MUST be enforced atomically across concurrent reward grants. Concurrent processing MUST NOT produce more than 12 granted referrer reward months. -68. When a qualified referral occurs after the referrer has reached the 12-month cap, the system MUST record that the +69. When a qualified referral occurs after the referrer has reached the 12-month cap, the system MUST record that the referrer reward was cap-limited and MUST NOT grant another referrer free month. -69. Referee rewards MUST NOT count against the referrer's 12-month cap. +70. Referee rewards MUST NOT count against the referrer's 12-month cap. ### Reward Granting -70. A qualified referral conversion MUST grant one free-month reward to the referee. +71. A qualified referral conversion MUST grant one free-month reward to the referee. -71. A qualified referral conversion MUST grant one free-month reward to the referrer. The reward MUST be marked +72. A qualified referral conversion MUST grant one free-month reward to the referrer. The reward MUST be marked cap-limited instead of granted when the referrer cap has been reached or another referrer eligibility rule prevents it. -72. Referral reward granting MUST be idempotent. Processing the same qualifying conversion multiple times MUST NOT create +73. Referral reward granting MUST be idempotent. Processing the same qualifying conversion multiple times MUST NOT create duplicate rewards for the same beneficiary role. -73. For a qualified referral, reward grant processing MUST be atomic across both beneficiary reward decisions. Both +74. For a qualified referral, reward grant processing MUST be atomic across both beneficiary reward decisions. Both beneficiary outcomes MUST be recorded together, including granted, cap-limited, and disqualified outcomes. -74. Reward records MUST identify the source referral, source conversion, beneficiary user, beneficiary role, number of +75. Reward records MUST identify the source referral, source conversion, beneficiary user, beneficiary role, number of months granted, status, and relevant timestamps. -75. Reward records MUST support the reward states defined in this spec. +76. Reward records MUST support the reward states defined in this spec. -76. A reward MUST NOT be considered fulfilled until KiloClaw billing state and any required Stripe state have been +77. A reward MUST NOT be considered fulfilled until KiloClaw billing state and any required Stripe state have been successfully updated so the corresponding KiloClaw renewal is delayed. -77. Impact Advocate reward state MAY be used for reconciliation, support, or reporting. It MUST NOT be the source of +78. Impact Advocate reward state MAY be used for reconciliation, support, or reporting. It MUST NOT be the source of truth for local free-month fulfillment. ### Reward Fulfillment and Billing -78. Free-month rewards MUST be fulfilled by delaying a KiloClaw renewal by one calendar month per reward. +79. Free-month rewards MUST be fulfilled by delaying a KiloClaw renewal by one calendar month per reward. -79. An earned reward applies to the beneficiary's next unpaid renewal boundary after the reward is earned. It MUST NOT +80. An earned reward applies to the beneficiary's next unpaid renewal boundary after the reward is earned. It MUST NOT modify already-finalized invoices or already-funded periods. -80. Free-month rewards MUST NOT be fulfilled as general account credits. +81. Free-month rewards MUST NOT be fulfilled as general account credits. -81. Free-month rewards MUST apply to KiloClaw billing only. They MUST NOT apply to inference usage, Kilo Pass, team plans, +82. Free-month rewards MUST apply to KiloClaw billing only. They MUST NOT apply to inference usage, Kilo Pass, team plans, or non-KiloClaw purchases. -82. Multiple free-month rewards MAY stack. Each applied reward MUST delay renewal by exactly one calendar month. +83. Multiple free-month rewards MAY stack. Each applied reward MUST delay renewal by exactly one calendar month. -83. For month-to-month KiloClaw subscriptions, one reward MUST delay the next monthly renewal by one calendar month. +84. For month-to-month KiloClaw subscriptions, one reward MUST delay the next monthly renewal by one calendar month. -84. For six-month commitment KiloClaw subscriptions, one reward MUST delay the next six-month renewal by one calendar +85. For six-month commitment KiloClaw subscriptions, one reward MUST delay the next six-month renewal by one calendar month. The reward MUST NOT convert the subscription to month-to-month and MUST NOT reduce the next invoice by one sixth. -85. For pure-credit KiloClaw subscriptions, reward application MUST update local renewal state so the credit renewal sweep +86. For pure-credit KiloClaw subscriptions, reward application MUST update local renewal state so the credit renewal sweep does not deduct KiloClaw hosting credits until the extended renewal time. -86. For Stripe-funded or hybrid KiloClaw subscriptions, reward application MUST keep local billing state and Stripe +87. For Stripe-funded or hybrid KiloClaw subscriptions, reward application MUST keep local billing state and Stripe billing state consistent. The system MUST NOT create a local-only renewal delay for a Stripe-funded subscription while allowing Stripe to charge on the original schedule. -87. Reward application MUST be idempotent. Retrying reward application MUST NOT extend the same subscription more than +88. Reward application MUST be idempotent. Retrying reward application MUST NOT extend the same subscription more than once for the same reward. -88. Reward application MUST record an audit trail containing the reward, beneficiary, affected subscription, previous +89. Reward application MUST record an audit trail containing the reward, beneficiary, affected subscription, previous renewal or period boundary, new renewal or period boundary, and any external billing operation identifiers. -89. Reward application MUST NOT break existing KiloClaw billing invariants for trials, pure-credit renewal, hybrid invoice +90. Reward application MUST NOT break existing KiloClaw billing invariants for trials, pure-credit renewal, hybrid invoice settlement, commit plans, plan switching, cancellation, reactivation, past-due recovery, suspension, or destruction. -90. Reward application MUST respect cancellation state. If a subscription is canceled or canceling before reward +91. Reward application MUST respect cancellation state. If a subscription is canceled or canceling before reward application, the reward MUST remain pending until the beneficiary has an active eligible personal KiloClaw subscription. ### Impact Conversion Reporting -91. Impact Advocate referral conversion MUST be driven by the existing Impact Performance conversion events. +92. Impact Advocate referral conversion MUST be driven by the existing Impact Performance conversion events. -92. `Sale (71659)` MUST be the paid KiloClaw conversion event used for referral conversion and renewal reporting. +93. `Sale (71659)` MUST be the paid KiloClaw conversion event used for referral conversion and renewal reporting. -93. The system MUST NOT dispatch client-side `trackConversion` for referrals while server-side Performance conversion is +94. The system MUST NOT dispatch client-side `trackConversion` for referrals while server-side Performance conversion is the configured reporting mechanism. -94. When a referral wins attribution and the first paid conversion qualifies, the system MUST ensure Impact receives the +95. When a referral wins attribution and the first paid conversion qualifies, the system MUST ensure Impact receives the required Performance conversion data for Advocate conversion reporting. -95. Conversion reporting MUST use deterministic order identifiers where possible so retries do not create duplicate Impact +96. Conversion reporting MUST use deterministic order identifiers where possible so retries do not create duplicate Impact actions. -96. Conversion reporting failures MUST NOT block billing settlement, reward ledger creation, or user access. Failures MUST +97. Conversion reporting failures MUST NOT block billing settlement, reward ledger creation, or user access. Failures MUST leave the conversion report in a retryable state until it succeeds, is superseded by a corrected payload, or is marked permanently failed by an operator-visible terminal state. ### Impact Reconciliation -97. The system MUST NOT rely on Impact Advocate webhooks for referral eligibility, reward granting, billing fulfillment, +98. The system MUST NOT rely on Impact Advocate webhooks for referral eligibility, reward granting, billing fulfillment, or reconciliation. -98. The system MAY use Impact dashboard exports or API reads for manual reconciliation and support investigations. +99. The system MAY use Impact dashboard exports or API reads for manual reconciliation and support investigations. -99. Impact reconciliation data MAY update local Impact-facing status fields, but it MUST NOT bypass local eligibility, - cap, attribution, or billing fulfillment rules. +100. Impact reconciliation data MAY update local Impact-facing status fields, but it MUST NOT bypass local eligibility, + cap, attribution, or billing fulfillment rules. ### Refunds, Reversals, and Fraud -100. Rewards from a qualifying Stripe payment MUST be canceled if Stripe reports a chargeback for that payment. +101. Rewards from a qualifying Stripe payment MUST be canceled if Stripe reports a chargeback for that payment. -101. Pending or earned-but-unapplied rewards MUST be canceled when the qualifying Stripe payment is charged back. +102. Pending or earned-but-unapplied rewards MUST be canceled when the qualifying Stripe payment is charged back. -102. Already-applied rewards from a charged-back Stripe payment MUST be marked for support review and MUST NOT be +103. Already-applied rewards from a charged-back Stripe payment MUST be marked for support review and MUST NOT be automatically canceled or clawed back. -103. Rewards from refunded or fraud-marked payments MUST be canceled before application. Already-applied rewards from +104. Rewards from refunded or fraud-marked payments MUST be canceled before application. Already-applied rewards from refunded or fraud-marked payments MUST be marked for support review and MUST NOT be automatically canceled or clawed back. -104. If a qualifying Impact action must be reversed, the system SHOULD use Impact's reverse-action mechanism instead of +105. If a qualifying Impact action must be reversed, the system SHOULD use Impact's reverse-action mechanism instead of creating an unrelated negative conversion. -105. Reversal and reward-cancellation handling MUST be idempotent. +106. Reversal and reward-cancellation handling MUST be idempotent. ### GDPR and PII -106. Referral tables that store user IDs, emails, referral relationships, IP addresses, referral cookies, Impact IDs, or +107. Referral tables that store user IDs, emails, referral relationships, IP addresses, referral cookies, Impact IDs, or reconciliation payloads MUST be included in GDPR soft-delete or anonymization flows. -107. GDPR deletion MUST delete or anonymize referral participant records, referral touch records, referral relationship +108. GDPR deletion MUST delete or anonymize referral participant records, referral touch records, referral relationship records, reconciliation payloads containing PII, and reward records to the extent required by policy. -108. Plain email stored for Impact Advocate compatibility MUST be deleted or anonymized during GDPR deletion. +109. Plain email stored for Impact Advocate compatibility MUST be deleted or anonymized during GDPR deletion. -109. Previously deleted user disqualification MUST use a legal-approved non-PII tombstone or irreversible hash. The system +110. Previously deleted user disqualification MUST use a legal-approved non-PII tombstone or irreversible hash. The system MUST NOT retain PII solely for this purpose. -110. Referral tracking values MUST NOT be logged in a way that exposes secrets, auth headers, cookies, or unnecessary PII. +111. Referral tracking values MUST NOT be logged in a way that exposes secrets, auth headers, cookies, or unnecessary PII. ### Reliability and Isolation -111. Referral touch capture, participant registration, conversion reporting, reconciliation processing, and reward +112. Referral touch capture, participant registration, conversion reporting, reconciliation processing, and reward fulfillment failures MUST NOT break unrelated product functionality. -112. Reward ledger operations MUST be transactional where needed to prevent duplicate grants, partial grants, or missing +113. Reward ledger operations MUST be transactional where needed to prevent duplicate grants, partial grants, or missing audit records. -113. Reward fulfillment failures MUST leave rewards in a retryable state unless the failure is a permanent eligibility or +114. Reward fulfillment failures MUST leave rewards in a retryable state unless the failure is a permanent eligibility or configuration failure. -114. The system MUST expose enough operational state to distinguish pending Impact registration, pending Impact conversion +115. The system MUST expose enough operational state to distinguish pending Impact registration, pending Impact conversion reporting, pending local reward application, applied rewards, reversed rewards, canceled rewards, review-required rewards, and disqualified referrals. -115. Admin-only subscription interventions, internal test conversions, and support adjustments MUST NOT emit referral +116. Admin-only subscription interventions, internal test conversions, and support adjustments MUST NOT emit referral rewards or Impact referral conversions unless explicitly marked as eligible by an authorized operator. ### Existing Internal Referral System -116. The existing internal referral-code system MUST NOT grant additional KiloClaw referral rewards for conversions already +117. The existing internal referral-code system MUST NOT grant additional KiloClaw referral rewards for conversions already governed by this spec. -117. Before launch, the existing internal referral system MUST be scoped away from KiloClaw, disabled for KiloClaw, or +118. Before launch, the existing internal referral system MUST be scoped away from KiloClaw, disabled for KiloClaw, or migrated into this program's rules to prevent double rewards. ## Error Handling diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 03ed4e516e..8c5c4b63a7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -163,6 +163,19 @@ pnpm drizzle migrate You need to re-run this every time you pull new migrations from the repository. +If you want to fully reset the local dev database first, use: + +```bash +pnpm dev:db:reset +pnpm drizzle migrate +``` + +To smoke-test that migrations still bootstrap correctly from a fresh empty database, run: + +```bash +pnpm drizzle:verify-bootstrap +``` + ### 6. Start the development server ```bash @@ -189,21 +202,23 @@ All tests should pass against the local PostgreSQL database. ## Common Development Commands -| Command | Description | -| -------------------------- | ------------------------------------------------------------------------------------------------- | -| `pnpm dev:start` | Start all local services in a tmux dashboard | -| `pnpm dev:stop` | Stop the tmux session and all services | -| `pnpm dev:env` | Sync `.dev.vars` files from `.env.local` (see [Worker `.dev.vars` setup](#worker-dev-vars-setup)) | -| `pnpm test` | Run the Jest test suite | -| `pnpm typecheck` | Run the TypeScript type checker | -| `pnpm lint` | Lint all source files | -| `pnpm format` | Format all supported files with oxfmt | -| `pnpm format:changed` | Format only files changed since `main` | -| `pnpm validate` | Run typecheck, lint, and tests | -| `pnpm drizzle migrate` | Apply pending database migrations | -| `pnpm drizzle generate` | Generate a new migration after schema changes | -| `pnpm --filter web stripe` | Start Stripe webhook forwarding to localhost | -| `pnpm test:e2e` | Run Playwright end-to-end tests | +| Command | Description | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `pnpm dev:start` | Start all local services in a tmux dashboard | +| `pnpm dev:stop` | Stop the tmux session and all services | +| `pnpm dev:env` | Sync `.dev.vars` files from `.env.local` (see [Worker `.dev.vars` setup](#worker-dev-vars-setup)) | +| `pnpm test` | Run the Jest test suite | +| `pnpm typecheck` | Run the TypeScript type checker | +| `pnpm lint` | Lint all source files | +| `pnpm format` | Format all supported files with oxfmt | +| `pnpm format:changed` | Format only files changed since `main` | +| `pnpm validate` | Run typecheck, lint, and tests | +| `pnpm drizzle migrate` | Apply pending database migrations | +| `pnpm drizzle generate` | Generate a new migration after schema changes | +| `pnpm drizzle:verify-bootstrap` | Create a temporary empty database and verify `pnpm drizzle migrate` bootstraps it cleanly | +| `pnpm dev:db:reset` | Drop all app-owned schemas in the local dev database, recreate `public`, and leave the DB truly empty before re-migrating | +| `pnpm --filter web stripe` | Start Stripe webhook forwarding to localhost | +| `pnpm test:e2e` | Run Playwright end-to-end tests | ## Git Workflow diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index afb80e8546..ee3e59bbb9 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -7,7 +7,6 @@ import { EventServiceProvider } from '@/contexts/EventServiceContext'; import { AdminOmnibox } from '@/components/admin-omnibox'; import { PrefetchedOrganizations } from './components/PrefetchedOrganizations'; import { PlatformPresenceMount } from './components/PlatformPresenceMount'; -import { ImpactIdentify } from '@/components/ImpactIdentify'; export default function AppLayout({ children }: { children: React.ReactNode }) { return ( @@ -16,7 +15,6 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { -
diff --git a/apps/web/src/app/(app)/profile/page.tsx b/apps/web/src/app/(app)/profile/page.tsx index 74169cc793..7c0c133f48 100644 --- a/apps/web/src/app/(app)/profile/page.tsx +++ b/apps/web/src/app/(app)/profile/page.tsx @@ -22,6 +22,7 @@ import { getUserOrganizationsWithSeats } from '@/lib/organizations/organizations import { PageLayout } from '@/components/PageLayout'; import { ProfileOrganizationsSection } from '@/components/profile/ProfileOrganizationsSection'; import { ProfileKiloPassSection } from '@/components/profile/ProfileKiloPassSection'; +import { ImpactAdvocateReferralCard } from '@/components/profile/ImpactAdvocateReferralCard'; import { CreateKilocodeOrgButton } from '@/components/dev/CreateKilocodeOrgButton'; import { isFeatureFlagEnabled } from '@/lib/posthog-feature-flags'; import { UserProfileCard } from '@/components/profile/UserProfileCard'; @@ -82,6 +83,8 @@ export default async function ProfilePage({ searchParams }: AppPageProps) { {isKiloPassUiEnabled && } + +
diff --git a/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.test.ts b/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.test.ts new file mode 100644 index 0000000000..03258699cf --- /dev/null +++ b/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.test.ts @@ -0,0 +1,160 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { insertKiloClawSubscriptionChangeLog } from '@kilocode/db'; + +import { processPersonalKiloClawPaidConversion } from '@/lib/kiloclaw-referrals'; +import { resolveCurrentPersonalSubscriptionRow } from '@/lib/kiloclaw/current-personal-subscription'; +import { getUserFromAuth } from '@/lib/user.server'; + +jest.mock('@/lib/user.server', () => ({ + getUserFromAuth: jest.fn(), +})); + +jest.mock('@/lib/kiloclaw/current-personal-subscription', () => ({ + resolveCurrentPersonalSubscriptionRow: jest.fn(), +})); + +jest.mock('@/lib/kiloclaw-referrals', () => ({ + processPersonalKiloClawPaidConversion: jest.fn(), +})); + +jest.mock('@kilocode/db', () => ({ + insertKiloClawSubscriptionChangeLog: jest.fn(), +})); + +import { POST } from './route'; + +const mockGetUserFromAuth = jest.mocked(getUserFromAuth); +const mockResolveCurrentPersonalSubscriptionRow = jest.mocked( + resolveCurrentPersonalSubscriptionRow +); +const mockProcessPersonalKiloClawPaidConversion = jest.mocked( + processPersonalKiloClawPaidConversion +); +const mockInsertKiloClawSubscriptionChangeLog = jest.mocked(insertKiloClawSubscriptionChangeLog); + +function createRequest(body: unknown) { + return new NextRequest( + 'http://localhost:3000/admin/api/users/user_123/kiloclaw-referral-eligibility', + { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'content-type': 'application/json', + }, + } + ); +} + +describe('POST /admin/api/users/[id]/kiloclaw-referral-eligibility', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetUserFromAuth.mockResolvedValue({ + user: { id: 'admin_123' } as never, + authFailedResponse: null, + }); + mockResolveCurrentPersonalSubscriptionRow.mockResolvedValue({ + subscription: { + id: 'subscription_123', + user_id: 'user_123', + plan: 'standard', + status: 'active', + }, + } as never); + mockInsertKiloClawSubscriptionChangeLog.mockResolvedValue(undefined); + mockProcessPersonalKiloClawPaidConversion.mockResolvedValue({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + conversionId: 'conversion_123', + disqualificationReason: null, + }); + }); + + it('returns authFailedResponse for unauthorized operators', async () => { + mockGetUserFromAuth.mockResolvedValue({ + user: null, + authFailedResponse: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) as never, + }); + + const response = await POST(createRequest({}), { params: Promise.resolve({ id: 'user_123' }) }); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }); + }); + + it('records an admin override and processes the conversion with overrideEligible=true', async () => { + const response = await POST( + createRequest({ + sourcePaymentId: 'invoice_123', + orderId: 'invoice_123', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: '2026-04-09T00:00:00.000Z', + sourceType: 'manual_adjustment', + }), + { params: Promise.resolve({ id: 'user_123' }) } + ); + + expect(mockInsertKiloClawSubscriptionChangeLog).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + subscriptionId: 'subscription_123', + actor: { + actorType: 'user', + actorId: 'admin_123', + }, + action: 'admin_override', + reason: 'referral_eligibility_override:manual_adjustment:invoice_123', + }) + ); + + expect(mockProcessPersonalKiloClawPaidConversion).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user_123', + sourcePaymentId: 'invoice_123', + qualificationContext: { + sourceType: 'manual_adjustment', + overrideEligible: true, + }, + }) + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + disposition: { + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + conversionId: 'conversion_123', + disqualificationReason: null, + }, + }); + }); + + it('returns 409 when no current personal subscription exists for the override target', async () => { + mockResolveCurrentPersonalSubscriptionRow.mockResolvedValue(null); + + const response = await POST( + createRequest({ + sourcePaymentId: 'invoice_123', + orderId: 'invoice_123', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + convertedAt: '2026-04-09T00:00:00.000Z', + sourceType: 'test', + }), + { params: Promise.resolve({ id: 'user_123' }) } + ); + + expect(mockInsertKiloClawSubscriptionChangeLog).not.toHaveBeenCalled(); + expect(mockProcessPersonalKiloClawPaidConversion).not.toHaveBeenCalled(); + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ + error: 'No current personal KiloClaw subscription found for referral override', + }); + }); +}); diff --git a/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.ts b/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.ts new file mode 100644 index 0000000000..68c77d61e2 --- /dev/null +++ b/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.ts @@ -0,0 +1,85 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { z } from 'zod'; +import { insertKiloClawSubscriptionChangeLog } from '@kilocode/db'; + +import { db } from '@/lib/drizzle'; +import { resolveCurrentPersonalSubscriptionRow } from '@/lib/kiloclaw/current-personal-subscription'; +import { processPersonalKiloClawPaidConversion } from '@/lib/kiloclaw-referrals'; +import { getUserFromAuth } from '@/lib/user.server'; + +const OverrideBodySchema = z.object({ + sourcePaymentId: z.string().min(1), + orderId: z.string().min(1), + amount: z.number().nonnegative(), + currencyCode: z.string().min(1), + itemCategory: z.string().min(1), + itemName: z.string().min(1), + itemSku: z.string().min(1).optional(), + convertedAt: z.string().datetime(), + sourceType: z.enum(['test', 'fraudulent', 'admin_created', 'manual_adjustment']), +}); + +/** + * Admin-only support route for explicitly marking an otherwise excluded + * KiloClaw referral conversion as eligible. + */ +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { user: adminUser, authFailedResponse } = await getUserFromAuth({ adminOnly: true }); + if (authFailedResponse) { + return authFailedResponse; + } + + if (!adminUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = OverrideBodySchema.safeParse(await request.json().catch(() => null)); + if (!body.success) { + return NextResponse.json({ error: 'Invalid body' }, { status: 400 }); + } + + const userId = (await params).id; + const currentPersonalSubscription = await resolveCurrentPersonalSubscriptionRow({ + userId, + dbOrTx: db, + }); + if (!currentPersonalSubscription) { + return NextResponse.json( + { error: 'No current personal KiloClaw subscription found for referral override' }, + { status: 409 } + ); + } + + await insertKiloClawSubscriptionChangeLog(db, { + subscriptionId: currentPersonalSubscription.subscription.id, + actor: { + actorType: 'user', + actorId: adminUser.id, + }, + action: 'admin_override', + reason: `referral_eligibility_override:${body.data.sourceType}:${body.data.sourcePaymentId}`, + before: currentPersonalSubscription.subscription, + after: currentPersonalSubscription.subscription, + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId, + sourcePaymentId: body.data.sourcePaymentId, + orderId: body.data.orderId, + amount: body.data.amount, + currencyCode: body.data.currencyCode, + itemCategory: body.data.itemCategory, + itemName: body.data.itemName, + itemSku: body.data.itemSku, + convertedAt: new Date(body.data.convertedAt), + qualificationContext: { + sourceType: body.data.sourceType, + overrideEligible: true, + }, + }); + + return NextResponse.json({ + ok: true, + disposition, + }); +} diff --git a/apps/web/src/app/api/cron/dispatch-affiliate-events/route.test.ts b/apps/web/src/app/api/cron/dispatch-affiliate-events/route.test.ts index 9ef29eff73..c8d7154cce 100644 --- a/apps/web/src/app/api/cron/dispatch-affiliate-events/route.test.ts +++ b/apps/web/src/app/api/cron/dispatch-affiliate-events/route.test.ts @@ -8,10 +8,31 @@ jest.mock('@/lib/affiliate-events', () => ({ dispatchQueuedAffiliateEvents: jest.fn(), })); +jest.mock('@/lib/impact-referral', () => ({ + dispatchQueuedImpactAdvocateRegistrationAttempts: jest.fn(), +})); + +jest.mock('@/lib/kiloclaw-referrals', () => ({ + dispatchQueuedImpactConversionReports: jest.fn(), + processQueuedKiloClawReferralRewards: jest.fn(), +})); + import { dispatchQueuedAffiliateEvents } from '@/lib/affiliate-events'; +import { dispatchQueuedImpactAdvocateRegistrationAttempts } from '@/lib/impact-referral'; +import { + dispatchQueuedImpactConversionReports, + processQueuedKiloClawReferralRewards, +} from '@/lib/kiloclaw-referrals'; import { GET } from './route'; const mockDispatchQueuedAffiliateEvents = jest.mocked(dispatchQueuedAffiliateEvents); +const mockDispatchQueuedImpactAdvocateRegistrationAttempts = jest.mocked( + dispatchQueuedImpactAdvocateRegistrationAttempts +); +const mockDispatchQueuedImpactConversionReports = jest.mocked( + dispatchQueuedImpactConversionReports +); +const mockProcessQueuedKiloClawReferralRewards = jest.mocked(processQueuedKiloClawReferralRewards); describe('GET /api/cron/dispatch-affiliate-events', () => { beforeEach(() => { @@ -28,6 +49,9 @@ describe('GET /api/cron/dispatch-affiliate-events', () => { expect(response.status).toBe(401); await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }); expect(mockDispatchQueuedAffiliateEvents).not.toHaveBeenCalled(); + expect(mockDispatchQueuedImpactAdvocateRegistrationAttempts).not.toHaveBeenCalled(); + expect(mockDispatchQueuedImpactConversionReports).not.toHaveBeenCalled(); + expect(mockProcessQueuedKiloClawReferralRewards).not.toHaveBeenCalled(); }); it('dispatches queued affiliate events when authorized', async () => { @@ -39,6 +63,25 @@ describe('GET /api/cron/dispatch-affiliate-events', () => { failed: 0, unblocked: 1, }); + mockDispatchQueuedImpactAdvocateRegistrationAttempts.mockResolvedValue({ + claimed: 2, + delivered: 1, + retried: 1, + failed: 0, + }); + mockDispatchQueuedImpactConversionReports.mockResolvedValue({ + claimed: 2, + delivered: 1, + retried: 1, + failed: 0, + }); + mockProcessQueuedKiloClawReferralRewards.mockResolvedValue({ + claimed: 3, + applied: 2, + expired: 1, + pending: 0, + failed: 0, + }); const response = await GET( new NextRequest('http://localhost:3000/api/cron/dispatch-affiliate-events', { @@ -51,16 +94,40 @@ describe('GET /api/cron/dispatch-affiliate-events', () => { expect(response.status).toBe(200); expect(mockDispatchQueuedAffiliateEvents).toHaveBeenCalledTimes(1); + expect(mockDispatchQueuedImpactAdvocateRegistrationAttempts).toHaveBeenCalledTimes(1); + expect(mockDispatchQueuedImpactConversionReports).toHaveBeenCalledTimes(1); + expect(mockProcessQueuedKiloClawReferralRewards).toHaveBeenCalledTimes(1); await expect(response.json()).resolves.toEqual( expect.objectContaining({ success: true, summary: { - reclaimed: 1, - claimed: 3, - delivered: 2, - retried: 1, - failed: 0, - unblocked: 1, + affiliateEvents: { + reclaimed: 1, + claimed: 3, + delivered: 2, + retried: 1, + failed: 0, + unblocked: 1, + }, + impactAdvocateRegistrations: { + claimed: 2, + delivered: 1, + retried: 1, + failed: 0, + }, + impactConversionReports: { + claimed: 2, + delivered: 1, + retried: 1, + failed: 0, + }, + referralRewards: { + claimed: 3, + applied: 2, + expired: 1, + pending: 0, + failed: 0, + }, }, timestamp: expect.any(String), }) diff --git a/apps/web/src/app/api/cron/dispatch-affiliate-events/route.ts b/apps/web/src/app/api/cron/dispatch-affiliate-events/route.ts index cb5e981962..487305edec 100644 --- a/apps/web/src/app/api/cron/dispatch-affiliate-events/route.ts +++ b/apps/web/src/app/api/cron/dispatch-affiliate-events/route.ts @@ -2,6 +2,11 @@ import { NextResponse } from 'next/server'; import { CRON_SECRET } from '@/lib/config.server'; import { dispatchQueuedAffiliateEvents } from '@/lib/affiliate-events'; +import { dispatchQueuedImpactAdvocateRegistrationAttempts } from '@/lib/impact-referral'; +import { + dispatchQueuedImpactConversionReports, + processQueuedKiloClawReferralRewards, +} from '@/lib/kiloclaw-referrals'; import { sentryLogger } from '@/lib/utils.server'; if (!CRON_SECRET) { @@ -22,12 +27,27 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const summary = await dispatchQueuedAffiliateEvents(); + const [ + affiliateSummary, + impactAdvocateRegistrationSummary, + impactConversionSummary, + referralRewardSummary, + ] = await Promise.all([ + dispatchQueuedAffiliateEvents(), + dispatchQueuedImpactAdvocateRegistrationAttempts(), + dispatchQueuedImpactConversionReports(), + processQueuedKiloClawReferralRewards(), + ]); return NextResponse.json( { success: true, - summary, + summary: { + affiliateEvents: affiliateSummary, + impactAdvocateRegistrations: impactAdvocateRegistrationSummary, + impactConversionReports: impactConversionSummary, + referralRewards: referralRewardSummary, + }, timestamp: new Date().toISOString(), }, { status: 200 } diff --git a/apps/web/src/app/api/impact-advocate/token/route.ts b/apps/web/src/app/api/impact-advocate/token/route.ts new file mode 100644 index 0000000000..e4763cb552 --- /dev/null +++ b/apps/web/src/app/api/impact-advocate/token/route.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert'; +import { eq } from 'drizzle-orm'; +import { NextResponse } from 'next/server'; +import { referral_codes } from '@kilocode/db/schema'; +import { db } from '@/lib/drizzle'; +import { getUserFromAuth } from '@/lib/user.server'; +import { + getImpactAdvocateWidgetId, + issueImpactAdvocateVerifiedAccessToken, +} from '@/lib/impact-advocate'; +import { ensureImpactAdvocateParticipantProfile } from '@/lib/impact-referral'; + +async function getOrCreateOpaqueReferralIdentifier(userId: string): Promise { + const generated = crypto.randomUUID(); + await db + .insert(referral_codes) + .values({ kilo_user_id: userId, code: generated }) + .onConflictDoNothing(); + + const rows = await db + .select() + .from(referral_codes) + .where(eq(referral_codes.kilo_user_id, userId)); + assert.equal(rows.length, 1); + return rows[0].code; +} + +export async function GET() { + const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + if (authFailedResponse) { + return authFailedResponse; + } + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const token = issueImpactAdvocateVerifiedAccessToken(user); + if (!token) { + return NextResponse.json({ error: 'Impact Advocate is not configured' }, { status: 503 }); + } + + try { + const opaqueReferralIdentifier = await getOrCreateOpaqueReferralIdentifier(user.id); + await ensureImpactAdvocateParticipantProfile({ + user, + opaqueReferralIdentifier, + }); + } catch (error) { + console.error('[impact-advocate-token] failed to prepare referral sharing identity', { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: 'Referral sharing is temporarily unavailable' }, + { status: 503 } + ); + } + + return NextResponse.json({ + token, + widgetId: getImpactAdvocateWidgetId(), + }); +} diff --git a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts index cbe3ebc3ae..4796f1f4bc 100644 --- a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts +++ b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'; import { send as sendEmail } from '@/lib/email'; import { maybePerformAutoTopUp } from '@/lib/autoTopUp'; import { enqueueAffiliateEventForUser } from '@/lib/affiliate-events'; +import { processPersonalKiloClawPaidConversion } from '@/lib/kiloclaw-referrals'; jest.mock('@/lib/config.server', () => ({ INTERNAL_API_SECRET: 'internal-secret', @@ -35,6 +36,10 @@ jest.mock('@/lib/affiliate-events', () => ({ enqueueAffiliateEventForUser: jest.fn(), })); +jest.mock('@/lib/kiloclaw-referrals', () => ({ + processPersonalKiloClawPaidConversion: jest.fn(), +})); + jest.mock('@/lib/kiloclaw/credit-billing', () => ({ projectPendingKiloPassBonusMicrodollars: jest.fn(), })); @@ -48,6 +53,9 @@ import { POST } from './route'; const mockSendEmail = jest.mocked(sendEmail); const mockMaybePerformAutoTopUp = jest.mocked(maybePerformAutoTopUp); const mockEnqueueAffiliateEventForUser = jest.mocked(enqueueAffiliateEventForUser); +const mockProcessPersonalKiloClawPaidConversion = jest.mocked( + processPersonalKiloClawPaidConversion +); type ConsoleSpy = jest.SpiedFunction | jest.SpiedFunction; @@ -82,6 +90,12 @@ describe('POST /api/internal/kiloclaw/billing-side-effects', () => { mockSendEmail.mockResolvedValue({ sent: true }); mockMaybePerformAutoTopUp.mockResolvedValue(undefined); + mockProcessPersonalKiloClawPaidConversion.mockResolvedValue({ + shouldEnqueueAffiliateSale: true, + winningTouchType: 'affiliate', + conversionId: 'conversion_123', + disqualificationReason: null, + }); }); it('logs started and completed side effects with billing correlation and no email recipient', async () => { @@ -225,4 +239,50 @@ describe('POST /api/internal/kiloclaw/billing-side-effects', () => { promoCode: undefined, }); }); + + it('processes paid conversions and only enqueues affiliate sales when they win attribution', async () => { + mockProcessPersonalKiloClawPaidConversion.mockResolvedValueOnce({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + conversionId: 'conversion_impact', + disqualificationReason: null, + }); + + const response = await POST( + createRequest({ + action: 'process_paid_conversion', + input: { + userId: 'user-123', + dedupeKey: 'affiliate:impact:sale:period-123', + eventDateIso: '2026-04-09T10:00:00.000Z', + orderId: 'period-123', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + }, + }) + ); + + expect(response.status).toBe(200); + expect(mockProcessPersonalKiloClawPaidConversion).toHaveBeenCalledWith({ + userId: 'user-123', + sourcePaymentId: 'period-123', + orderId: 'period-123', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T10:00:00.000Z'), + }); + expect(mockEnqueueAffiliateEventForUser).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toEqual({ + affiliateSaleEnqueued: false, + winningTouchType: 'referral', + conversionId: 'conversion_impact', + disqualificationReason: null, + }); + }); }); diff --git a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts index 15ed3815bb..3fb1f17539 100644 --- a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts +++ b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts @@ -15,6 +15,7 @@ import { ensureAutoIntroSchedule } from '@/lib/kiloclaw/stripe-handlers'; import { isIntroPriceId } from '@/lib/kiloclaw/stripe-price-ids.server'; import { client as stripe } from '@/lib/stripe-client'; import { enqueueAffiliateEventForUser } from '@/lib/affiliate-events'; +import { processPersonalKiloClawPaidConversion } from '@/lib/kiloclaw-referrals'; import { projectPendingKiloPassBonusMicrodollars } from '@/lib/kiloclaw/credit-billing'; import { maybeIssueKiloPassBonusFromUsageThreshold } from '@/lib/kilo-pass/usage-triggered-bonus'; @@ -130,6 +131,20 @@ const BodySchema = z.discriminatedUnion('action', [ promoCode: z.string().min(1).optional(), }), }), + z.object({ + action: z.literal('process_paid_conversion'), + input: z.object({ + userId: z.string().min(1), + dedupeKey: z.string().min(1), + eventDateIso: z.string().datetime(), + orderId: z.string().min(1), + amount: z.number().nonnegative(), + currencyCode: z.string().min(1), + itemCategory: z.string().min(1), + itemName: z.string().min(1), + itemSku: z.string().min(1).optional(), + }), + }), z.object({ action: z.literal('project_pending_kilo_pass_bonus'), input: z.object({ @@ -169,6 +184,8 @@ function getActionLogFields(body: z.infer): { }; case 'enqueue_affiliate_event': return { userId: body.input.userId }; + case 'process_paid_conversion': + return { userId: body.input.userId }; case 'project_pending_kilo_pass_bonus': return { userId: body.input.userId }; case 'issue_kilo_pass_bonus_from_usage_threshold': @@ -220,6 +237,12 @@ export async function POST(request: NextRequest) { | { ok: true } | { repaired: boolean } | { enqueued: boolean } + | { + affiliateSaleEnqueued: boolean; + winningTouchType: 'referral' | 'affiliate' | 'none'; + conversionId: string | null; + disqualificationReason: string | null; + } | { projectedBonusMicrodollars: number }; switch (parsed.data.action) { @@ -266,6 +289,44 @@ export async function POST(request: NextRequest) { payload = { enqueued: true }; break; + case 'process_paid_conversion': { + const disposition = await processPersonalKiloClawPaidConversion({ + userId: parsed.data.input.userId, + sourcePaymentId: parsed.data.input.orderId, + orderId: parsed.data.input.orderId, + amount: parsed.data.input.amount, + currencyCode: parsed.data.input.currencyCode, + itemCategory: parsed.data.input.itemCategory, + itemName: parsed.data.input.itemName, + itemSku: parsed.data.input.itemSku, + convertedAt: new Date(parsed.data.input.eventDateIso), + }); + + if (disposition.shouldEnqueueAffiliateSale) { + await enqueueAffiliateEventForUser({ + userId: parsed.data.input.userId, + provider: 'impact', + eventType: 'sale', + dedupeKey: parsed.data.input.dedupeKey, + eventDate: new Date(parsed.data.input.eventDateIso), + orderId: parsed.data.input.orderId, + amount: parsed.data.input.amount, + currencyCode: parsed.data.input.currencyCode, + itemCategory: parsed.data.input.itemCategory, + itemName: parsed.data.input.itemName, + itemSku: parsed.data.input.itemSku, + }); + } + + payload = { + affiliateSaleEnqueued: disposition.shouldEnqueueAffiliateSale, + winningTouchType: disposition.winningTouchType, + conversionId: disposition.conversionId, + disqualificationReason: disposition.disqualificationReason, + }; + break; + } + case 'project_pending_kilo_pass_bonus': payload = { projectedBonusMicrodollars: await projectPendingKiloPassBonusMicrodollars({ diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index b68d1e9ab6..2943ba3092 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -5,6 +5,7 @@ import './globals.css'; import { PostHogProvider } from '../components/PostHogProvider'; import { Providers } from '../components/Providers'; import { DataLayerProvider } from '../components/DataLayerProvider'; +import { ImpactIdentify } from '@/components/ImpactIdentify'; import { APP_URL } from '@/lib/constants'; const inter = Inter({ @@ -108,6 +109,7 @@ export default function RootLayout({ + {children} diff --git a/apps/web/src/app/users/after-sign-in/route.tsx b/apps/web/src/app/users/after-sign-in/route.tsx index 04b7e9366d..2147e19fbb 100644 --- a/apps/web/src/app/users/after-sign-in/route.tsx +++ b/apps/web/src/app/users/after-sign-in/route.tsx @@ -9,6 +9,17 @@ import { IMPACT_CLICK_ID_COOKIE, resolveImpactAffiliateTrackingId, } from '@/lib/impact-affiliate-utils'; +import { + countryCodeFromHeaders, + localeFromHeaders, + queueImpactAdvocateParticipantRegistration, + recordImpactAffiliateTouch, + recordImpactReferralTouch, +} from '@/lib/impact-referral'; +import { + parseImpactAffiliateTouchFromUrl, + parseImpactReferralTouchFromUrl, +} from '@/lib/impact-referral-utils'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { APP_URL } from '@/lib/constants'; @@ -118,17 +129,69 @@ export async function GET(request: NextRequest) { request.cookies.get(IMPACT_APP_TRACKED_CLICK_ID_COOKIE)?.value?.trim() || null, }); + const affiliateTouch = parseImpactAffiliateTouchFromUrl(url, affiliateTrackingId); + const referralTouch = parseImpactReferralTouchFromUrl(url); + + if (user && affiliateTouch) { + try { + await recordImpactAffiliateTouch({ + userId: user.id, + touch: affiliateTouch, + }); + } catch (error) { + console.error('[after-sign-in] failed to record affiliate touch', { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (user && referralTouch) { + try { + await recordImpactReferralTouch({ + userId: user.id, + touch: referralTouch, + }); + } catch (error) { + console.error('[after-sign-in] failed to record referral touch', { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + } + + try { + await queueImpactAdvocateParticipantRegistration({ + user, + referralTouch, + locale: localeFromHeaders(request.headers), + countryCode: countryCodeFromHeaders(request.headers), + }); + } catch (error) { + console.error('[after-sign-in] failed to enqueue Impact Advocate registration', { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + if (user && affiliateTrackingId) { const existingAttribution = await getAffiliateAttribution(user.id, 'impact'); if (!existingAttribution) { - await recordAffiliateAttributionAndQueueParentEvent({ - userId: user.id, - provider: 'impact', - trackingId: affiliateTrackingId, - customerEmail: user.google_user_email, - eventDate: new Date(), - }); + try { + await recordAffiliateAttributionAndQueueParentEvent({ + userId: user.id, + provider: 'impact', + trackingId: affiliateTrackingId, + customerEmail: user.google_user_email, + eventDate: new Date(), + }); + } catch (error) { + console.error('[after-sign-in] failed to persist affiliate attribution', { + userId: user.id, + error: error instanceof Error ? error.message : String(error), + }); + } } } diff --git a/apps/web/src/components/ImpactIdentify.tsx b/apps/web/src/components/ImpactIdentify.tsx index 3f2a2da30c..2023df550c 100644 --- a/apps/web/src/components/ImpactIdentify.tsx +++ b/apps/web/src/components/ImpactIdentify.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useUser } from '@/hooks/useUser'; +import { IMPACT_CUSTOM_PROFILE_ID_STORAGE_KEY } from '@/lib/impact-referral-utils'; async function sha1Hex(value: string): Promise { const normalized = value.trim().toLowerCase(); @@ -12,32 +13,60 @@ async function sha1Hex(value: string): Promise { .join(''); } +function getStableAnonymousProfileId(): string { + const existing = window.localStorage.getItem(IMPACT_CUSTOM_PROFILE_ID_STORAGE_KEY)?.trim(); + if (existing) { + return existing; + } + + const generated = `kilo-anon:${crypto.randomUUID()}`; + window.localStorage.setItem(IMPACT_CUSTOM_PROFILE_ID_STORAGE_KEY, generated); + return generated; +} + export function ImpactIdentify() { const { data: user } = useUser(); useEffect(() => { - if (!user || typeof window.ire !== 'function') return; - let cancelled = false; + let retryTimeout: ReturnType | null = null; + + const runIdentify = async (retriesRemaining: number): Promise => { + if (cancelled) return; + + if (typeof window.ire !== 'function') { + if (retriesRemaining <= 0) return; - void sha1Hex(user.google_user_email) - .then(hashedEmail => { - if (cancelled || typeof window.ire !== 'function') return; - - window.ire('identify', { - customerId: user.id, - customerEmail: hashedEmail, - customProfileId: '', - }); - }) - .catch(error => { - console.error('ImpactIdentify failed', error); + retryTimeout = setTimeout(() => { + void runIdentify(retriesRemaining - 1); + }, 250); + return; + } + + const customProfileId = user?.id ? `kilo-user:${user.id}` : getStableAnonymousProfileId(); + const customerId = user?.id ?? ''; + const customerEmail = user ? await sha1Hex(user.google_user_email) : ''; + + if (cancelled || typeof window.ire !== 'function') return; + + window.ire('identify', { + customerId, + customerEmail, + customProfileId, }); + }; + + void runIdentify(10).catch(error => { + console.error('ImpactIdentify failed', error); + }); return () => { cancelled = true; + if (retryTimeout) { + clearTimeout(retryTimeout); + } }; - }, [user]); + }, [user?.google_user_email, user?.id]); return null; } diff --git a/apps/web/src/components/profile/ImpactAdvocateReferralCard.tsx b/apps/web/src/components/profile/ImpactAdvocateReferralCard.tsx new file mode 100644 index 0000000000..301b0e39fa --- /dev/null +++ b/apps/web/src/components/profile/ImpactAdvocateReferralCard.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { createElement, useEffect, useState } from 'react'; +import { Gift } from 'lucide-react'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +type WidgetState = + | { status: 'loading' } + | { status: 'ready'; token: string; widgetId: string } + | { status: 'unavailable'; message: string }; + +export function ImpactAdvocateReferralCard() { + const [state, setState] = useState({ status: 'loading' }); + + useEffect(() => { + let cancelled = false; + + const loadWidgetToken = async () => { + try { + const response = await fetch('/api/impact-advocate/token', { + method: 'GET', + credentials: 'same-origin', + headers: { + Accept: 'application/json', + }, + }); + + const payload = (await response.json().catch(() => null)) as { + token?: string; + widgetId?: string; + error?: string; + } | null; + + if (cancelled) { + return; + } + + if (!response.ok || !payload?.token || !payload.widgetId) { + setState({ + status: 'unavailable', + message: + payload?.error ?? + (response.status === 503 + ? 'Referral sharing is not configured in this environment.' + : 'Referral sharing is temporarily unavailable.'), + }); + return; + } + + window.impactToken = payload.token; + setState({ + status: 'ready', + token: payload.token, + widgetId: payload.widgetId, + }); + } catch (error) { + if (cancelled) { + return; + } + + setState({ + status: 'unavailable', + message: error instanceof Error ? error.message : 'Failed to load referral sharing.', + }); + } + }; + + void loadWidgetToken(); + + return () => { + cancelled = true; + }; + }, []); + + return ( + + + + + Referral Program + + + Invite a friend to KiloClaw. When they become an eligible paid personal subscriber, you + both get a free month. + + + + {state.status === 'loading' ? ( +
Loading referral sharing…
+ ) : state.status === 'unavailable' ? ( +
{state.message}
+ ) : ( +
+ {createElement( + 'impact-embed', + { + widget: state.widgetId, + className: 'block min-h-52 w-full', + }, +
Loading referral widget…
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/db/empty-database.ts b/apps/web/src/db/empty-database.ts index 1961af2846..960afcb9f6 100644 --- a/apps/web/src/db/empty-database.ts +++ b/apps/web/src/db/empty-database.ts @@ -3,35 +3,33 @@ import { sql } from 'drizzle-orm'; import { db } from '../lib/drizzle'; async function main() { - console.log('Emptying database (drop all tables+views)...'); + console.log('Resetting database (drop and recreate app schemas)...'); - const { rows: tables } = await db.execute( - sql`SELECT schemaname, tablename FROM pg_tables WHERE schemaname NOT IN ('information_schema', 'pg_catalog')` - ); + const { rows } = await db.execute(sql` + SELECT nspname + FROM pg_namespace + WHERE nspname NOT LIKE 'pg_%' + AND nspname <> 'information_schema' + `); - for (const { schemaname, tablename } of tables) { - if (typeof schemaname === 'string' && typeof tablename === 'string') { - console.log(`Dropping table ${schemaname}.${tablename}...`); - await db.execute(sql.raw(`DROP TABLE "${schemaname}"."${tablename}" CASCADE`)); + for (const row of rows) { + if (typeof row.nspname !== 'string') { + continue; } - } - - const { rows: views } = await db.execute( - sql`SELECT schemaname, viewname FROM pg_views WHERE schemaname NOT IN ('information_schema', 'pg_catalog')` - ); - for (const { schemaname, viewname } of views) { - if (typeof schemaname === 'string' && typeof viewname === 'string') { - console.log(`Dropping view ${schemaname}.${viewname}...`); - await db.execute(sql.raw(`DROP VIEW "${schemaname}"."${viewname}" CASCADE`)); - } + console.log(`Dropping schema ${row.nspname}...`); + await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${row.nspname}" CASCADE`)); } - console.log('Database emptied! You should run "pnpm drizzle migrate" to recreate our schema.'); + await db.execute(sql.raw('CREATE SCHEMA "public"')); + + console.log( + 'Database reset to empty app schemas. Run "pnpm drizzle migrate" to recreate our schema.' + ); process.exit(0); } main().catch(error => { - console.error('Database emptying failed:', error); + console.error('Database reset failed:', error); process.exit(1); }); diff --git a/apps/web/src/lib/config.server.ts b/apps/web/src/lib/config.server.ts index fccbce9e1a..8970db3c70 100644 --- a/apps/web/src/lib/config.server.ts +++ b/apps/web/src/lib/config.server.ts @@ -43,6 +43,11 @@ export const CODE_REVIEW_WORKER_AUTH_TOKEN = getEnvVariable('CODE_REVIEW_WORKER_ export const IMPACT_ACCOUNT_SID = getEnvVariable('IMPACT_ACCOUNT_SID') || ''; export const IMPACT_AUTH_TOKEN = getEnvVariable('IMPACT_AUTH_TOKEN') || ''; export const IMPACT_CAMPAIGN_ID = getEnvVariable('IMPACT_CAMPAIGN_ID') || ''; +export const IMPACT_ADVOCATE_TENANT_ALIAS = getEnvVariable('IMPACT_ADVOCATE_TENANT_ALIAS') || ''; +export const IMPACT_ADVOCATE_PROGRAM_ID = getEnvVariable('IMPACT_ADVOCATE_PROGRAM_ID') || ''; +export const IMPACT_ADVOCATE_ACCOUNT_SID = getEnvVariable('IMPACT_ADVOCATE_ACCOUNT_SID') || ''; +export const IMPACT_ADVOCATE_AUTH_TOKEN = getEnvVariable('IMPACT_ADVOCATE_AUTH_TOKEN') || ''; +export const IMPACT_ADVOCATE_WIDGET_ID = getEnvVariable('IMPACT_ADVOCATE_WIDGET_ID') || ''; if (!NEXTAUTH_SECRET) throw new Error('NEXTAUTH_SECRET is required JWT signing'); if (!TURNSTILE_SECRET_KEY) throw new Error('TURNSTILE_SECRET_KEY is required'); diff --git a/apps/web/src/lib/getSignInCallbackUrl.test.ts b/apps/web/src/lib/getSignInCallbackUrl.test.ts index eb441ccbe2..431f7f7855 100644 --- a/apps/web/src/lib/getSignInCallbackUrl.test.ts +++ b/apps/web/src/lib/getSignInCallbackUrl.test.ts @@ -217,6 +217,19 @@ describe('getSignInCallbackUrl', () => { expect(result).toBe('/users/after-sign-in?source=extension&im_ref=impact-click-id-123'); }); + + test('preserves referral query params through the auth callback', () => { + const result = getSignInCallbackUrl({ + _saasquatch: 'opaque-referral-cookie', + rsCode: 'ref-code', + rsShareMedium: 'email', + rsEngagementMedium: 'link', + }); + + expect(result).toBe( + '/users/after-sign-in?_saasquatch=opaque-referral-cookie&rsCode=ref-code&rsShareMedium=email&rsEngagementMedium=link' + ); + }); }); describe('stripHost', () => { diff --git a/apps/web/src/lib/getSignInCallbackUrl.ts b/apps/web/src/lib/getSignInCallbackUrl.ts index 3f9a5885f7..63c1d6812f 100644 --- a/apps/web/src/lib/getSignInCallbackUrl.ts +++ b/apps/web/src/lib/getSignInCallbackUrl.ts @@ -40,6 +40,22 @@ export default function getSignInCallbackUrl(searchParams?: NextAppSearchParams) callbackParams.set('im_ref', searchParams.im_ref); } + if (typeof searchParams?._saasquatch === 'string' && searchParams?._saasquatch) { + callbackParams.set('_saasquatch', searchParams._saasquatch); + } + + if (typeof searchParams?.rsCode === 'string' && searchParams?.rsCode) { + callbackParams.set('rsCode', searchParams.rsCode); + } + + if (typeof searchParams?.rsShareMedium === 'string' && searchParams?.rsShareMedium) { + callbackParams.set('rsShareMedium', searchParams.rsShareMedium); + } + + if (typeof searchParams?.rsEngagementMedium === 'string' && searchParams?.rsEngagementMedium) { + callbackParams.set('rsEngagementMedium', searchParams.rsEngagementMedium); + } + // Always route through /users/after-sign-in to ensure stytch verification check if ( typeof searchParams?.callbackPath === 'string' && diff --git a/apps/web/src/lib/impact-advocate.test.ts b/apps/web/src/lib/impact-advocate.test.ts new file mode 100644 index 0000000000..4b01591c73 --- /dev/null +++ b/apps/web/src/lib/impact-advocate.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import jwt from 'jsonwebtoken'; + +describe('impact advocate', () => { + const originalEnv = { + IMPACT_ADVOCATE_ACCOUNT_SID: process.env.IMPACT_ADVOCATE_ACCOUNT_SID, + IMPACT_ADVOCATE_AUTH_TOKEN: process.env.IMPACT_ADVOCATE_AUTH_TOKEN, + IMPACT_ADVOCATE_PROGRAM_ID: process.env.IMPACT_ADVOCATE_PROGRAM_ID, + IMPACT_ADVOCATE_TENANT_ALIAS: process.env.IMPACT_ADVOCATE_TENANT_ALIAS, + IMPACT_ADVOCATE_WIDGET_ID: process.env.IMPACT_ADVOCATE_WIDGET_ID, + IMPACT_ACCOUNT_SID: process.env.IMPACT_ACCOUNT_SID, + }; + + afterEach(() => { + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = originalEnv.IMPACT_ADVOCATE_ACCOUNT_SID; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = originalEnv.IMPACT_ADVOCATE_AUTH_TOKEN; + process.env.IMPACT_ADVOCATE_PROGRAM_ID = originalEnv.IMPACT_ADVOCATE_PROGRAM_ID; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = originalEnv.IMPACT_ADVOCATE_TENANT_ALIAS; + process.env.IMPACT_ADVOCATE_WIDGET_ID = originalEnv.IMPACT_ADVOCATE_WIDGET_ID; + process.env.IMPACT_ACCOUNT_SID = originalEnv.IMPACT_ACCOUNT_SID; + jest.resetModules(); + }); + + it('builds register participant payloads with exact cookie attribution', async () => { + process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'kilo'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'account-sid'; + + const { buildImpactAdvocateRegisterParticipantPayload } = await import('@/lib/impact-advocate'); + + expect( + buildImpactAdvocateRegisterParticipantPayload({ + user: { id: 'user_123', google_user_email: 'referee@example.com' }, + referralCookieValue: 'opaque-cookie-value', + locale: 'en-US', + countryCode: 'US', + }) + ).toEqual({ + id: 'user_123', + accountId: 'user_123', + programId: '51699', + email: 'referee@example.com', + cookies: 'opaque-cookie-value', + locale: 'en-US', + countryCode: 'US', + }); + }); + + it('issues verified access JWTs with the account sid in the kid header', async () => { + process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; + process.env.IMPACT_ADVOCATE_WIDGET_ID = 'p/51699/w/referrerWidget'; + + const { getImpactAdvocateWidgetId, issueImpactAdvocateVerifiedAccessToken } = + await import('@/lib/impact-advocate'); + + const token = issueImpactAdvocateVerifiedAccessToken( + { id: 'user_123', google_user_email: 'referrer@example.com' }, + new Date('2026-04-23T12:00:00.000Z') + ); + + expect(token).toBeTruthy(); + expect(getImpactAdvocateWidgetId()).toBe('p/51699/w/referrerWidget'); + + const decoded = jwt.decode(token ?? '', { complete: true }); + if (!decoded || typeof decoded !== 'object') { + throw new Error('Expected a decoded JWT payload'); + } + + expect(decoded.header.kid).toBe('impact-account-sid'); + expect(decoded.payload).toMatchObject({ + iss: 'tenant-alias', + aud: 'impact-advocate', + sub: 'user_123', + user: { + id: 'user_123', + accountId: 'user_123', + email: 'referrer@example.com', + }, + }); + }); +}); diff --git a/apps/web/src/lib/impact-advocate.ts b/apps/web/src/lib/impact-advocate.ts new file mode 100644 index 0000000000..63c4ce516e --- /dev/null +++ b/apps/web/src/lib/impact-advocate.ts @@ -0,0 +1,191 @@ +import 'server-only'; + +import jwt from 'jsonwebtoken'; +import type { SignOptions } from 'jsonwebtoken'; +import type { User } from '@kilocode/db/schema'; +import { + IMPACT_ACCOUNT_SID, + IMPACT_ADVOCATE_ACCOUNT_SID, + IMPACT_ADVOCATE_AUTH_TOKEN, + IMPACT_ADVOCATE_PROGRAM_ID, + IMPACT_ADVOCATE_TENANT_ALIAS, + IMPACT_ADVOCATE_WIDGET_ID, +} from '@/lib/config.server'; + +export const IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID = '51699'; +export const IMPACT_ADVOCATE_DEFAULT_WIDGET_ID = 'p/51699/w/referrerWidget'; + +export type ImpactAdvocateIdentityPayload = { + id: string; + accountId: string; + email: string; +}; + +export type ImpactAdvocateRegisterParticipantPayload = { + id: string; + accountId: string; + programId: string; + email: string; + cookies: string; + locale?: string; + countryCode?: string; +}; + +export type ImpactAdvocateDispatchResult = + | { + ok: true; + responseBody?: string; + statusCode?: number; + } + | { + ok: false; + failureKind: 'http_4xx' | 'http_5xx' | 'network'; + statusCode?: number; + responseBody?: string; + error?: string; + }; + +function getImpactAdvocateConfig() { + const accountSid = IMPACT_ADVOCATE_ACCOUNT_SID || IMPACT_ACCOUNT_SID; + const authToken = IMPACT_ADVOCATE_AUTH_TOKEN; + const tenantAlias = IMPACT_ADVOCATE_TENANT_ALIAS; + const programId = IMPACT_ADVOCATE_PROGRAM_ID || IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID; + const widgetId = IMPACT_ADVOCATE_WIDGET_ID || IMPACT_ADVOCATE_DEFAULT_WIDGET_ID; + + if (!accountSid || !authToken || !tenantAlias) { + return null; + } + + return { + accountSid, + authToken, + tenantAlias, + programId, + widgetId, + }; +} + +export function isImpactAdvocateConfigured(): boolean { + return getImpactAdvocateConfig() !== null; +} + +export function getImpactAdvocateWidgetId(): string { + return getImpactAdvocateConfig()?.widgetId ?? IMPACT_ADVOCATE_DEFAULT_WIDGET_ID; +} + +export function buildImpactAdvocateIdentityPayload( + user: Pick +): ImpactAdvocateIdentityPayload { + return { + id: user.id, + accountId: user.id, + email: user.google_user_email, + }; +} + +export function buildImpactAdvocateRegisterParticipantPayload(params: { + user: Pick; + referralCookieValue: string; + locale?: string | null; + countryCode?: string | null; +}): ImpactAdvocateRegisterParticipantPayload { + const config = getImpactAdvocateConfig(); + + return { + id: params.user.id, + accountId: params.user.id, + programId: config?.programId ?? IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID, + email: params.user.google_user_email, + cookies: params.referralCookieValue, + ...(params.locale ? { locale: params.locale } : {}), + ...(params.countryCode ? { countryCode: params.countryCode } : {}), + }; +} + +function getImpactAdvocateAuthorizationHeader( + config: NonNullable> +): string { + return `Basic ${Buffer.from(`${config.accountSid}:${config.authToken}`).toString('base64')}`; +} + +function getImpactAdvocateRegisterParticipantUrl( + config: NonNullable> +): string { + return `https://api.impact.com/Advocate/${config.tenantAlias}/Programs/${config.programId}/Participants`; +} + +export async function sendImpactAdvocateRegisterParticipantPayload( + payload: ImpactAdvocateRegisterParticipantPayload +): Promise { + const config = getImpactAdvocateConfig(); + if (!config) { + return { + ok: false, + failureKind: 'http_4xx', + error: 'Impact Advocate is unconfigured', + }; + } + + try { + const response = await fetch(getImpactAdvocateRegisterParticipantUrl(config), { + method: 'POST', + headers: { + Authorization: getImpactAdvocateAuthorizationHeader(config), + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + const responseBody = await response.text(); + if (response.ok) { + return { + ok: true, + statusCode: response.status, + responseBody, + }; + } + + return { + ok: false, + failureKind: response.status >= 500 ? 'http_5xx' : 'http_4xx', + statusCode: response.status, + responseBody, + }; + } catch (error) { + return { + ok: false, + failureKind: 'network', + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export function issueImpactAdvocateVerifiedAccessToken( + user: Pick, + now: Date = new Date() +): string | null { + const config = getImpactAdvocateConfig(); + if (!config) return null; + + const options: SignOptions = { + algorithm: 'HS256', + expiresIn: '5m', + header: { + alg: 'HS256', + kid: config.accountSid, + }, + subject: user.id, + }; + + return jwt.sign( + { + iss: config.tenantAlias, + aud: 'impact-advocate', + iat: Math.floor(now.getTime() / 1000), + user: buildImpactAdvocateIdentityPayload(user), + }, + config.authToken, + options + ); +} diff --git a/apps/web/src/lib/impact-referral-utils.test.ts b/apps/web/src/lib/impact-referral-utils.test.ts new file mode 100644 index 0000000000..6359850f2b --- /dev/null +++ b/apps/web/src/lib/impact-referral-utils.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from '@jest/globals'; +import { + IMPACT_OPAQUE_TRACKING_VALUE_MAX_LENGTH, + IMPACT_REFERRAL_TOUCH_VALIDITY_MS, + parseImpactAffiliateTouchFromUrl, + parseImpactReferralTouchFromUrl, + redactOpaqueTrackingValueForLogs, + sanitizeOpaqueTrackingValue, +} from '@/lib/impact-referral-utils'; + +describe('impact referral utils', () => { + it('accepts opaque tracking values within the configured limit', () => { + expect(sanitizeOpaqueTrackingValue('abc123')).toEqual({ + acceptedValue: 'abc123', + originalLength: 6, + isAccepted: true, + }); + }); + + it('rejects opaque tracking values above the configured limit', () => { + const tooLongValue = 'x'.repeat(IMPACT_OPAQUE_TRACKING_VALUE_MAX_LENGTH + 1); + expect(sanitizeOpaqueTrackingValue(tooLongValue)).toEqual({ + acceptedValue: null, + originalLength: tooLongValue.length, + isAccepted: false, + }); + }); + + it('redacts opaque tracking values for logs without exposing the full value', () => { + expect(redactOpaqueTrackingValueForLogs('abcd1234wxyz5678')).toBe('abcd…5678'); + expect(redactOpaqueTrackingValueForLogs('tiny')).toBe('[redacted]'); + expect(redactOpaqueTrackingValueForLogs(null)).toBeNull(); + }); + + it('parses referral touches and applies a 30 day expiry window', () => { + const now = new Date('2026-04-23T10:00:00.000Z'); + const touch = parseImpactReferralTouchFromUrl( + 'https://kilo.ai/get-started?_saasquatch=sq-cookie&rsCode=abc&rsShareMedium=email&rsEngagementMedium=link&utm_source=impact', + now + ); + + expect(touch).toEqual({ + opaqueTrackingValue: 'sq-cookie', + trackingValueLength: 9, + isTrackingValueAccepted: true, + rsCode: 'abc', + rsShareMedium: 'email', + rsEngagementMedium: 'link', + landingPath: + '/get-started?_saasquatch=sq-cookie&rsCode=abc&rsShareMedium=email&rsEngagementMedium=link&utm_source=impact', + utmSource: 'impact', + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: now, + expiresAt: new Date(now.getTime() + IMPACT_REFERRAL_TOUCH_VALIDITY_MS), + }); + }); + + it('keeps referral metadata for diagnostics when _saasquatch is missing', () => { + const touch = parseImpactReferralTouchFromUrl( + 'https://kilo.ai/get-started?rsCode=abc&rsShareMedium=email' + ); + + expect(touch?.opaqueTrackingValue).toBeNull(); + expect(touch?.trackingValueLength).toBe(0); + expect(touch?.isTrackingValueAccepted).toBe(false); + expect(touch?.rsCode).toBe('abc'); + }); + + it('parses affiliate touches from im_ref and override cookies', () => { + const fromQuery = parseImpactAffiliateTouchFromUrl('https://kilo.ai/?im_ref=impact-click'); + expect(fromQuery?.trackingId).toBe('impact-click'); + + const fromCookie = parseImpactAffiliateTouchFromUrl('https://kilo.ai/', 'impact-cookie-click'); + expect(fromCookie?.trackingId).toBe('impact-cookie-click'); + }); +}); diff --git a/apps/web/src/lib/impact-referral-utils.ts b/apps/web/src/lib/impact-referral-utils.ts new file mode 100644 index 0000000000..5c01d601ae --- /dev/null +++ b/apps/web/src/lib/impact-referral-utils.ts @@ -0,0 +1,165 @@ +export const IMPACT_OPAQUE_TRACKING_VALUE_MAX_LENGTH = 512; +export const IMPACT_REFERRAL_TOUCH_VALIDITY_MS = 30 * 24 * 60 * 60 * 1000; +export const IMPACT_CUSTOM_PROFILE_ID_STORAGE_KEY = 'impact_custom_profile_id'; + +export type SanitizedOpaqueTrackingValue = { + acceptedValue: string | null; + originalLength: number; + isAccepted: boolean; +}; + +export type ParsedImpactReferralTouch = { + opaqueTrackingValue: string | null; + trackingValueLength: number; + isTrackingValueAccepted: boolean; + rsCode: string | null; + rsShareMedium: string | null; + rsEngagementMedium: string | null; + landingPath: string | null; + utmSource: string | null; + utmMedium: string | null; + utmCampaign: string | null; + utmTerm: string | null; + utmContent: string | null; + touchedAt: Date; + expiresAt: Date; +}; + +export type ParsedImpactAffiliateTouch = { + trackingId: string | null; + trackingValueLength: number; + isTrackingValueAccepted: boolean; + landingPath: string | null; + utmSource: string | null; + utmMedium: string | null; + utmCampaign: string | null; + utmTerm: string | null; + utmContent: string | null; + touchedAt: Date; + expiresAt: Date; +}; + +function normalizeInput(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function sanitizeMetadataValue(value: string | null | undefined): string | null { + const normalized = normalizeInput(value); + if (!normalized || normalized.length > IMPACT_OPAQUE_TRACKING_VALUE_MAX_LENGTH) { + return null; + } + return normalized; +} + +function landingPathFromUrl(url: URL): string | null { + const path = `${url.pathname}${url.search}`.trim(); + return path ? path : null; +} + +export function sanitizeOpaqueTrackingValue( + value: string | null | undefined +): SanitizedOpaqueTrackingValue { + const normalized = normalizeInput(value); + const originalLength = normalized?.length ?? 0; + + if (!normalized) { + return { + acceptedValue: null, + originalLength, + isAccepted: false, + }; + } + + if (normalized.length > IMPACT_OPAQUE_TRACKING_VALUE_MAX_LENGTH) { + return { + acceptedValue: null, + originalLength, + isAccepted: false, + }; + } + + return { + acceptedValue: normalized, + originalLength, + isAccepted: true, + }; +} + +export function redactOpaqueTrackingValueForLogs(value: string | null | undefined): string | null { + const normalized = normalizeInput(value); + if (!normalized) return null; + + if (normalized.length <= 8) { + return '[redacted]'; + } + + return `${normalized.slice(0, 4)}…${normalized.slice(-4)}`; +} + +export function parseImpactReferralTouchFromUrl( + candidateUrl: string | URL, + now: Date = new Date() +): ParsedImpactReferralTouch | null { + const url = + typeof candidateUrl === 'string' ? new URL(candidateUrl, 'http://localhost') : candidateUrl; + const searchParams = url.searchParams; + + const hasReferralSignals = ['_saasquatch', 'rsCode', 'rsShareMedium', 'rsEngagementMedium'].some( + key => normalizeInput(searchParams.get(key)) + ); + + if (!hasReferralSignals) { + return null; + } + + const trackingValue = sanitizeOpaqueTrackingValue(searchParams.get('_saasquatch')); + + return { + opaqueTrackingValue: trackingValue.acceptedValue, + trackingValueLength: trackingValue.originalLength, + isTrackingValueAccepted: trackingValue.isAccepted, + rsCode: sanitizeMetadataValue(searchParams.get('rsCode')), + rsShareMedium: sanitizeMetadataValue(searchParams.get('rsShareMedium')), + rsEngagementMedium: sanitizeMetadataValue(searchParams.get('rsEngagementMedium')), + landingPath: landingPathFromUrl(url), + utmSource: sanitizeMetadataValue(searchParams.get('utm_source')), + utmMedium: sanitizeMetadataValue(searchParams.get('utm_medium')), + utmCampaign: sanitizeMetadataValue(searchParams.get('utm_campaign')), + utmTerm: sanitizeMetadataValue(searchParams.get('utm_term')), + utmContent: sanitizeMetadataValue(searchParams.get('utm_content')), + touchedAt: now, + expiresAt: new Date(now.getTime() + IMPACT_REFERRAL_TOUCH_VALIDITY_MS), + }; +} + +export function parseImpactAffiliateTouchFromUrl( + candidateUrl: string | URL, + trackingIdOverride?: string | null, + now: Date = new Date() +): ParsedImpactAffiliateTouch | null { + const url = + typeof candidateUrl === 'string' ? new URL(candidateUrl, 'http://localhost') : candidateUrl; + const searchParams = url.searchParams; + const trackingValue = sanitizeOpaqueTrackingValue( + trackingIdOverride ?? searchParams.get('im_ref') + ); + + if (!trackingValue.acceptedValue && trackingValue.originalLength === 0) { + return null; + } + + return { + trackingId: trackingValue.acceptedValue, + trackingValueLength: trackingValue.originalLength, + isTrackingValueAccepted: trackingValue.isAccepted, + landingPath: landingPathFromUrl(url), + utmSource: sanitizeMetadataValue(searchParams.get('utm_source')), + utmMedium: sanitizeMetadataValue(searchParams.get('utm_medium')), + utmCampaign: sanitizeMetadataValue(searchParams.get('utm_campaign')), + utmTerm: sanitizeMetadataValue(searchParams.get('utm_term')), + utmContent: sanitizeMetadataValue(searchParams.get('utm_content')), + touchedAt: now, + expiresAt: new Date(now.getTime() + IMPACT_REFERRAL_TOUCH_VALIDITY_MS), + }; +} diff --git a/apps/web/src/lib/impact-referral.test.ts b/apps/web/src/lib/impact-referral.test.ts new file mode 100644 index 0000000000..c9c308c553 --- /dev/null +++ b/apps/web/src/lib/impact-referral.test.ts @@ -0,0 +1,255 @@ +process.env.NEXTAUTH_SECRET ||= 'test-nextauth-secret'; +process.env.TURNSTILE_SECRET_KEY ||= 'test-turnstile-secret'; + +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { eq, sql } from 'drizzle-orm'; + +import { db } from '@/lib/drizzle'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { + impact_advocate_participants, + impact_advocate_registration_attempts, + kilocode_users, +} from '@kilocode/db/schema'; + +describe('impact referral participant registration dispatch', () => { + beforeEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + process.env.IMPACT_ACCOUNT_SID = 'impact-account-sid'; + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-advocate-account-sid'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'impact-advocate-auth-token'; + process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await db.delete(impact_advocate_registration_attempts).where(sql`true`); + await db.delete(impact_advocate_participants).where(sql`true`); + await db.delete(kilocode_users).where(sql`true`); + }); + + it('delivers queued participant registrations and marks the participant registered', async () => { + const fetchMock = jest + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ participantId: 'impact-participant-1' }), { status: 200 }) + ); + global.fetch = fetchMock; + + const user = await insertTestUser({ + google_user_email: 'participant@example.com', + normalized_email: 'participant@example.com', + }); + + const { + dispatchQueuedImpactAdvocateRegistrationAttempts, + queueImpactAdvocateParticipantRegistration, + } = await import('@/lib/impact-referral'); + + await queueImpactAdvocateParticipantRegistration({ + user, + referralTouch: { + opaqueTrackingValue: 'sq-cookie', + trackingValueLength: 9, + isTrackingValueAccepted: true, + rsCode: 'ref-code', + rsShareMedium: 'email', + rsEngagementMedium: 'link', + landingPath: '/get-started?_saasquatch=sq-cookie', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: new Date('2026-04-23T00:00:00.000Z'), + expiresAt: new Date('2026-05-23T00:00:00.000Z'), + }, + locale: 'en-US', + countryCode: 'US', + }); + + const summary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(summary).toEqual({ + claimed: 1, + delivered: 1, + retried: 0, + failed: 0, + }); + + const [participant] = await db.select().from(impact_advocate_participants); + expect(participant.registration_state).toBe('registered'); + expect(participant.registered_at).toBeTruthy(); + expect(participant.last_error_code).toBeNull(); + + const [attempt] = await db.select().from(impact_advocate_registration_attempts); + expect(attempt.delivery_state).toBe('succeeded'); + expect(attempt.attempt_count).toBe(1); + expect(attempt.next_retry_at).toBeNull(); + expect(attempt.response_status_code).toBe(200); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.impact.com/Advocate/tenant-alias/Programs/51699/Participants', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: + 'Basic ' + + Buffer.from('impact-advocate-account-sid:impact-advocate-auth-token').toString( + 'base64' + ), + Accept: 'application/json', + 'Content-Type': 'application/json', + }), + }) + ); + const requestBody = fetchMock.mock.calls[0]?.[1]?.body; + expect(typeof requestBody).toBe('string'); + expect(JSON.parse(String(requestBody))).toEqual({ + id: user.id, + accountId: user.id, + programId: '51699', + email: user.google_user_email, + cookies: 'sq-cookie', + locale: 'en-US', + countryCode: 'US', + }); + }); + + it('keeps transient failures retryable until a later dispatch succeeds', async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce(new Response('upstream unavailable', { status: 503 })) + .mockResolvedValueOnce(new Response('{}', { status: 200 })); + global.fetch = fetchMock; + + const user = await insertTestUser({ + google_user_email: 'retrying@example.com', + normalized_email: 'retrying@example.com', + }); + + const { + dispatchQueuedImpactAdvocateRegistrationAttempts, + queueImpactAdvocateParticipantRegistration, + } = await import('@/lib/impact-referral'); + + await queueImpactAdvocateParticipantRegistration({ + user, + referralTouch: { + opaqueTrackingValue: 'sq-cookie', + trackingValueLength: 9, + isTrackingValueAccepted: true, + rsCode: 'ref-code', + rsShareMedium: null, + rsEngagementMedium: null, + landingPath: '/get-started', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: new Date('2026-04-23T00:00:00.000Z'), + expiresAt: new Date('2026-05-23T00:00:00.000Z'), + }, + }); + + const firstSummary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(firstSummary).toEqual({ + claimed: 1, + delivered: 0, + retried: 1, + failed: 0, + }); + + const [afterFirstAttempt] = await db.select().from(impact_advocate_registration_attempts); + expect(afterFirstAttempt.delivery_state).toBe('failed'); + expect(afterFirstAttempt.next_retry_at).toBeTruthy(); + + const [retryingParticipant] = await db.select().from(impact_advocate_participants); + expect(retryingParticipant.registration_state).toBe('retrying'); + + await db + .update(impact_advocate_registration_attempts) + .set({ next_retry_at: '2020-01-01T00:00:00.000Z' }) + .where(eq(impact_advocate_registration_attempts.id, afterFirstAttempt.id)); + + const secondSummary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(secondSummary).toEqual({ + claimed: 1, + delivered: 1, + retried: 0, + failed: 0, + }); + + const [registeredParticipant] = await db.select().from(impact_advocate_participants); + expect(registeredParticipant.registration_state).toBe('registered'); + }); + + it('marks 4xx failures terminal, logs them, and does not retry unchanged payloads', async () => { + const fetchMock = jest + .fn() + .mockResolvedValue(new Response('bad request', { status: 400 })); + global.fetch = fetchMock; + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + const user = await insertTestUser({ + google_user_email: 'terminal@example.com', + normalized_email: 'terminal@example.com', + }); + + const { + dispatchQueuedImpactAdvocateRegistrationAttempts, + queueImpactAdvocateParticipantRegistration, + } = await import('@/lib/impact-referral'); + + await queueImpactAdvocateParticipantRegistration({ + user, + referralTouch: { + opaqueTrackingValue: 'sq-cookie', + trackingValueLength: 9, + isTrackingValueAccepted: true, + rsCode: null, + rsShareMedium: null, + rsEngagementMedium: null, + landingPath: '/get-started', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: new Date('2026-04-23T00:00:00.000Z'), + expiresAt: new Date('2026-05-23T00:00:00.000Z'), + }, + }); + + const firstSummary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(firstSummary).toEqual({ + claimed: 1, + delivered: 0, + retried: 0, + failed: 1, + }); + + const [participant] = await db.select().from(impact_advocate_participants); + expect(participant.registration_state).toBe('failed'); + expect(participant.last_error_code).toBe('http_4xx'); + + const secondSummary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(secondSummary).toEqual({ + claimed: 0, + delivered: 0, + retried: 0, + failed: 0, + }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[impact-referral] Impact Advocate participant registration failed permanently', + expect.objectContaining({ + userId: user.id, + statusCode: 400, + failureKind: 'http_4xx', + }) + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/lib/impact-referral.ts b/apps/web/src/lib/impact-referral.ts new file mode 100644 index 0000000000..a4a3757063 --- /dev/null +++ b/apps/web/src/lib/impact-referral.ts @@ -0,0 +1,526 @@ +import 'server-only'; + +import { createHash } from 'crypto'; +import { db, type DrizzleTransaction } from '@/lib/drizzle'; +import { + buildImpactAdvocateRegisterParticipantPayload, + isImpactAdvocateConfigured, + sendImpactAdvocateRegisterParticipantPayload, + type ImpactAdvocateRegisterParticipantPayload, +} from '@/lib/impact-advocate'; +import type { + ParsedImpactAffiliateTouch, + ParsedImpactReferralTouch, +} from '@/lib/impact-referral-utils'; +import { + deleted_user_email_tombstones, + impact_advocate_participants, + impact_advocate_registration_attempts, + kiloclaw_attribution_touches, + type User, +} from '@kilocode/db/schema'; +import { + ImpactAdvocateAttemptDeliveryState, + ImpactAdvocateRegistrationState, + KiloClawAttributionTouchProvider, + KiloClawAttributionTouchType, +} from '@kilocode/db/schema-types'; +import { and, asc, eq, lte, or, sql } from 'drizzle-orm'; + +type DatabaseClient = typeof db | DrizzleTransaction; + +type AttributionActor = { + userId?: string | null; + anonymousId?: string | null; +}; + +export type ImpactAdvocateRegistrationDispatchSummary = { + claimed: number; + delivered: number; + retried: number; + failed: number; +}; + +function getDatabaseClient(database?: DatabaseClient): DatabaseClient { + return database ?? db; +} + +function buildHashedDedupeKey(parts: Array): string { + const normalized = parts.map(part => part?.trim() ?? '').join('|'); + return createHash('sha256').update(normalized, 'utf8').digest('hex'); +} + +function touchMinuteBucket(touchedAt: Date): string { + return touchedAt.toISOString().slice(0, 16); +} + +function touchIdentity(actor: AttributionActor): string { + if (actor.userId) return `user:${actor.userId}`; + if (actor.anonymousId) return `anon:${actor.anonymousId}`; + return 'anonymous:missing'; +} + +function isImpactAdvocateRegisterParticipantPayload( + value: Record | null +): value is ImpactAdvocateRegisterParticipantPayload { + if (!value) { + return false; + } + + return ( + typeof value.id === 'string' && + typeof value.accountId === 'string' && + typeof value.programId === 'string' && + typeof value.email === 'string' && + typeof value.cookies === 'string' && + (value.locale === undefined || typeof value.locale === 'string') && + (value.countryCode === undefined || typeof value.countryCode === 'string') + ); +} + +export function hashNormalizedEmailForDeletionTombstone(normalizedEmail: string): string { + return createHash('sha256').update(normalizedEmail.trim().toLowerCase(), 'utf8').digest('hex'); +} + +export async function recordImpactAffiliateTouch(params: { + database?: DatabaseClient; + userId?: string | null; + anonymousId?: string | null; + touch: ParsedImpactAffiliateTouch; +}): Promise { + const database = getDatabaseClient(params.database); + const dedupeKey = buildHashedDedupeKey([ + touchIdentity(params), + KiloClawAttributionTouchType.Affiliate, + KiloClawAttributionTouchProvider.ImpactPerformance, + params.touch.trackingId, + params.touch.landingPath, + touchMinuteBucket(params.touch.touchedAt), + ]); + + await database + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: dedupeKey, + anonymous_id: params.anonymousId ?? null, + user_id: params.userId ?? null, + touch_type: KiloClawAttributionTouchType.Affiliate, + provider: KiloClawAttributionTouchProvider.ImpactPerformance, + opaque_tracking_value: params.touch.trackingId, + tracking_value_length: params.touch.trackingValueLength, + is_tracking_value_accepted: params.touch.isTrackingValueAccepted, + im_ref: params.touch.trackingId, + landing_path: params.touch.landingPath, + utm_source: params.touch.utmSource, + utm_medium: params.touch.utmMedium, + utm_campaign: params.touch.utmCampaign, + utm_term: params.touch.utmTerm, + utm_content: params.touch.utmContent, + touched_at: params.touch.touchedAt.toISOString(), + expires_at: params.touch.expiresAt.toISOString(), + }) + .onConflictDoNothing({ target: [kiloclaw_attribution_touches.dedupe_key] }); +} + +export async function recordImpactReferralTouch(params: { + database?: DatabaseClient; + userId?: string | null; + anonymousId?: string | null; + touch: ParsedImpactReferralTouch; +}): Promise { + const database = getDatabaseClient(params.database); + const dedupeKey = buildHashedDedupeKey([ + touchIdentity(params), + KiloClawAttributionTouchType.Referral, + KiloClawAttributionTouchProvider.ImpactAdvocate, + params.touch.opaqueTrackingValue, + params.touch.rsCode, + params.touch.landingPath, + touchMinuteBucket(params.touch.touchedAt), + ]); + + await database + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: dedupeKey, + anonymous_id: params.anonymousId ?? null, + user_id: params.userId ?? null, + touch_type: KiloClawAttributionTouchType.Referral, + provider: KiloClawAttributionTouchProvider.ImpactAdvocate, + opaque_tracking_value: params.touch.opaqueTrackingValue, + tracking_value_length: params.touch.trackingValueLength, + is_tracking_value_accepted: params.touch.isTrackingValueAccepted, + rs_code: params.touch.rsCode, + rs_share_medium: params.touch.rsShareMedium, + rs_engagement_medium: params.touch.rsEngagementMedium, + landing_path: params.touch.landingPath, + utm_source: params.touch.utmSource, + utm_medium: params.touch.utmMedium, + utm_campaign: params.touch.utmCampaign, + utm_term: params.touch.utmTerm, + utm_content: params.touch.utmContent, + touched_at: params.touch.touchedAt.toISOString(), + expires_at: params.touch.expiresAt.toISOString(), + }) + .onConflictDoNothing({ target: [kiloclaw_attribution_touches.dedupe_key] }); +} + +export async function ensureImpactAdvocateParticipantProfile(params: { + database?: DatabaseClient; + user: Pick; + locale?: string | null; + countryCode?: string | null; + opaqueReferralIdentifier?: string | null; +}): Promise<{ id: string }> { + const database = getDatabaseClient(params.database); + + const [insertedParticipant] = await database + .insert(impact_advocate_participants) + .values({ + user_id: params.user.id, + advocate_id: params.user.id, + advocate_account_id: params.user.id, + opaque_referral_identifier: params.opaqueReferralIdentifier ?? null, + contact_email: params.user.google_user_email, + locale: params.locale ?? null, + country_code: params.countryCode ?? null, + registration_state: isImpactAdvocateConfigured() + ? ImpactAdvocateRegistrationState.Pending + : ImpactAdvocateRegistrationState.Failed, + last_error_code: isImpactAdvocateConfigured() ? null : 'missing_configuration', + last_error_message: isImpactAdvocateConfigured() + ? null + : 'Impact Advocate configuration is incomplete', + }) + .onConflictDoNothing({ target: [impact_advocate_participants.user_id] }) + .returning({ id: impact_advocate_participants.id }); + + const participant = + insertedParticipant ?? + (await database.query.impact_advocate_participants.findFirst({ + where: eq(impact_advocate_participants.user_id, params.user.id), + columns: { id: true }, + })); + + if (!participant) { + throw new Error(`Impact Advocate participant missing for user ${params.user.id}`); + } + + await database + .update(impact_advocate_participants) + .set({ + advocate_id: params.user.id, + advocate_account_id: params.user.id, + contact_email: params.user.google_user_email, + locale: params.locale ?? null, + country_code: params.countryCode ?? null, + ...(params.opaqueReferralIdentifier + ? { opaque_referral_identifier: params.opaqueReferralIdentifier } + : {}), + }) + .where(eq(impact_advocate_participants.id, participant.id)); + + return { id: participant.id }; +} + +export async function queueImpactAdvocateParticipantRegistration(params: { + database?: DatabaseClient; + user: Pick; + referralTouch: ParsedImpactReferralTouch; + locale?: string | null; + countryCode?: string | null; +}): Promise { + if (!params.referralTouch.opaqueTrackingValue) { + return; + } + + const database = getDatabaseClient(params.database); + const payload = buildImpactAdvocateRegisterParticipantPayload({ + user: params.user, + referralCookieValue: params.referralTouch.opaqueTrackingValue, + locale: params.locale, + countryCode: params.countryCode, + }); + const nowIso = new Date().toISOString(); + const participant = await ensureImpactAdvocateParticipantProfile({ + database, + user: params.user, + locale: params.locale, + countryCode: params.countryCode, + }); + + await database + .update(impact_advocate_participants) + .set({ + registration_state: isImpactAdvocateConfigured() + ? ImpactAdvocateRegistrationState.Pending + : ImpactAdvocateRegistrationState.Failed, + last_error_code: isImpactAdvocateConfigured() ? null : 'missing_configuration', + last_error_message: isImpactAdvocateConfigured() + ? null + : 'Impact Advocate configuration is incomplete', + last_registration_attempt_at: nowIso, + }) + .where(eq(impact_advocate_participants.id, participant.id)); + + const attemptDedupeKey = buildHashedDedupeKey([ + 'impact-advocate-registration', + params.user.id, + params.referralTouch.opaqueTrackingValue, + ]); + + await database + .insert(impact_advocate_registration_attempts) + .values({ + participant_id: participant.id, + dedupe_key: attemptDedupeKey, + opaque_cookie_value: params.referralTouch.opaqueTrackingValue, + cookie_value_length: params.referralTouch.trackingValueLength, + delivery_state: isImpactAdvocateConfigured() + ? ImpactAdvocateAttemptDeliveryState.Queued + : ImpactAdvocateAttemptDeliveryState.Failed, + request_payload: payload satisfies Record, + response_payload: isImpactAdvocateConfigured() + ? null + : ({ error: 'missing_configuration' } satisfies Record), + response_status_code: isImpactAdvocateConfigured() ? null : 503, + }) + .onConflictDoNothing({ target: [impact_advocate_registration_attempts.dedupe_key] }); +} + +export async function createDeletedUserEmailTombstone(params: { + database?: DatabaseClient; + normalizedEmail: string | null; +}): Promise { + if (!params.normalizedEmail) { + return; + } + + const database = getDatabaseClient(params.database); + await database + .insert(deleted_user_email_tombstones) + .values({ + normalized_email_hash: hashNormalizedEmailForDeletionTombstone(params.normalizedEmail), + }) + .onConflictDoNothing({ target: [deleted_user_email_tombstones.normalized_email_hash] }); +} + +export function localeFromHeaders(headers?: Headers): string | null { + const acceptLanguage = headers?.get('accept-language')?.trim(); + if (!acceptLanguage) return null; + return acceptLanguage.split(',')[0]?.trim() || null; +} + +export function countryCodeFromHeaders(headers?: Headers): string | null { + const countryCode = headers?.get('x-vercel-ip-country')?.trim(); + return countryCode ? countryCode : null; +} + +function registrationBackoffDelayMs(attemptCount: number): number { + const maxDelayMs = 60 * 60 * 1000; + const initialDelayMs = 60 * 1000; + return Math.min(initialDelayMs * 2 ** Math.max(attemptCount, 0), maxDelayMs); +} + +function nextRegistrationRetryAt(attemptCount: number): string { + return new Date(Date.now() + registrationBackoffDelayMs(attemptCount)).toISOString(); +} + +async function dispatchImpactAdvocateRegistrationAttemptById( + attemptId: string +): Promise<'delivered' | 'retried' | 'failed'> { + const attempt = await db.query.impact_advocate_registration_attempts.findFirst({ + where: eq(impact_advocate_registration_attempts.id, attemptId), + }); + if (!attempt) { + return 'failed'; + } + + const participant = await db.query.impact_advocate_participants.findFirst({ + where: eq(impact_advocate_participants.id, attempt.participant_id), + }); + if (!participant) { + return 'failed'; + } + + const payload = attempt.request_payload; + if (!isImpactAdvocateRegisterParticipantPayload(payload)) { + const failedAt = new Date().toISOString(); + await db.transaction(async tx => { + await tx + .update(impact_advocate_registration_attempts) + .set({ + delivery_state: ImpactAdvocateAttemptDeliveryState.Failed, + attempt_count: attempt.attempt_count + 1, + next_retry_at: null, + claimed_at: failedAt, + response_payload: { + error: 'missing_request_payload', + } satisfies Record, + }) + .where(eq(impact_advocate_registration_attempts.id, attempt.id)); + + await tx + .update(impact_advocate_participants) + .set({ + registration_state: ImpactAdvocateRegistrationState.Failed, + last_error_code: 'missing_request_payload', + last_error_message: 'Impact Advocate registration attempt is missing request_payload', + last_registration_attempt_at: failedAt, + }) + .where(eq(impact_advocate_participants.id, participant.id)); + }); + return 'failed'; + } + + const sendingAt = new Date().toISOString(); + await db + .update(impact_advocate_registration_attempts) + .set({ + delivery_state: ImpactAdvocateAttemptDeliveryState.Sending, + claimed_at: sendingAt, + }) + .where(eq(impact_advocate_registration_attempts.id, attempt.id)); + + const result = await sendImpactAdvocateRegisterParticipantPayload(payload); + const attemptCount = attempt.attempt_count + 1; + const completedAt = new Date().toISOString(); + + if (result.ok) { + await db.transaction(async tx => { + await tx + .update(impact_advocate_registration_attempts) + .set({ + delivery_state: ImpactAdvocateAttemptDeliveryState.Succeeded, + attempt_count: attemptCount, + next_retry_at: null, + claimed_at: completedAt, + response_status_code: result.statusCode ?? null, + response_payload: { + responseBody: result.responseBody ?? null, + } satisfies Record, + }) + .where(eq(impact_advocate_registration_attempts.id, attempt.id)); + + await tx + .update(impact_advocate_participants) + .set({ + registration_state: ImpactAdvocateRegistrationState.Registered, + registered_at: completedAt, + last_registration_attempt_at: completedAt, + last_error_code: null, + last_error_message: null, + }) + .where(eq(impact_advocate_participants.id, participant.id)); + }); + return 'delivered'; + } + + const isTerminalFailure = result.failureKind === 'http_4xx'; + if (isTerminalFailure) { + console.error('[impact-referral] Impact Advocate participant registration failed permanently', { + attemptId: attempt.id, + participantId: participant.id, + userId: participant.user_id, + statusCode: result.statusCode ?? null, + failureKind: result.failureKind, + }); + } + + await db.transaction(async tx => { + await tx + .update(impact_advocate_registration_attempts) + .set({ + delivery_state: ImpactAdvocateAttemptDeliveryState.Failed, + attempt_count: attemptCount, + next_retry_at: isTerminalFailure ? null : nextRegistrationRetryAt(attemptCount), + claimed_at: completedAt, + response_status_code: result.statusCode ?? null, + response_payload: { + failureKind: result.failureKind, + responseBody: result.responseBody ?? null, + error: result.error ?? null, + } satisfies Record, + }) + .where(eq(impact_advocate_registration_attempts.id, attempt.id)); + + await tx + .update(impact_advocate_participants) + .set({ + registration_state: isTerminalFailure + ? ImpactAdvocateRegistrationState.Failed + : ImpactAdvocateRegistrationState.Retrying, + last_registration_attempt_at: completedAt, + last_error_code: isTerminalFailure ? 'http_4xx' : result.failureKind, + last_error_message: + result.error ?? + (result.statusCode + ? `Impact Advocate registration failed with status ${result.statusCode}` + : 'Impact Advocate registration failed'), + }) + .where(eq(impact_advocate_participants.id, participant.id)); + }); + + return isTerminalFailure ? 'failed' : 'retried'; +} + +export async function dispatchQueuedImpactAdvocateRegistrationAttempts(params?: { + limit?: number; +}): Promise { + const limit = params?.limit ?? 100; + const nowIso = new Date().toISOString(); + const rows = await db + .select({ id: impact_advocate_registration_attempts.id }) + .from(impact_advocate_registration_attempts) + .innerJoin( + impact_advocate_participants, + eq(impact_advocate_participants.id, impact_advocate_registration_attempts.participant_id) + ) + .where( + or( + eq( + impact_advocate_registration_attempts.delivery_state, + ImpactAdvocateAttemptDeliveryState.Queued + ), + and( + eq( + impact_advocate_participants.registration_state, + ImpactAdvocateRegistrationState.Retrying + ), + eq( + impact_advocate_registration_attempts.delivery_state, + ImpactAdvocateAttemptDeliveryState.Failed + ), + or( + sql`${impact_advocate_registration_attempts.next_retry_at} IS NULL`, + lte(impact_advocate_registration_attempts.next_retry_at, nowIso) + ) + ) + ) + ) + .orderBy( + asc(impact_advocate_registration_attempts.created_at), + asc(impact_advocate_registration_attempts.id) + ) + .limit(limit); + + const summary: ImpactAdvocateRegistrationDispatchSummary = { + claimed: rows.length, + delivered: 0, + retried: 0, + failed: 0, + }; + + for (const row of rows) { + const outcome = await dispatchImpactAdvocateRegistrationAttemptById(row.id); + if (outcome === 'delivered') { + summary.delivered++; + } else if (outcome === 'retried') { + summary.retried++; + } else { + summary.failed++; + } + } + + return summary; +} diff --git a/apps/web/src/lib/kiloclaw-referrals.test.ts b/apps/web/src/lib/kiloclaw-referrals.test.ts new file mode 100644 index 0000000000..8130059d3d --- /dev/null +++ b/apps/web/src/lib/kiloclaw-referrals.test.ts @@ -0,0 +1,1189 @@ +import { randomUUID } from 'crypto'; +import { eq, sql } from 'drizzle-orm'; + +jest.mock('@/lib/impact', () => { + const actual = jest.requireActual('@/lib/impact'); + return { + ...actual, + isImpactConfigured: jest.fn(() => true), + sendImpactConversionPayload: jest.fn(async () => ({ ok: true, delivery: 'accepted' })), + reverseImpactAction: jest.fn(async () => ({ ok: true, delivery: 'accepted' })), + }; +}); + +jest.mock('@/lib/impact-advocate', () => { + const actual = jest.requireActual('@/lib/impact-advocate'); + return { + ...actual, + isImpactAdvocateConfigured: jest.fn(() => true), + }; +}); + +jest.mock('@/lib/stripe-client', () => ({ + client: { + subscriptions: { + update: jest.fn(async () => ({})), + }, + }, +})); + +import { db } from '@/lib/drizzle'; +import { + credit_transactions, + impact_advocate_participants, + impact_conversion_reports, + kiloclaw_attribution_touches, + kiloclaw_instances, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_subscription_change_log, + kiloclaw_subscriptions, + kilocode_users, + referral_codes, + user_affiliate_attributions, + type KiloClawAttributionTouch, +} from '@kilocode/db/schema'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { + markPersonalKiloClawReferralPaymentAdverse, + processPersonalKiloClawPaidConversion, + processQueuedKiloClawReferralRewards, + resolveWinningAttributionTouch, +} from '@/lib/kiloclaw-referrals'; +import { isImpactConfigured, reverseImpactAction, sendImpactConversionPayload } from '@/lib/impact'; +import { isImpactAdvocateConfigured } from '@/lib/impact-advocate'; +import { client as stripeClient } from '@/lib/stripe-client'; + +const mockIsImpactConfigured = jest.mocked(isImpactConfigured); +const mockIsImpactAdvocateConfigured = jest.mocked(isImpactAdvocateConfigured); +const mockSendImpactConversionPayload = jest.mocked(sendImpactConversionPayload); +const mockReverseImpactAction = jest.mocked(reverseImpactAction); +const mockStripeSubscriptionUpdate = jest.mocked(stripeClient.subscriptions.update); + +function makeTouch( + overrides: Partial & Pick +): KiloClawAttributionTouch { + const touchedAt = overrides.touched_at ?? '2026-04-01T00:00:00.000Z'; + return { + id: overrides.id ?? randomUUID(), + dedupe_key: overrides.dedupe_key ?? randomUUID(), + anonymous_id: overrides.anonymous_id ?? null, + user_id: overrides.user_id ?? 'user_123', + touch_type: overrides.touch_type, + provider: + overrides.provider ?? + (overrides.touch_type === 'referral' ? 'impact_advocate' : 'impact_performance'), + opaque_tracking_value: overrides.opaque_tracking_value ?? 'opaque-value', + tracking_value_length: overrides.tracking_value_length ?? 12, + is_tracking_value_accepted: overrides.is_tracking_value_accepted ?? true, + rs_code: overrides.rs_code ?? null, + rs_share_medium: overrides.rs_share_medium ?? null, + rs_engagement_medium: overrides.rs_engagement_medium ?? null, + im_ref: overrides.im_ref ?? null, + landing_path: overrides.landing_path ?? null, + utm_source: overrides.utm_source ?? null, + utm_medium: overrides.utm_medium ?? null, + utm_campaign: overrides.utm_campaign ?? null, + utm_term: overrides.utm_term ?? null, + utm_content: overrides.utm_content ?? null, + touched_at: touchedAt, + expires_at: overrides.expires_at ?? '2026-05-01T00:00:00.000Z', + sale_attributed_at: overrides.sale_attributed_at ?? null, + created_at: overrides.created_at ?? touchedAt, + }; +} + +async function insertActivePersonalSubscription( + userId: string, + overrides?: Partial & { + organizationId?: string | null; + } +): Promise<{ subscriptionId: string; instanceId: string }> { + const [instance] = await db + .insert(kiloclaw_instances) + .values({ + user_id: userId, + sandbox_id: `sandbox-${userId}`, + organization_id: overrides?.organizationId ?? null, + }) + .returning({ id: kiloclaw_instances.id }); + + const [subscription] = await db + .insert(kiloclaw_subscriptions) + .values({ + user_id: userId, + instance_id: instance.id, + payment_source: 'credits', + plan: 'standard', + status: 'active', + current_period_start: '2026-04-01T00:00:00.000Z', + current_period_end: '2026-05-01T00:00:00.000Z', + credit_renewal_at: '2026-05-01T00:00:00.000Z', + cancel_at_period_end: false, + ...overrides, + }) + .returning({ id: kiloclaw_subscriptions.id }); + + return { + subscriptionId: subscription.id, + instanceId: instance.id, + }; +} + +async function insertImpactAdvocateParticipant(userId: string, opaqueReferralIdentifier?: string) { + const identifier = opaqueReferralIdentifier ?? randomUUID(); + await db.insert(impact_advocate_participants).values({ + user_id: userId, + advocate_id: userId, + advocate_account_id: userId, + opaque_referral_identifier: identifier, + contact_email: `${userId}@example.com`, + registration_state: 'registered', + registered_at: '2026-03-01T00:00:00.000Z', + }); + return identifier; +} + +describe('kiloclaw referrals', () => { + afterEach(async () => { + jest.clearAllMocks(); + mockIsImpactConfigured.mockReturnValue(true); + mockIsImpactAdvocateConfigured.mockReturnValue(true); + await db.delete(impact_conversion_reports).where(sql`true`); + await db.delete(kiloclaw_referral_reward_applications).where(sql`true`); + await db.delete(kiloclaw_referral_rewards).where(sql`true`); + await db.delete(kiloclaw_referral_reward_decisions).where(sql`true`); + await db.delete(kiloclaw_referral_conversions).where(sql`true`); + await db.delete(user_affiliate_attributions).where(sql`true`); + await db.delete(kiloclaw_attribution_touches).where(sql`true`); + await db.delete(credit_transactions).where(sql`true`); + await db.delete(kiloclaw_subscription_change_log).where(sql`true`); + await db.delete(kiloclaw_subscriptions).where(sql`true`); + await db.delete(kiloclaw_instances).where(sql`true`); + await db.delete(impact_advocate_participants).where(sql`true`); + await db.delete(referral_codes).where(sql`true`); + await db.delete(kilocode_users).where(sql`true`); + }); + + describe('resolveWinningAttributionTouch', () => { + const convertedAt = new Date('2026-04-10T00:00:00.000Z'); + + it('prefers referral over an unsold affiliate touch', () => { + const affiliateTouch = makeTouch({ + id: 'affiliate-touch', + touch_type: 'affiliate', + touched_at: '2026-04-01T00:00:00.000Z', + im_ref: 'im-ref', + }); + const referralTouch = makeTouch({ + id: 'referral-touch', + touch_type: 'referral', + touched_at: '2026-04-02T00:00:00.000Z', + rs_code: 'ref-code', + }); + + expect( + resolveWinningAttributionTouch({ touches: [affiliateTouch, referralTouch], convertedAt }) + ).toMatchObject({ winner: 'referral', referralTouch: { id: 'referral-touch' } }); + }); + + it('preserves affiliate when it had already been sale-attributed before the referral touch', () => { + const affiliateTouch = makeTouch({ + id: 'affiliate-touch', + touch_type: 'affiliate', + touched_at: '2026-04-01T00:00:00.000Z', + sale_attributed_at: '2026-04-01T12:00:00.000Z', + im_ref: 'im-ref', + }); + const referralTouch = makeTouch({ + id: 'referral-touch', + touch_type: 'referral', + touched_at: '2026-04-02T00:00:00.000Z', + rs_code: 'ref-code', + }); + + expect( + resolveWinningAttributionTouch({ touches: [affiliateTouch, referralTouch], convertedAt }) + ).toMatchObject({ winner: 'affiliate', affiliateTouch: { id: 'affiliate-touch' } }); + }); + + it('keeps referral priority when the referral touch happened first', () => { + const referralTouch = makeTouch({ + id: 'referral-touch', + touch_type: 'referral', + touched_at: '2026-04-01T00:00:00.000Z', + rs_code: 'ref-code', + }); + const affiliateTouch = makeTouch({ + id: 'affiliate-touch', + touch_type: 'affiliate', + touched_at: '2026-04-02T00:00:00.000Z', + im_ref: 'im-ref', + }); + + expect( + resolveWinningAttributionTouch({ touches: [affiliateTouch, referralTouch], convertedAt }) + ).toMatchObject({ winner: 'referral', referralTouch: { id: 'referral-touch' } }); + }); + + it('falls back to affiliate when no valid referral exists', () => { + const affiliateTouch = makeTouch({ + id: 'affiliate-touch', + touch_type: 'affiliate', + im_ref: 'im-ref', + }); + + expect( + resolveWinningAttributionTouch({ touches: [affiliateTouch], convertedAt }) + ).toMatchObject({ winner: 'affiliate', affiliateTouch: { id: 'affiliate-touch' } }); + }); + + it('falls back to referral when no affiliate exists', () => { + const referralTouch = makeTouch({ + id: 'referral-touch', + touch_type: 'referral', + rs_code: 'ref-code', + }); + + expect( + resolveWinningAttributionTouch({ touches: [referralTouch], convertedAt }) + ).toMatchObject({ winner: 'referral', referralTouch: { id: 'referral-touch' } }); + }); + + it('returns none when all touches are expired or invalid', () => { + const expiredAffiliateTouch = makeTouch({ + id: 'affiliate-touch', + touch_type: 'affiliate', + im_ref: 'im-ref', + expires_at: '2026-04-05T00:00:00.000Z', + }); + const invalidReferralTouch = makeTouch({ + id: 'referral-touch', + touch_type: 'referral', + rs_code: 'ref-code', + opaque_tracking_value: null, + is_tracking_value_accepted: false, + }); + + expect( + resolveWinningAttributionTouch({ + touches: [expiredAffiliateTouch, invalidReferralTouch], + convertedAt, + }) + ).toEqual({ + winner: 'none', + affiliateTouch: null, + referralTouch: null, + }); + }); + }); + + describe('processPersonalKiloClawPaidConversion', () => { + it('records affiliate-winning first paid conversions and marks the touch as sale-attributed', async () => { + const user = await insertTestUser({ + google_user_email: 'affiliate-winner@example.com', + normalized_email: 'affiliate-winner@example.com', + }); + const sourcePaymentId = 'kiloclaw-subscription:instance-a:2026-04'; + + await insertActivePersonalSubscription(user.id); + await db.insert(credit_transactions).values({ + kilo_user_id: user.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + const affiliateTouchId = '11111111-1111-4111-8111-111111111111'; + await db.insert(kiloclaw_attribution_touches).values({ + id: affiliateTouchId, + dedupe_key: 'affiliate-touch', + user_id: user.id, + touch_type: 'affiliate', + provider: 'impact_performance', + opaque_tracking_value: 'im-ref-123', + tracking_value_length: 10, + is_tracking_value_accepted: true, + im_ref: 'im-ref-123', + touched_at: '2026-04-01T00:00:00.000Z', + expires_at: '2026-05-01T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: user.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: true, + winningTouchType: 'affiliate', + conversionId: expect.any(String), + disqualificationReason: 'referral_affiliate_won', + }); + + const [touch] = await db + .select() + .from(kiloclaw_attribution_touches) + .where(eq(kiloclaw_attribution_touches.id, affiliateTouchId)); + expect(touch.sale_attributed_at).toBeTruthy(); + expect(mockSendImpactConversionPayload).not.toHaveBeenCalled(); + }); + + it('records referral-winning first paid conversions, grants both sides, and queues impact reporting', async () => { + const referrer = await insertTestUser({ + google_user_email: 'referrer@example.com', + normalized_email: 'referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'referee@example.com', + normalized_email: 'referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-b:2026-04'; + + await insertActivePersonalSubscription(referrer.id); + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '22222222-2222-4222-8222-222222222222', + dedupe_key: 'referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition.shouldEnqueueAffiliateSale).toBe(false); + expect(disposition.winningTouchType).toBe('referral'); + expect(disposition.disqualificationReason).toBeNull(); + + const decisions = await db + .select() + .from(kiloclaw_referral_reward_decisions) + .where( + eq(kiloclaw_referral_reward_decisions.conversion_id, disposition.conversionId ?? '') + ); + expect(decisions).toHaveLength(2); + expect(decisions.map(decision => decision.outcome).sort()).toEqual(['granted', 'granted']); + + const rewards = await db + .select() + .from(kiloclaw_referral_rewards) + .where(eq(kiloclaw_referral_rewards.conversion_id, disposition.conversionId ?? '')); + expect(rewards).toHaveLength(2); + expect(rewards.map(reward => reward.status).sort()).toEqual(['applied', 'applied']); + + const applications = await db.select().from(kiloclaw_referral_reward_applications); + expect(applications).toHaveLength(2); + expect( + applications.map(application => String(application.new_renewal_boundary)).sort() + ).toEqual(['2026-06-01 00:00:00+00', '2026-06-01 00:00:00+00']); + + const subscriptions = await db + .select({ + userId: kiloclaw_subscriptions.user_id, + currentPeriodEnd: kiloclaw_subscriptions.current_period_end, + creditRenewalAt: kiloclaw_subscriptions.credit_renewal_at, + }) + .from(kiloclaw_subscriptions) + .where(eq(kiloclaw_subscriptions.plan, 'standard')); + expect(subscriptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + userId: referrer.id, + currentPeriodEnd: '2026-06-01 00:00:00+00', + creditRenewalAt: '2026-06-01 00:00:00+00', + }), + expect.objectContaining({ + userId: referee.id, + currentPeriodEnd: '2026-06-01 00:00:00+00', + creditRenewalAt: '2026-06-01 00:00:00+00', + }), + ]) + ); + + const reports = await db.select().from(impact_conversion_reports); + expect(reports).toHaveLength(1); + expect(reports[0].state).toBe('delivered'); + expect(mockSendImpactConversionPayload).toHaveBeenCalledTimes(1); + }); + + it('logs terminal 4xx Impact conversion report failures and stops retrying unchanged payloads', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + mockSendImpactConversionPayload.mockResolvedValueOnce({ + ok: false, + failureKind: 'http_4xx', + statusCode: 400, + responseBody: 'bad request', + }); + + const referrer = await insertTestUser({ + google_user_email: 'terminal-report-referrer@example.com', + normalized_email: 'terminal-report-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'terminal-report-referee@example.com', + normalized_email: 'terminal-report-referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-terminal-report:2026-04'; + + await insertActivePersonalSubscription(referrer.id); + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '12121212-1212-4212-8212-121212121212', + dedupe_key: 'terminal-report-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + conversionId: expect.any(String), + disqualificationReason: null, + }); + + const [report] = await db.select().from(impact_conversion_reports); + expect(report.state).toBe('failed'); + expect(report.next_retry_at).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[kiloclaw-referrals] Impact conversion report failed permanently', + expect.objectContaining({ + reportId: report.id, + conversionId: disposition.conversionId, + statusCode: 400, + failureKind: 'http_4xx', + }) + ); + }); + + it('fails closed when reward-bearing referral configuration is missing', async () => { + mockIsImpactConfigured.mockReturnValue(false); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + const referrer = await insertTestUser({ + google_user_email: 'config-referrer@example.com', + normalized_email: 'config-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'config-referee@example.com', + normalized_email: 'config-referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-config:2026-04'; + + await insertActivePersonalSubscription(referrer.id); + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '77777777-7777-4777-8777-777777777777', + dedupe_key: 'missing-config-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + conversionId: expect.any(String), + disqualificationReason: 'referral_missing_configuration', + }); + + const decisions = await db + .select({ + beneficiaryRole: kiloclaw_referral_reward_decisions.beneficiary_role, + outcome: kiloclaw_referral_reward_decisions.outcome, + reason: kiloclaw_referral_reward_decisions.reason, + }) + .from(kiloclaw_referral_reward_decisions) + .where( + eq(kiloclaw_referral_reward_decisions.conversion_id, disposition.conversionId ?? '') + ); + expect(decisions).toHaveLength(2); + expect(decisions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + beneficiaryRole: 'referee', + outcome: 'disqualified', + reason: 'referral_missing_configuration', + }), + expect.objectContaining({ + beneficiaryRole: 'referrer', + outcome: 'disqualified', + reason: 'referral_missing_configuration', + }), + ]) + ); + + const rewards = await db.select().from(kiloclaw_referral_rewards); + expect(rewards).toHaveLength(0); + + const reports = await db.select().from(impact_conversion_reports); + expect(reports).toHaveLength(1); + expect(reports[0].state).toBe('failed'); + expect(reports[0].response_payload).toMatchObject({ + error: 'missing_reward_bearing_referral_configuration', + }); + expect(mockSendImpactConversionPayload).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[kiloclaw-referrals] reward-bearing referral configuration is incomplete', + expect.objectContaining({ + sourcePaymentId, + userId: referee.id, + impactPerformanceConfigured: false, + impactAdvocateConfigured: true, + }) + ); + } finally { + consoleErrorSpy.mockRestore(); + } + }); + + it('disqualifies referral touches captured after the user already existed', async () => { + const referrer = await insertTestUser({ + google_user_email: 'old-referrer@example.com', + normalized_email: 'old-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'existing-referee@example.com', + normalized_email: 'existing-referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-c:2026-04'; + + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '33333333-3333-4333-8333-333333333333', + dedupe_key: 'late-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2030-01-01T00:00:00.000Z', + expires_at: '2030-02-01T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2030-01-05T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + conversionId: expect.any(String), + disqualificationReason: 'referral_existing_user_before_touch', + }); + + const rewards = await db.select().from(kiloclaw_referral_rewards); + expect(rewards).toHaveLength(0); + expect(mockSendImpactConversionPayload).not.toHaveBeenCalled(); + }); + + it('does not preserve affiliate renewals when no affiliate touch has previously won a sale', async () => { + const referee = await insertTestUser({ + google_user_email: 'renewal-no-sale-touch@example.com', + normalized_email: 'renewal-no-sale-touch@example.com', + }); + + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values([ + { + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: 'kiloclaw-subscription:instance-renewal:2026-03', + }, + { + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard renewal', + credit_category: 'kiloclaw-subscription:instance-renewal:2026-04', + }, + ]); + await db.insert(user_affiliate_attributions).values({ + user_id: referee.id, + provider: 'impact', + tracking_id: 'impact-click-123', + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '88888888-8888-4888-8888-888888888888', + dedupe_key: 'affiliate-touch-without-sale', + user_id: referee.id, + touch_type: 'affiliate', + provider: 'impact_performance', + opaque_tracking_value: 'impact-click-123', + tracking_value_length: 16, + is_tracking_value_accepted: true, + im_ref: 'impact-click-123', + touched_at: '2026-03-01T00:00:00.000Z', + expires_at: '2026-03-31T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId: 'kiloclaw-subscription:instance-renewal:2026-04', + orderId: 'kiloclaw-subscription:instance-renewal:2026-04', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'none', + conversionId: null, + disqualificationReason: 'not_first_paid_period', + }); + }); + + it('preserves affiliate renewals when a prior affiliate touch already won the sale', async () => { + const referee = await insertTestUser({ + google_user_email: 'renewal-sale-touch@example.com', + normalized_email: 'renewal-sale-touch@example.com', + }); + + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values([ + { + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: 'kiloclaw-subscription:instance-renewal-sale:2026-03', + }, + { + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard renewal', + credit_category: 'kiloclaw-subscription:instance-renewal-sale:2026-04', + }, + ]); + await db.insert(user_affiliate_attributions).values({ + user_id: referee.id, + provider: 'impact', + tracking_id: 'impact-click-456', + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '99999999-9999-4999-8999-999999999999', + dedupe_key: 'affiliate-touch-with-sale', + user_id: referee.id, + touch_type: 'affiliate', + provider: 'impact_performance', + opaque_tracking_value: 'impact-click-456', + tracking_value_length: 16, + is_tracking_value_accepted: true, + im_ref: 'impact-click-456', + touched_at: '2026-03-01T00:00:00.000Z', + expires_at: '2026-03-31T00:00:00.000Z', + sale_attributed_at: '2026-03-05T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId: 'kiloclaw-subscription:instance-renewal-sale:2026-04', + orderId: 'kiloclaw-subscription:instance-renewal-sale:2026-04', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: true, + winningTouchType: 'affiliate', + conversionId: null, + disqualificationReason: 'not_first_paid_period', + }); + }); + + it('disqualifies conversions when the user has no current personal KiloClaw subscription', async () => { + const referee = await insertTestUser({ + google_user_email: 'no-personal-sub@example.com', + normalized_email: 'no-personal-sub@example.com', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId: 'kiloclaw-subscription:instance-missing:2026-04', + orderId: 'kiloclaw-subscription:instance-missing:2026-04', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'none', + conversionId: null, + disqualificationReason: 'referral_non_personal_subscription', + }); + }); + + it('disqualifies admin-adjusted subscriptions unless explicitly overridden', async () => { + const referee = await insertTestUser({ + google_user_email: 'admin-adjusted@example.com', + normalized_email: 'admin-adjusted@example.com', + }); + const { subscriptionId } = await insertActivePersonalSubscription(referee.id); + await db.insert(kiloclaw_subscription_change_log).values({ + subscription_id: subscriptionId, + actor_type: 'system', + actor_id: 'admin-test', + action: 'admin_override', + reason: 'manual adjustment', + before_state: null, + after_state: null, + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId: 'kiloclaw-subscription:instance-admin-adjusted:2026-04', + orderId: 'kiloclaw-subscription:instance-admin-adjusted:2026-04', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'none', + conversionId: null, + disqualificationReason: 'referral_admin_adjusted_subscription', + }); + }); + + it('disqualifies explicitly flagged test conversions unless an override marks them eligible', async () => { + const referee = await insertTestUser({ + google_user_email: 'test-flagged@example.com', + normalized_email: 'test-flagged@example.com', + }); + await insertActivePersonalSubscription(referee.id); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId: 'kiloclaw-subscription:instance-test-flagged:2026-04', + orderId: 'kiloclaw-subscription:instance-test-flagged:2026-04', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + qualificationContext: { + sourceType: 'test', + }, + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'none', + conversionId: null, + disqualificationReason: 'referral_test_subscription', + }); + }); + + it('allows explicitly overridden manual conversions to continue through normal qualification', async () => { + const referee = await insertTestUser({ + google_user_email: 'override-eligible@example.com', + normalized_email: 'override-eligible@example.com', + }); + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: 'kiloclaw-subscription:instance-override-eligible:2026-04', + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + dedupe_key: 'override-eligible-affiliate-touch', + user_id: referee.id, + touch_type: 'affiliate', + provider: 'impact_performance', + opaque_tracking_value: 'impact-click-override', + tracking_value_length: 21, + is_tracking_value_accepted: true, + im_ref: 'impact-click-override', + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId: 'kiloclaw-subscription:instance-override-eligible:2026-04', + orderId: 'kiloclaw-subscription:instance-override-eligible:2026-04', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + qualificationContext: { + sourceType: 'manual_adjustment', + overrideEligible: true, + }, + }); + + expect(disposition).toEqual({ + shouldEnqueueAffiliateSale: true, + winningTouchType: 'affiliate', + conversionId: expect.any(String), + disqualificationReason: 'referral_affiliate_won', + }); + }); + + it('applies pending referrer rewards after the referrer later starts an eligible subscription', async () => { + const referrer = await insertTestUser({ + google_user_email: 'pending-referrer@example.com', + normalized_email: 'pending-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'pending-referee@example.com', + normalized_email: 'pending-referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-d:2026-04'; + + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '44444444-4444-4444-8444-444444444444', + dedupe_key: 'pending-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + const rewardsBefore = await db + .select({ + beneficiaryUserId: kiloclaw_referral_rewards.beneficiary_user_id, + status: kiloclaw_referral_rewards.status, + }) + .from(kiloclaw_referral_rewards) + .where(eq(kiloclaw_referral_rewards.conversion_id, disposition.conversionId ?? '')); + expect(rewardsBefore).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + beneficiaryUserId: referee.id, + status: 'applied', + }), + expect.objectContaining({ + beneficiaryUserId: referrer.id, + status: 'pending', + }), + ]) + ); + + await insertActivePersonalSubscription(referrer.id); + + const summary = await processQueuedKiloClawReferralRewards({ + beneficiaryUserIds: [referrer.id], + }); + expect(summary).toEqual({ + claimed: 1, + applied: 1, + expired: 0, + pending: 0, + failed: 0, + }); + + const [referrerReward] = await db + .select() + .from(kiloclaw_referral_rewards) + .where(eq(kiloclaw_referral_rewards.beneficiary_user_id, referrer.id)); + expect(referrerReward.status).toBe('applied'); + }); + + it('keeps stripe-funded reward application in sync with Stripe trial-end billing delays', async () => { + const referrer = await insertTestUser({ + google_user_email: 'stripe-referrer@example.com', + normalized_email: 'stripe-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'stripe-referee@example.com', + normalized_email: 'stripe-referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-e:2026-04'; + + await insertActivePersonalSubscription(referrer.id); + await insertActivePersonalSubscription(referee.id, { + stripe_subscription_id: 'sub_referee_123', + }); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '55555555-5555-4555-8555-555555555555', + dedupe_key: 'stripe-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(mockStripeSubscriptionUpdate).toHaveBeenCalledWith( + 'sub_referee_123', + expect.objectContaining({ + proration_behavior: 'none', + trial_end: Math.floor(new Date('2026-06-01T00:00:00.000Z').getTime() / 1000), + }), + expect.objectContaining({ + idempotencyKey: expect.stringContaining('stripe-apply'), + }) + ); + }); + + it('cancels unapplied rewards and marks applied rewards for review when the qualifying payment is charged back', async () => { + const referrer = await insertTestUser({ + google_user_email: 'reversal-referrer@example.com', + normalized_email: 'reversal-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'reversal-referee@example.com', + normalized_email: 'reversal-referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-f:2026-04'; + + mockSendImpactConversionPayload.mockResolvedValueOnce({ + ok: true, + delivery: 'immediate', + actionId: '1000.2000.3000', + responseBody: '{}', + }); + + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '66666666-6666-4666-8666-666666666666', + dedupe_key: 'reversal-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + const summary = await markPersonalKiloClawReferralPaymentAdverse({ + sourcePaymentId, + reason: 'chargeback', + occurredAt: new Date('2026-04-15T00:00:00.000Z'), + }); + expect(summary).toEqual({ + conversionId: expect.any(String), + canceledRewards: 1, + reviewRequiredRewards: 1, + impactActionReversed: true, + }); + + const rewards = await db + .select({ + beneficiaryUserId: kiloclaw_referral_rewards.beneficiary_user_id, + status: kiloclaw_referral_rewards.status, + reviewReason: kiloclaw_referral_rewards.review_reason, + }) + .from(kiloclaw_referral_rewards); + expect(rewards).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + beneficiaryUserId: referee.id, + status: 'review_required', + reviewReason: 'referral_payment_chargeback', + }), + expect.objectContaining({ + beneficiaryUserId: referrer.id, + status: 'canceled', + reviewReason: 'referral_payment_chargeback', + }), + ]) + ); + expect(mockReverseImpactAction).toHaveBeenCalledWith({ actionId: '1000.2000.3000' }); + }); + }); +}); diff --git a/apps/web/src/lib/kiloclaw-referrals.ts b/apps/web/src/lib/kiloclaw-referrals.ts new file mode 100644 index 0000000000..406cef3024 --- /dev/null +++ b/apps/web/src/lib/kiloclaw-referrals.ts @@ -0,0 +1,1562 @@ +import 'server-only'; + +import { addMonths } from 'date-fns'; +import { and, asc, count, eq, inArray, like, lt, lte, or, sql } from 'drizzle-orm'; + +import { db, type DrizzleTransaction } from '@/lib/drizzle'; +import { + IMPACT_ACTION_TRACKER_IDS, + buildSalePayload, + hashEmailForImpact, + isImpactConfigured, + reverseImpactAction, + sendImpactConversionPayload, + type ImpactConversionPayload, + type ImpactDispatchResult, +} from '@/lib/impact'; +import { isImpactAdvocateConfigured } from '@/lib/impact-advocate'; +import { hashNormalizedEmailForDeletionTombstone } from '@/lib/impact-referral'; +import { resolveCurrentPersonalSubscriptionRow } from '@/lib/kiloclaw/current-personal-subscription'; +import { client as stripe } from '@/lib/stripe-client'; +import { insertKiloClawSubscriptionChangeLog } from '@kilocode/db'; +import { + credit_transactions, + deleted_user_email_tombstones, + impact_advocate_participants, + impact_conversion_reports, + kiloclaw_attribution_touches, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referrals, + kiloclaw_subscription_change_log, + kiloclaw_subscriptions, + kilocode_users, + type KiloClawAttributionTouch, + type KiloClawSubscription, +} from '@kilocode/db/schema'; +import { + ImpactConversionReportState, + KiloClawAttributionTouchType, + KiloClawReferralBeneficiaryRole, + KiloClawReferralDecisionOutcome, + KiloClawReferralRewardStatus, + KiloClawReferralWinningTouchType, +} from '@kilocode/db/schema-types'; + +type DatabaseClient = typeof db | DrizzleTransaction; + +type WinningAttributionResolution = + | { + winner: 'referral'; + referralTouch: KiloClawAttributionTouch; + affiliateTouch: KiloClawAttributionTouch | null; + } + | { + winner: 'affiliate'; + affiliateTouch: KiloClawAttributionTouch; + referralTouch: KiloClawAttributionTouch | null; + } + | { + winner: 'none'; + affiliateTouch: KiloClawAttributionTouch | null; + referralTouch: KiloClawAttributionTouch | null; + }; + +export type KiloClawPaidConversionDisposition = { + shouldEnqueueAffiliateSale: boolean; + winningTouchType: 'referral' | 'affiliate' | 'none'; + conversionId: string | null; + disqualificationReason: string | null; +}; + +export type ImpactConversionReportDispatchSummary = { + claimed: number; + delivered: number; + retried: number; + failed: number; +}; + +export type ReferralRewardProcessingSummary = { + claimed: number; + applied: number; + expired: number; + pending: number; + failed: number; +}; + +export type AdverseReferralPaymentReason = 'chargeback' | 'refund' | 'fraud'; + +export type PaidConversionQualificationContext = { + sourceType?: 'normal' | 'test' | 'fraudulent' | 'admin_created' | 'manual_adjustment'; + overrideEligible?: boolean; +}; + +export type AdverseReferralPaymentSummary = { + conversionId: string | null; + canceledRewards: number; + reviewRequiredRewards: number; + impactActionReversed: boolean; +}; + +const REFERRAL_REWARD_ACTOR = { + actorType: 'system', + actorId: 'kiloclaw-referrals', +} as const; + +function getDatabaseClient(database?: DatabaseClient): DatabaseClient { + return database ?? db; +} + +function reportBackoffDelayMs(attemptCount: number): number { + const maxDelayMs = 60 * 60 * 1000; + const initialDelayMs = 60 * 1000; + return Math.min(initialDelayMs * 2 ** Math.max(attemptCount, 0), maxDelayMs); +} + +function nextReportRetryAt(attemptCount: number): string { + return new Date(Date.now() + reportBackoffDelayMs(attemptCount)).toISOString(); +} + +function referralDisqualificationReason(reason: string): string { + return `referral_${reason}`; +} + +function hasAcceptedTrackingValue(touch: KiloClawAttributionTouch): boolean { + return touch.is_tracking_value_accepted && Boolean(touch.opaque_tracking_value?.trim()); +} + +function isTouchValidAtConversion(touch: KiloClawAttributionTouch, convertedAt: Date): boolean { + return ( + hasAcceptedTrackingValue(touch) && + new Date(touch.touched_at).getTime() <= convertedAt.getTime() && + convertedAt.getTime() < new Date(touch.expires_at).getTime() + ); +} + +export function resolveWinningAttributionTouch(params: { + touches: KiloClawAttributionTouch[]; + convertedAt: Date; +}): WinningAttributionResolution { + const validReferralTouches = params.touches + .filter( + touch => + touch.touch_type === KiloClawAttributionTouchType.Referral && + isTouchValidAtConversion(touch, params.convertedAt) + ) + .sort((a, b) => new Date(a.touched_at).getTime() - new Date(b.touched_at).getTime()); + const validAffiliateTouches = params.touches + .filter( + touch => + touch.touch_type === KiloClawAttributionTouchType.Affiliate && + isTouchValidAtConversion(touch, params.convertedAt) + ) + .sort((a, b) => new Date(a.touched_at).getTime() - new Date(b.touched_at).getTime()); + + const oldestReferralTouch = validReferralTouches[0] ?? null; + const oldestAffiliateTouch = validAffiliateTouches[0] ?? null; + + if (!oldestReferralTouch && !oldestAffiliateTouch) { + return { + winner: 'none', + affiliateTouch: null, + referralTouch: null, + }; + } + + if (!oldestReferralTouch && oldestAffiliateTouch) { + return { + winner: 'affiliate', + affiliateTouch: oldestAffiliateTouch, + referralTouch: null, + }; + } + + if (!oldestAffiliateTouch && oldestReferralTouch) { + return { + winner: 'referral', + affiliateTouch: null, + referralTouch: oldestReferralTouch, + }; + } + + const preservedAffiliateTouch = validAffiliateTouches.find(touch => { + if (!touch.sale_attributed_at) return false; + return ( + new Date(touch.sale_attributed_at).getTime() < + new Date(oldestReferralTouch.touched_at).getTime() + ); + }); + + if (preservedAffiliateTouch) { + return { + winner: 'affiliate', + affiliateTouch: preservedAffiliateTouch, + referralTouch: oldestReferralTouch, + }; + } + + return { + winner: 'referral', + affiliateTouch: oldestAffiliateTouch, + referralTouch: oldestReferralTouch, + }; +} + +async function countMonetizedKiloClawPaymentPeriods( + userId: string, + database: DatabaseClient +): Promise { + const [result] = await database + .select({ count: count() }) + .from(credit_transactions) + .where( + and( + eq(credit_transactions.kilo_user_id, userId), + eq(credit_transactions.is_free, false), + lt(credit_transactions.amount_microdollars, 0), + or( + like(credit_transactions.credit_category, 'kiloclaw-subscription:%'), + like(credit_transactions.credit_category, 'kiloclaw-subscription-commit:%'), + like(credit_transactions.credit_category, 'kiloclaw-settlement:%') + ) + ) + ); + + return result?.count ?? 0; +} + +async function findAcceptedUserTouches(params: { + userId: string; + convertedAt: Date; + database: DatabaseClient; +}): Promise { + return await params.database + .select() + .from(kiloclaw_attribution_touches) + .where( + and( + eq(kiloclaw_attribution_touches.user_id, params.userId), + lte(kiloclaw_attribution_touches.touched_at, params.convertedAt.toISOString()) + ) + ) + .orderBy( + asc(kiloclaw_attribution_touches.touched_at), + asc(kiloclaw_attribution_touches.created_at) + ); +} + +function buildOpaqueReferralIdentifierFromTouch(touch: KiloClawAttributionTouch): string | null { + const referralIdentifier = buildImpactReferralId(touch)?.trim(); + return referralIdentifier ? referralIdentifier : null; +} + +async function resolveReferrerUserIdFromReferralTouch(params: { + referralTouch: KiloClawAttributionTouch; + database: DatabaseClient; +}): Promise { + const opaqueReferralIdentifier = buildOpaqueReferralIdentifierFromTouch(params.referralTouch); + if (!opaqueReferralIdentifier) { + return null; + } + + const [row] = await params.database + .select({ userId: impact_advocate_participants.user_id }) + .from(impact_advocate_participants) + .where(eq(impact_advocate_participants.opaque_referral_identifier, opaqueReferralIdentifier)) + .limit(1); + + return row?.userId ?? null; +} + +async function hasDeletedUserEmailTombstone(params: { + normalizedEmail: string | null; + database: DatabaseClient; +}): Promise { + if (!params.normalizedEmail) { + return false; + } + + const [row] = await params.database + .select({ hash: deleted_user_email_tombstones.normalized_email_hash }) + .from(deleted_user_email_tombstones) + .where( + eq( + deleted_user_email_tombstones.normalized_email_hash, + hashNormalizedEmailForDeletionTombstone(params.normalizedEmail) + ) + ) + .limit(1); + + return Boolean(row); +} + +async function hasActiveEligiblePersonalSubscription( + userId: string, + database: DatabaseClient +): Promise { + const row = await resolveCurrentPersonalSubscriptionRow({ userId, dbOrTx: database }); + if (!row) return false; + + return ( + row.subscription.plan !== 'trial' && + row.subscription.status === 'active' && + !row.subscription.cancel_at_period_end && + row.subscription.suspended_at === null && + row.subscription.past_due_since === null + ); +} + +async function markAffiliateTouchSaleAttributed(params: { + database: DatabaseClient; + affiliateTouchId: string; + convertedAt: Date; +}): Promise { + await params.database + .update(kiloclaw_attribution_touches) + .set({ + sale_attributed_at: sql`COALESCE(${kiloclaw_attribution_touches.sale_attributed_at}, ${params.convertedAt.toISOString()}::timestamptz)`, + }) + .where(eq(kiloclaw_attribution_touches.id, params.affiliateTouchId)); +} + +async function lockReferrerRewardCapacity( + referrerUserId: string, + database: DatabaseClient +): Promise { + await database.execute( + sql`SELECT ${kilocode_users.id} FROM ${kilocode_users} WHERE ${kilocode_users.id} = ${referrerUserId} FOR UPDATE` + ); +} + +async function getGrantedReferrerMonths( + referrerUserId: string, + database: DatabaseClient +): Promise { + const [result] = await database + .select({ + totalMonths: sql`COALESCE(SUM(${kiloclaw_referral_reward_decisions.months_granted}), 0)`, + }) + .from(kiloclaw_referral_reward_decisions) + .where( + and( + eq(kiloclaw_referral_reward_decisions.beneficiary_user_id, referrerUserId), + eq( + kiloclaw_referral_reward_decisions.beneficiary_role, + KiloClawReferralBeneficiaryRole.Referrer + ), + eq(kiloclaw_referral_reward_decisions.outcome, KiloClawReferralDecisionOutcome.Granted) + ) + ); + + return Number(result?.totalMonths ?? 0); +} + +async function hasSaleAttributedAffiliateTouch(params: { + userId: string; + database: DatabaseClient; +}): Promise { + const [touch] = await params.database + .select({ id: kiloclaw_attribution_touches.id }) + .from(kiloclaw_attribution_touches) + .where( + and( + eq(kiloclaw_attribution_touches.user_id, params.userId), + eq(kiloclaw_attribution_touches.touch_type, KiloClawAttributionTouchType.Affiliate), + sql`${kiloclaw_attribution_touches.sale_attributed_at} IS NOT NULL` + ) + ) + .limit(1); + + return Boolean(touch); +} + +async function hasAdminOverrideHistory(params: { + subscriptionId: string; + database: DatabaseClient; +}): Promise { + const [row] = await params.database + .select({ id: kiloclaw_subscription_change_log.id }) + .from(kiloclaw_subscription_change_log) + .where( + and( + eq(kiloclaw_subscription_change_log.subscription_id, params.subscriptionId), + eq(kiloclaw_subscription_change_log.action, 'admin_override') + ) + ) + .limit(1); + + return Boolean(row); +} + +async function getHeuristicSourcePaymentDisqualificationReason(params: { + sourcePaymentId: string; + database: DatabaseClient; +}): Promise { + const [transaction] = await params.database + .select({ + description: credit_transactions.description, + isFree: credit_transactions.is_free, + }) + .from(credit_transactions) + .where(eq(credit_transactions.credit_category, params.sourcePaymentId)) + .limit(1); + + if (!transaction) { + return null; + } + + if (transaction.isFree) { + return referralDisqualificationReason('fully_comped_period'); + } + + const description = transaction.description?.trim().toLowerCase() ?? ''; + if (description.includes('fraud')) { + return referralDisqualificationReason('fraudulent_subscription'); + } + if (description.includes('manual')) { + return referralDisqualificationReason('manual_adjustment_subscription'); + } + if (description.includes('admin')) { + return referralDisqualificationReason('admin_created_subscription'); + } + if (description.includes('test')) { + return referralDisqualificationReason('test_subscription'); + } + + return null; +} + +function getObjectProperty(record: unknown, key: string): unknown { + if (typeof record !== 'object' || record === null) { + return undefined; + } + + if (!Object.prototype.hasOwnProperty.call(record, key)) { + return undefined; + } + + return Reflect.get(record, key); +} + +function getImpactActionIdFromResponsePayload(payload: unknown): string | null { + const value = getObjectProperty(payload, 'actionId'); + return typeof value === 'string' && value.trim() ? value : null; +} + +function getRewardApplicationReason(reason: string): string { + return `referral_reward_${reason}`; +} + +function getAdversePaymentReason(reason: AdverseReferralPaymentReason): string { + return `referral_payment_${reason}`; +} + +function getQualificationDisqualificationReason( + sourceType: Exclude +): string { + switch (sourceType) { + case 'test': + return referralDisqualificationReason('test_subscription'); + case 'fraudulent': + return referralDisqualificationReason('fraudulent_subscription'); + case 'admin_created': + return referralDisqualificationReason('admin_created_subscription'); + case 'manual_adjustment': + return referralDisqualificationReason('manual_adjustment_subscription'); + } +} + +function getRewardBearingReferralConfigurationState() { + const impactPerformanceConfigured = isImpactConfigured(); + const impactAdvocateConfigured = isImpactAdvocateConfigured(); + + return { + impactPerformanceConfigured, + impactAdvocateConfigured, + isConfigured: impactPerformanceConfigured && impactAdvocateConfigured, + }; +} + +function logRewardBearingReferralConfigurationFailure(params: { + sourcePaymentId?: string; + conversionId?: string; + rewardId?: string; + userId?: string; +}): void { + const configurationState = getRewardBearingReferralConfigurationState(); + console.error('[kiloclaw-referrals] reward-bearing referral configuration is incomplete', { + ...params, + impactPerformanceConfigured: configurationState.impactPerformanceConfigured, + impactAdvocateConfigured: configurationState.impactAdvocateConfigured, + }); +} + +function getNextRenewalBoundary(subscription: KiloClawSubscription): string | null { + return subscription.credit_renewal_at ?? subscription.current_period_end; +} + +function hasActiveEligibleSubscriptionRow(subscription: KiloClawSubscription): boolean { + return ( + subscription.plan !== 'trial' && + subscription.status === 'active' && + !subscription.cancel_at_period_end && + subscription.suspended_at === null && + subscription.past_due_since === null + ); +} + +function requiresDeferredStripeRewardApplication(subscription: KiloClawSubscription): boolean { + return Boolean(subscription.stripe_schedule_id || subscription.scheduled_plan); +} + +async function applyReferralRewardById( + rewardId: string +): Promise<'applied' | 'expired' | 'pending' | 'noop'> { + return await db.transaction(async tx => { + const [reward] = await tx + .select() + .from(kiloclaw_referral_rewards) + .where(eq(kiloclaw_referral_rewards.id, rewardId)) + .limit(1); + + if (!reward) { + return 'noop'; + } + + if ( + reward.status === KiloClawReferralRewardStatus.Applied || + reward.status === KiloClawReferralRewardStatus.Canceled || + reward.status === KiloClawReferralRewardStatus.Expired || + reward.status === KiloClawReferralRewardStatus.Reversed || + reward.status === KiloClawReferralRewardStatus.ReviewRequired + ) { + return 'noop'; + } + + const now = new Date(); + if ( + reward.status === KiloClawReferralRewardStatus.Pending && + reward.expires_at && + now.getTime() >= new Date(reward.expires_at).getTime() + ) { + await tx + .update(kiloclaw_referral_rewards) + .set({ + status: KiloClawReferralRewardStatus.Expired, + review_reason: getRewardApplicationReason('inactive_referrer_expired'), + }) + .where(eq(kiloclaw_referral_rewards.id, reward.id)); + return 'expired'; + } + + if (!getRewardBearingReferralConfigurationState().isConfigured) { + logRewardBearingReferralConfigurationFailure({ + rewardId: reward.id, + userId: reward.beneficiary_user_id, + }); + return 'pending'; + } + + await lockReferrerRewardCapacity(reward.beneficiary_user_id, tx); + const currentSubscription = await resolveCurrentPersonalSubscriptionRow({ + userId: reward.beneficiary_user_id, + dbOrTx: tx, + }); + const subscription = currentSubscription?.subscription ?? null; + + if (!subscription || !hasActiveEligibleSubscriptionRow(subscription)) { + if (reward.status === KiloClawReferralRewardStatus.Earned) { + await tx + .update(kiloclaw_referral_rewards) + .set({ status: KiloClawReferralRewardStatus.Pending }) + .where(eq(kiloclaw_referral_rewards.id, reward.id)); + } + return 'pending'; + } + + const previousBoundary = getNextRenewalBoundary(subscription); + if (!previousBoundary) { + console.warn( + '[kiloclaw-referrals] reward application left pending due to ambiguous renewal boundary', + { + rewardId: reward.id, + userId: reward.beneficiary_user_id, + subscriptionId: subscription.id, + } + ); + if (reward.status === KiloClawReferralRewardStatus.Pending) { + await tx + .update(kiloclaw_referral_rewards) + .set({ status: KiloClawReferralRewardStatus.Earned }) + .where(eq(kiloclaw_referral_rewards.id, reward.id)); + } + return 'pending'; + } + + if ( + subscription.stripe_subscription_id !== null && + requiresDeferredStripeRewardApplication(subscription) + ) { + console.warn( + '[kiloclaw-referrals] reward application deferred due to scheduled Stripe changes', + { + rewardId: reward.id, + userId: reward.beneficiary_user_id, + subscriptionId: subscription.id, + stripeScheduleId: subscription.stripe_schedule_id, + scheduledPlan: subscription.scheduled_plan, + } + ); + if (reward.status === KiloClawReferralRewardStatus.Pending) { + await tx + .update(kiloclaw_referral_rewards) + .set({ status: KiloClawReferralRewardStatus.Earned }) + .where(eq(kiloclaw_referral_rewards.id, reward.id)); + } + return 'pending'; + } + + const appliedAt = now.toISOString(); + const newBoundary = addMonths(new Date(previousBoundary), reward.months_granted).toISOString(); + const localOperationId = `kiloclaw-referral-reward:${reward.id}:apply`; + const stripeIdempotencyKey = `kiloclaw-referral-reward:${reward.id}:stripe-apply`; + + if (subscription.stripe_subscription_id) { + await stripe.subscriptions.update( + subscription.stripe_subscription_id, + { + trial_end: Math.floor(new Date(newBoundary).getTime() / 1000), + proration_behavior: 'none', + }, + { + idempotencyKey: stripeIdempotencyKey, + } + ); + } + + const [beforeSubscription] = await tx + .select() + .from(kiloclaw_subscriptions) + .where(eq(kiloclaw_subscriptions.id, subscription.id)) + .limit(1); + const [afterSubscription] = await tx + .update(kiloclaw_subscriptions) + .set({ + current_period_end: newBoundary, + credit_renewal_at: + subscription.payment_source === 'credits' ? newBoundary : subscription.credit_renewal_at, + commit_ends_at: + subscription.plan === 'commit' && subscription.commit_ends_at + ? addMonths(new Date(subscription.commit_ends_at), reward.months_granted).toISOString() + : subscription.commit_ends_at, + }) + .where(eq(kiloclaw_subscriptions.id, subscription.id)) + .returning(); + + if (!afterSubscription) { + return 'noop'; + } + + const [appliedReward] = await tx + .update(kiloclaw_referral_rewards) + .set({ + status: KiloClawReferralRewardStatus.Applied, + applies_to_subscription_id: subscription.id, + applied_at: appliedAt, + review_reason: null, + }) + .where( + and( + eq(kiloclaw_referral_rewards.id, reward.id), + or( + eq(kiloclaw_referral_rewards.status, KiloClawReferralRewardStatus.Earned), + eq(kiloclaw_referral_rewards.status, KiloClawReferralRewardStatus.Pending) + ), + sql`${kiloclaw_referral_rewards.applied_at} IS NULL` + ) + ) + .returning({ id: kiloclaw_referral_rewards.id }); + + if (!appliedReward) { + return 'noop'; + } + + await insertKiloClawSubscriptionChangeLog(tx, { + subscriptionId: subscription.id, + actor: REFERRAL_REWARD_ACTOR, + action: 'period_advanced', + reason: getRewardApplicationReason('applied'), + before: beforeSubscription ?? null, + after: afterSubscription, + }); + + const [existingApplication] = await tx + .select({ id: kiloclaw_referral_reward_applications.id }) + .from(kiloclaw_referral_reward_applications) + .where(eq(kiloclaw_referral_reward_applications.reward_id, reward.id)) + .limit(1); + + if (!existingApplication) { + await tx.insert(kiloclaw_referral_reward_applications).values({ + reward_id: reward.id, + beneficiary_user_id: reward.beneficiary_user_id, + subscription_id: subscription.id, + previous_renewal_boundary: previousBoundary, + new_renewal_boundary: newBoundary, + local_operation_id: localOperationId, + stripe_operation_id: subscription.stripe_subscription_id, + stripe_idempotency_key: subscription.stripe_subscription_id ? stripeIdempotencyKey : null, + applied_at: appliedAt, + }); + } + + return 'applied'; + }); +} + +export async function processQueuedKiloClawReferralRewards(params?: { + limit?: number; + beneficiaryUserIds?: string[]; +}): Promise { + const limit = params?.limit ?? 100; + const pendingRows = await db + .select({ id: kiloclaw_referral_rewards.id }) + .from(kiloclaw_referral_rewards) + .where( + and( + or( + eq(kiloclaw_referral_rewards.status, KiloClawReferralRewardStatus.Pending), + eq(kiloclaw_referral_rewards.status, KiloClawReferralRewardStatus.Earned) + ), + params?.beneficiaryUserIds?.length + ? inArray(kiloclaw_referral_rewards.beneficiary_user_id, params.beneficiaryUserIds) + : undefined + ) + ) + .orderBy(asc(kiloclaw_referral_rewards.earned_at), asc(kiloclaw_referral_rewards.created_at)) + .limit(limit); + + const summary: ReferralRewardProcessingSummary = { + claimed: pendingRows.length, + applied: 0, + expired: 0, + pending: 0, + failed: 0, + }; + + for (const row of pendingRows) { + try { + const outcome = await applyReferralRewardById(row.id); + if (outcome === 'applied') { + summary.applied++; + } else if (outcome === 'expired') { + summary.expired++; + } else if (outcome === 'pending') { + summary.pending++; + } + } catch { + summary.failed++; + } + } + + return summary; +} + +async function persistImpactReportReversal(params: { + reportId: string; + reason: AdverseReferralPaymentReason; + occurredAt: Date; +}): Promise { + const existing = await getImpactConversionReportById(params.reportId, db); + if (!existing) { + return false; + } + + const existingPayload = existing.response_payload ?? {}; + if (getObjectProperty(existingPayload, 'referralReversal')) { + return false; + } + + const actionId = getImpactActionIdFromResponsePayload(existingPayload); + if (!actionId) { + await db + .update(impact_conversion_reports) + .set({ + response_payload: { + ...existingPayload, + referralReversal: { + reason: params.reason, + occurredAt: params.occurredAt.toISOString(), + status: 'missing_action_id', + }, + } satisfies Record, + }) + .where(eq(impact_conversion_reports.id, params.reportId)); + return false; + } + + const result = await reverseImpactAction({ actionId }); + await db + .update(impact_conversion_reports) + .set({ + response_payload: { + ...existingPayload, + referralReversal: { + reason: params.reason, + occurredAt: params.occurredAt.toISOString(), + ok: result.ok, + failureKind: result.ok ? null : result.failureKind, + statusCode: result.ok ? null : (result.statusCode ?? null), + responseBody: result.responseBody ?? null, + }, + } satisfies Record, + }) + .where(eq(impact_conversion_reports.id, params.reportId)); + + return result.ok; +} + +export async function markPersonalKiloClawReferralPaymentAdverse(params: { + sourcePaymentId: string; + reason: AdverseReferralPaymentReason; + occurredAt: Date; +}): Promise { + let impactReportId: string | null = null; + + const summary = await db.transaction(async tx => { + const conversion = await tx.query.kiloclaw_referral_conversions.findFirst({ + where: eq(kiloclaw_referral_conversions.source_payment_id, params.sourcePaymentId), + }); + + if (!conversion) { + return { + conversionId: null, + canceledRewards: 0, + reviewRequiredRewards: 0, + }; + } + + const rewards = await tx + .select() + .from(kiloclaw_referral_rewards) + .where(eq(kiloclaw_referral_rewards.conversion_id, conversion.id)); + + let canceledRewards = 0; + let reviewRequiredRewards = 0; + for (const reward of rewards) { + if ( + reward.status === KiloClawReferralRewardStatus.Pending || + reward.status === KiloClawReferralRewardStatus.Earned + ) { + await tx + .update(kiloclaw_referral_rewards) + .set({ + status: KiloClawReferralRewardStatus.Canceled, + review_reason: getAdversePaymentReason(params.reason), + }) + .where(eq(kiloclaw_referral_rewards.id, reward.id)); + canceledRewards++; + continue; + } + + if (reward.status === KiloClawReferralRewardStatus.Applied) { + await tx + .update(kiloclaw_referral_rewards) + .set({ + status: KiloClawReferralRewardStatus.ReviewRequired, + review_reason: getAdversePaymentReason(params.reason), + }) + .where(eq(kiloclaw_referral_rewards.id, reward.id)); + reviewRequiredRewards++; + } + } + + const report = await tx.query.impact_conversion_reports.findFirst({ + where: eq(impact_conversion_reports.conversion_id, conversion.id), + columns: { id: true }, + }); + impactReportId = report?.id ?? null; + + return { + conversionId: conversion.id, + canceledRewards, + reviewRequiredRewards, + }; + }); + + const impactActionReversed = impactReportId + ? await persistImpactReportReversal({ + reportId: impactReportId, + reason: params.reason, + occurredAt: params.occurredAt, + }) + : false; + + return { + ...summary, + impactActionReversed, + }; +} + +async function upsertReferralRelationship(params: { + refereeUserId: string; + referrerUserId: string | null; + sourceTouchId: string; + impactReferralId: string | null; + database: DatabaseClient; +}): Promise { + await params.database + .insert(kiloclaw_referrals) + .values({ + referee_user_id: params.refereeUserId, + referrer_user_id: params.referrerUserId, + source_touch_id: params.sourceTouchId, + impact_referral_id: params.impactReferralId, + }) + .onConflictDoUpdate({ + target: [kiloclaw_referrals.referee_user_id], + set: { + referrer_user_id: params.referrerUserId, + source_touch_id: params.sourceTouchId, + impact_referral_id: params.impactReferralId, + }, + }); +} + +function buildImpactReferralId(touch: KiloClawAttributionTouch): string | null { + return touch.rs_code?.trim() || touch.opaque_tracking_value?.trim() || null; +} + +async function getImpactConversionReportById( + reportId: string, + database: DatabaseClient +): Promise { + const report = await database.query.impact_conversion_reports.findFirst({ + where: eq(impact_conversion_reports.id, reportId), + }); + return report ?? null; +} + +async function persistImpactConversionReportResult(params: { + reportId: string; + result: ImpactDispatchResult; + database?: DatabaseClient; +}): Promise { + const database = getDatabaseClient(params.database); + const existing = await getImpactConversionReportById(params.reportId, database); + if (!existing) return; + + const attemptCount = existing.attempt_count + 1; + if (params.result.ok) { + if ('skipped' in params.result) { + logRewardBearingReferralConfigurationFailure({ + conversionId: existing.conversion_id ?? undefined, + }); + await database + .update(impact_conversion_reports) + .set({ + state: ImpactConversionReportState.Failed, + attempt_count: attemptCount, + next_retry_at: null, + delivered_at: null, + response_status_code: null, + response_payload: { + error: 'missing_reward_bearing_referral_configuration', + delivery: params.result.skipped, + responseBody: params.result.responseBody ?? null, + } satisfies Record, + }) + .where(eq(impact_conversion_reports.id, params.reportId)); + return; + } + + await database + .update(impact_conversion_reports) + .set({ + state: ImpactConversionReportState.Delivered, + attempt_count: attemptCount, + next_retry_at: null, + delivered_at: new Date().toISOString(), + response_status_code: null, + response_payload: { + delivery: params.result.delivery ?? null, + responseBody: params.result.responseBody ?? null, + ...(params.result.ok && 'actionId' in params.result + ? { actionId: params.result.actionId } + : {}), + ...(params.result.ok && 'submissionUri' in params.result + ? { submissionUri: params.result.submissionUri } + : {}), + } satisfies Record, + }) + .where(eq(impact_conversion_reports.id, params.reportId)); + return; + } + + const isTerminalFailure = params.result.failureKind === 'http_4xx'; + if (isTerminalFailure) { + console.error('[kiloclaw-referrals] Impact conversion report failed permanently', { + reportId: params.reportId, + conversionId: existing.conversion_id, + statusCode: params.result.statusCode ?? null, + failureKind: params.result.failureKind, + }); + } + + await database + .update(impact_conversion_reports) + .set({ + state: isTerminalFailure + ? ImpactConversionReportState.Failed + : ImpactConversionReportState.Retrying, + attempt_count: attemptCount, + next_retry_at: isTerminalFailure ? null : nextReportRetryAt(attemptCount), + response_status_code: params.result.statusCode ?? null, + response_payload: { + failureKind: params.result.failureKind, + responseBody: params.result.responseBody ?? null, + error: params.result.error ?? null, + } satisfies Record, + }) + .where(eq(impact_conversion_reports.id, params.reportId)); +} + +async function dispatchImpactConversionReportById( + reportId: string +): Promise<'delivered' | 'retried' | 'failed'> { + const report = await getImpactConversionReportById(reportId, db); + if (!report) { + return 'failed'; + } + + const payload = report.request_payload as ImpactConversionPayload | null; + if (!payload) { + await db + .update(impact_conversion_reports) + .set({ + state: ImpactConversionReportState.Failed, + response_payload: { error: 'missing_request_payload' } satisfies Record, + }) + .where(eq(impact_conversion_reports.id, report.id)); + return 'failed'; + } + + const result = await sendImpactConversionPayload(payload); + await persistImpactConversionReportResult({ reportId: report.id, result }); + return result.ok ? 'delivered' : result.failureKind === 'http_4xx' ? 'failed' : 'retried'; +} + +export async function dispatchQueuedImpactConversionReports(params?: { + limit?: number; +}): Promise { + const limit = params?.limit ?? 100; + const nowIso = new Date().toISOString(); + const rows = await db + .select({ id: impact_conversion_reports.id }) + .from(impact_conversion_reports) + .where( + and( + or( + eq(impact_conversion_reports.state, ImpactConversionReportState.Queued), + eq(impact_conversion_reports.state, ImpactConversionReportState.Retrying) + ), + or( + sql`${impact_conversion_reports.next_retry_at} IS NULL`, + lte(impact_conversion_reports.next_retry_at, nowIso) + ) + ) + ) + .limit(limit); + + const summary: ImpactConversionReportDispatchSummary = { + claimed: rows.length, + delivered: 0, + retried: 0, + failed: 0, + }; + + for (const row of rows) { + const outcome = await dispatchImpactConversionReportById(row.id); + if (outcome === 'delivered') { + summary.delivered++; + } else if (outcome === 'retried') { + summary.retried++; + } else { + summary.failed++; + } + } + + return summary; +} + +export async function processPersonalKiloClawPaidConversion(params: { + userId: string; + sourcePaymentId: string; + orderId: string; + amount: number; + currencyCode: string; + itemCategory: string; + itemName: string; + itemSku?: string; + convertedAt: Date; + qualificationContext?: PaidConversionQualificationContext; +}): Promise { + let impactReportId: string | null = null; + const rewardBeneficiaryUserIds = new Set(); + const disposition = await db.transaction(async tx => { + const existingConversion = await tx.query.kiloclaw_referral_conversions.findFirst({ + where: eq(kiloclaw_referral_conversions.source_payment_id, params.sourcePaymentId), + }); + + if (existingConversion) { + return { + shouldEnqueueAffiliateSale: + existingConversion.winning_touch_type === KiloClawReferralWinningTouchType.Affiliate, + winningTouchType: existingConversion.winning_touch_type, + conversionId: existingConversion.id, + disqualificationReason: existingConversion.disqualification_reason, + } satisfies KiloClawPaidConversionDisposition; + } + + const [user] = await tx + .select({ + id: kilocode_users.id, + createdAt: kilocode_users.created_at, + email: kilocode_users.google_user_email, + normalizedEmail: kilocode_users.normalized_email, + }) + .from(kilocode_users) + .where(eq(kilocode_users.id, params.userId)) + .limit(1); + + if (!user) { + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.None, + conversionId: null, + disqualificationReason: 'user_missing', + } satisfies KiloClawPaidConversionDisposition; + } + + const explicitDisqualificationReason = + params.qualificationContext?.sourceType && + params.qualificationContext.sourceType !== 'normal' && + !params.qualificationContext.overrideEligible + ? getQualificationDisqualificationReason(params.qualificationContext.sourceType) + : null; + if (explicitDisqualificationReason) { + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.None, + conversionId: null, + disqualificationReason: explicitDisqualificationReason, + } satisfies KiloClawPaidConversionDisposition; + } + + const heuristicDisqualificationReason = await getHeuristicSourcePaymentDisqualificationReason({ + sourcePaymentId: params.sourcePaymentId, + database: tx, + }); + if (heuristicDisqualificationReason && !params.qualificationContext?.overrideEligible) { + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.None, + conversionId: null, + disqualificationReason: heuristicDisqualificationReason, + } satisfies KiloClawPaidConversionDisposition; + } + + const currentPersonalSubscription = await resolveCurrentPersonalSubscriptionRow({ + userId: params.userId, + dbOrTx: tx, + }); + if (!currentPersonalSubscription) { + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.None, + conversionId: null, + disqualificationReason: referralDisqualificationReason('non_personal_subscription'), + } satisfies KiloClawPaidConversionDisposition; + } + + const hasAdminAdjustedSubscription = await hasAdminOverrideHistory({ + subscriptionId: currentPersonalSubscription.subscription.id, + database: tx, + }); + if (hasAdminAdjustedSubscription && !params.qualificationContext?.overrideEligible) { + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.None, + conversionId: null, + disqualificationReason: referralDisqualificationReason('admin_adjusted_subscription'), + } satisfies KiloClawPaidConversionDisposition; + } + + const monetizedPeriods = await countMonetizedKiloClawPaymentPeriods(params.userId, tx); + if (monetizedPeriods > 1) { + const hasPreservedAffiliateSale = await hasSaleAttributedAffiliateTouch({ + userId: params.userId, + database: tx, + }); + + return { + shouldEnqueueAffiliateSale: hasPreservedAffiliateSale, + winningTouchType: hasPreservedAffiliateSale + ? KiloClawReferralWinningTouchType.Affiliate + : KiloClawReferralWinningTouchType.None, + conversionId: null, + disqualificationReason: 'not_first_paid_period', + } satisfies KiloClawPaidConversionDisposition; + } + + const touches = await findAcceptedUserTouches({ + userId: params.userId, + convertedAt: params.convertedAt, + database: tx, + }); + const resolution = resolveWinningAttributionTouch({ + touches, + convertedAt: params.convertedAt, + }); + + if (resolution.winner === 'none') { + const [conversion] = await tx + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: params.userId, + referrer_user_id: null, + source_touch_id: null, + winning_touch_type: KiloClawReferralWinningTouchType.None, + source_payment_id: params.sourcePaymentId, + qualified: false, + disqualification_reason: referralDisqualificationReason('no_valid_attribution'), + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.None, + conversionId: conversion?.id ?? null, + disqualificationReason: referralDisqualificationReason('no_valid_attribution'), + } satisfies KiloClawPaidConversionDisposition; + } + + if (resolution.winner === 'affiliate') { + await markAffiliateTouchSaleAttributed({ + database: tx, + affiliateTouchId: resolution.affiliateTouch.id, + convertedAt: params.convertedAt, + }); + + const [conversion] = await tx + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: params.userId, + referrer_user_id: null, + source_touch_id: resolution.affiliateTouch.id, + winning_touch_type: KiloClawReferralWinningTouchType.Affiliate, + source_payment_id: params.sourcePaymentId, + qualified: false, + disqualification_reason: referralDisqualificationReason('affiliate_won'), + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + return { + shouldEnqueueAffiliateSale: true, + winningTouchType: KiloClawReferralWinningTouchType.Affiliate, + conversionId: conversion?.id ?? null, + disqualificationReason: referralDisqualificationReason('affiliate_won'), + } satisfies KiloClawPaidConversionDisposition; + } + + const referrerUserId = await resolveReferrerUserIdFromReferralTouch({ + referralTouch: resolution.referralTouch, + database: tx, + }); + await upsertReferralRelationship({ + refereeUserId: params.userId, + referrerUserId, + sourceTouchId: resolution.referralTouch.id, + impactReferralId: buildImpactReferralId(resolution.referralTouch), + database: tx, + }); + + const deletedUser = await hasDeletedUserEmailTombstone({ + normalizedEmail: user.normalizedEmail, + database: tx, + }); + const userExistedBeforeReferral = + new Date(user.createdAt).getTime() < new Date(resolution.referralTouch.touched_at).getTime(); + const isSelfReferral = referrerUserId !== null && referrerUserId === params.userId; + + if (deletedUser || userExistedBeforeReferral || !referrerUserId || isSelfReferral) { + const disqualificationReason = deletedUser + ? referralDisqualificationReason('deleted_user_tombstone') + : userExistedBeforeReferral + ? referralDisqualificationReason('existing_user_before_touch') + : !referrerUserId + ? referralDisqualificationReason('referrer_unresolved') + : referralDisqualificationReason('self_referral'); + + const [conversion] = await tx + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: params.userId, + referrer_user_id: referrerUserId, + source_touch_id: resolution.referralTouch.id, + winning_touch_type: KiloClawReferralWinningTouchType.Referral, + source_payment_id: params.sourcePaymentId, + qualified: false, + disqualification_reason: disqualificationReason, + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.Referral, + conversionId: conversion?.id ?? null, + disqualificationReason, + } satisfies KiloClawPaidConversionDisposition; + } + + if (!getRewardBearingReferralConfigurationState().isConfigured) { + const disqualificationReason = referralDisqualificationReason('missing_configuration'); + logRewardBearingReferralConfigurationFailure({ + sourcePaymentId: params.sourcePaymentId, + userId: params.userId, + }); + + const [conversion] = await tx + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: params.userId, + referrer_user_id: referrerUserId, + source_touch_id: resolution.referralTouch.id, + winning_touch_type: KiloClawReferralWinningTouchType.Referral, + source_payment_id: params.sourcePaymentId, + qualified: false, + disqualification_reason: disqualificationReason, + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + if (!conversion) { + throw new Error( + `Failed to create referral conversion for payment ${params.sourcePaymentId}` + ); + } + + await tx.insert(kiloclaw_referral_reward_decisions).values([ + { + conversion_id: conversion.id, + beneficiary_user_id: params.userId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + outcome: KiloClawReferralDecisionOutcome.Disqualified, + reason: disqualificationReason, + months_granted: 0, + }, + { + conversion_id: conversion.id, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + outcome: KiloClawReferralDecisionOutcome.Disqualified, + reason: disqualificationReason, + months_granted: 0, + }, + ]); + + const payload = buildSalePayload({ + customerId: params.userId, + customerEmailHash: hashEmailForImpact(user.email), + eventDate: params.convertedAt, + orderId: params.orderId, + amount: params.amount, + currencyCode: params.currencyCode, + itemCategory: params.itemCategory, + itemName: params.itemName, + itemSku: params.itemSku, + trackingId: null, + }); + + await tx + .insert(impact_conversion_reports) + .values({ + conversion_id: conversion.id, + dedupe_key: `impact-referral-sale:${params.sourcePaymentId}`, + action_tracker_id: IMPACT_ACTION_TRACKER_IDS.sale, + order_id: params.orderId, + state: ImpactConversionReportState.Failed, + request_payload: payload satisfies Record, + response_payload: { + error: 'missing_reward_bearing_referral_configuration', + } satisfies Record, + }) + .onConflictDoNothing({ target: [impact_conversion_reports.dedupe_key] }); + + impactReportId = null; + + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.Referral, + conversionId: conversion.id, + disqualificationReason, + } satisfies KiloClawPaidConversionDisposition; + } + + await lockReferrerRewardCapacity(referrerUserId, tx); + const referrerGrantedMonths = await getGrantedReferrerMonths(referrerUserId, tx); + const referrerAtCap = referrerGrantedMonths >= 12; + + const [conversion] = await tx + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: params.userId, + referrer_user_id: referrerUserId, + source_touch_id: resolution.referralTouch.id, + winning_touch_type: KiloClawReferralWinningTouchType.Referral, + source_payment_id: params.sourcePaymentId, + qualified: true, + disqualification_reason: null, + converted_at: params.convertedAt.toISOString(), + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + if (!conversion) { + throw new Error(`Failed to create referral conversion for payment ${params.sourcePaymentId}`); + } + + const refereeHasEligibleSubscription = await hasActiveEligiblePersonalSubscription( + params.userId, + tx + ); + const referrerHasEligibleSubscription = await hasActiveEligiblePersonalSubscription( + referrerUserId, + tx + ); + + const [refereeDecision, referrerDecision] = await tx + .insert(kiloclaw_referral_reward_decisions) + .values([ + { + conversion_id: conversion.id, + beneficiary_user_id: params.userId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + outcome: KiloClawReferralDecisionOutcome.Granted, + reason: null, + months_granted: 1, + }, + { + conversion_id: conversion.id, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + outcome: referrerAtCap + ? KiloClawReferralDecisionOutcome.CapLimited + : KiloClawReferralDecisionOutcome.Granted, + reason: referrerAtCap ? referralDisqualificationReason('referrer_cap_reached') : null, + months_granted: referrerAtCap ? 0 : 1, + }, + ]) + .returning({ + id: kiloclaw_referral_reward_decisions.id, + beneficiary_user_id: kiloclaw_referral_reward_decisions.beneficiary_user_id, + beneficiary_role: kiloclaw_referral_reward_decisions.beneficiary_role, + outcome: kiloclaw_referral_reward_decisions.outcome, + }); + + await tx.insert(kiloclaw_referral_rewards).values( + [refereeDecision, referrerDecision] + .filter(decision => decision.outcome === KiloClawReferralDecisionOutcome.Granted) + .map(decision => ({ + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: decision.beneficiary_user_id, + beneficiary_role: decision.beneficiary_role, + months_granted: 1, + status: + decision.beneficiary_role === KiloClawReferralBeneficiaryRole.Referee + ? refereeHasEligibleSubscription + ? KiloClawReferralRewardStatus.Earned + : KiloClawReferralRewardStatus.Pending + : referrerHasEligibleSubscription + ? KiloClawReferralRewardStatus.Earned + : KiloClawReferralRewardStatus.Pending, + earned_at: params.convertedAt.toISOString(), + expires_at: + decision.beneficiary_role === KiloClawReferralBeneficiaryRole.Referrer && + !referrerHasEligibleSubscription + ? addMonths(params.convertedAt, 12).toISOString() + : null, + })) + ); + + const payload = buildSalePayload({ + customerId: params.userId, + customerEmailHash: hashEmailForImpact(user.email), + eventDate: params.convertedAt, + orderId: params.orderId, + amount: params.amount, + currencyCode: params.currencyCode, + itemCategory: params.itemCategory, + itemName: params.itemName, + itemSku: params.itemSku, + trackingId: null, + }); + + const [report] = await tx + .insert(impact_conversion_reports) + .values({ + conversion_id: conversion.id, + dedupe_key: `impact-referral-sale:${params.sourcePaymentId}`, + action_tracker_id: IMPACT_ACTION_TRACKER_IDS.sale, + order_id: params.orderId, + state: ImpactConversionReportState.Queued, + request_payload: payload satisfies Record, + }) + .onConflictDoNothing({ target: [impact_conversion_reports.dedupe_key] }) + .returning({ id: impact_conversion_reports.id }); + + const existingReport = + report ?? + (await tx.query.impact_conversion_reports.findFirst({ + where: eq( + impact_conversion_reports.dedupe_key, + `impact-referral-sale:${params.sourcePaymentId}` + ), + columns: { id: true }, + })); + impactReportId = existingReport?.id ?? null; + rewardBeneficiaryUserIds.add(params.userId); + rewardBeneficiaryUserIds.add(referrerUserId); + + return { + shouldEnqueueAffiliateSale: false, + winningTouchType: KiloClawReferralWinningTouchType.Referral, + conversionId: conversion.id, + disqualificationReason: null, + } satisfies KiloClawPaidConversionDisposition; + }); + + if (impactReportId) { + await dispatchImpactConversionReportById(impactReportId); + } + + if (rewardBeneficiaryUserIds.size > 0) { + try { + await processQueuedKiloClawReferralRewards({ + beneficiaryUserIds: Array.from(rewardBeneficiaryUserIds), + }); + } catch (error) { + console.error('[kiloclaw-referrals] failed to apply queued referral rewards', { + sourcePaymentId: params.sourcePaymentId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return disposition; +} diff --git a/apps/web/src/lib/kiloclaw/credit-billing.ts b/apps/web/src/lib/kiloclaw/credit-billing.ts index a52e0e2992..a697edf5a7 100644 --- a/apps/web/src/lib/kiloclaw/credit-billing.ts +++ b/apps/web/src/lib/kiloclaw/credit-billing.ts @@ -25,6 +25,7 @@ import { clearTrialInactivityStopAfterTrialTransition, } from '@/lib/kiloclaw/instance-lifecycle'; import { buildAffiliateEventDedupeKey, enqueueAffiliateEventForUser } from '@/lib/affiliate-events'; +import { processPersonalKiloClawPaidConversion } from '@/lib/kiloclaw-referrals'; import { computeUsageTriggeredMonthlyBonusDecision, maybeIssueKiloPassBonusFromUsageThreshold, @@ -306,6 +307,22 @@ async function enqueueCreditEnrollmentAffiliateEvents(params: { }); } + const conversionDisposition = await processPersonalKiloClawPaidConversion({ + userId: params.userId, + sourcePaymentId: params.saleOrderId, + orderId: params.saleOrderId, + amount: params.saleAmountMicrodollars / 1_000_000, + currencyCode: 'usd', + itemCategory: getKiloClawAffiliateItemCategory(params.plan), + itemName: getKiloClawAffiliateItemName(params.plan), + itemSku: params.saleItemSku, + convertedAt: params.eventDate, + }); + + if (!conversionDisposition.shouldEnqueueAffiliateSale) { + return; + } + await enqueueAffiliateEventForUser({ userId: params.userId, provider: 'impact', @@ -1291,17 +1308,6 @@ export async function enrollWithCredits(params: { after: mutatedSubscription, }); } - - await enqueueCreditEnrollmentAffiliateEvents({ - userId, - plan, - saleEntityId: saleDedupeKeyEntityId, - saleOrderId: deductionCategory, - saleAmountMicrodollars: costMicrodollars, - eventDate: now, - saleItemSku, - trialEndEntityId, - }); }); if (deductionWasDuplicate) { @@ -1328,6 +1334,17 @@ export async function enrollWithCredits(params: { throw new Error('Enrollment already processed for this billing period.'); } + await enqueueCreditEnrollmentAffiliateEvents({ + userId, + plan, + saleEntityId: saleDedupeKeyEntityId, + saleOrderId: deductionCategory, + saleAmountMicrodollars: costMicrodollars, + eventDate: now, + saleItemSku, + trialEndEntityId, + }); + // Step 4: Post-transaction bonus evaluation (spec rule 6) try { await maybeIssueKiloPassBonusFromUsageThreshold({ diff --git a/apps/web/src/lib/kiloclaw/stripe-handlers.ts b/apps/web/src/lib/kiloclaw/stripe-handlers.ts index d2ef82f1af..4d4d809b50 100644 --- a/apps/web/src/lib/kiloclaw/stripe-handlers.ts +++ b/apps/web/src/lib/kiloclaw/stripe-handlers.ts @@ -25,6 +25,7 @@ import { after } from 'next/server'; import { IS_IN_AUTOMATED_TEST } from '@/lib/config.server'; import { client as stripe } from '@/lib/stripe-client'; import { buildAffiliateEventDedupeKey, enqueueAffiliateEventForUser } from '@/lib/affiliate-events'; +import { processPersonalKiloClawPaidConversion } from '@/lib/kiloclaw-referrals'; import { IMPACT_ORDER_ID_MACRO } from '@/lib/impact'; import { CurrentPersonalSubscriptionResolutionError, @@ -1505,24 +1506,38 @@ export async function handleKiloClawInvoicePaid(params: { invoice.status_transitions?.paid_at != null ? new Date(invoice.status_transitions.paid_at * 1000) : new Date(); - await enqueueAffiliateEventForUser({ + const conversionDisposition = await processPersonalKiloClawPaidConversion({ userId: metadata.kiloUserId, - provider: 'impact', - eventType: 'sale', - dedupeKey: buildAffiliateEventDedupeKey({ - provider: 'impact', - eventType: 'sale', - entityId: invoice.id, - }), + sourcePaymentId: invoice.id, orderId: invoice.id, amount: invoice.amount_paid / 100, currencyCode: invoice.currency ?? 'usd', - eventDate, + convertedAt: eventDate, itemCategory: getImpactItemCategory(plan), itemName: getImpactItemName(plan), itemSku: matchingPriceId, - stripeChargeId: chargeId, }); + + if (conversionDisposition.shouldEnqueueAffiliateSale) { + await enqueueAffiliateEventForUser({ + userId: metadata.kiloUserId, + provider: 'impact', + eventType: 'sale', + dedupeKey: buildAffiliateEventDedupeKey({ + provider: 'impact', + eventType: 'sale', + entityId: invoice.id, + }), + orderId: invoice.id, + amount: invoice.amount_paid / 100, + currencyCode: invoice.currency ?? 'usd', + eventDate, + itemCategory: getImpactItemCategory(plan), + itemName: getImpactItemName(plan), + itemSku: matchingPriceId, + stripeChargeId: chargeId, + }); + } } catch (error) { logWarning('Affiliate sale enqueue failed', { stripe_event_id: eventId, diff --git a/apps/web/src/lib/referral.ts b/apps/web/src/lib/referral.ts index 6217a22ea3..ee77474456 100644 --- a/apps/web/src/lib/referral.ts +++ b/apps/web/src/lib/referral.ts @@ -1,6 +1,10 @@ import 'server-only'; import assert from 'node:assert'; -import { referral_code_usages, referral_codes } from '@kilocode/db/schema'; +import { + kiloclaw_referral_conversions, + referral_code_usages, + referral_codes, +} from '@kilocode/db/schema'; import { db } from '@/lib/drizzle'; import { eq, and, count, sql, isNull, isNotNull } from 'drizzle-orm'; import { captureMessage } from '@sentry/nextjs'; @@ -52,6 +56,15 @@ const redeemingReferralPromoCode = referralRedeemingBonus.credit_category; const referringReferralPromoCode = referralReferringBonus.credit_category; export async function processReferralTopUp(redeemingKiloUserId: string) { + const [kiloclawReferralConversion] = await db + .select({ id: kiloclaw_referral_conversions.id }) + .from(kiloclaw_referral_conversions) + .where(eq(kiloclaw_referral_conversions.referee_user_id, redeemingKiloUserId)) + .limit(1); + if (kiloclawReferralConversion) { + return; + } + // Validate referral eligibility using shared helper const validationResult = await validateReferralForRedemption(redeemingKiloUserId); if (validationResult === 'NOTFOUND') return; diff --git a/apps/web/src/lib/referrals.test.ts b/apps/web/src/lib/referrals.test.ts index a709e4eafd..8b7c08af70 100644 --- a/apps/web/src/lib/referrals.test.ts +++ b/apps/web/src/lib/referrals.test.ts @@ -1,4 +1,10 @@ -import { referral_codes, referral_code_usages, credit_transactions } from '@kilocode/db/schema'; +import { + referral_codes, + referral_code_usages, + credit_transactions, + kiloclaw_referral_conversions, + kilocode_users, +} from '@kilocode/db/schema'; import { db } from '@/lib/drizzle'; import { getReferralCodeForUser, @@ -16,7 +22,13 @@ describe('referrals', () => { // eslint-disable-next-line drizzle/enforce-delete-with-where await db.delete(referral_code_usages); // eslint-disable-next-line drizzle/enforce-delete-with-where + await db.delete(kiloclaw_referral_conversions); + // eslint-disable-next-line drizzle/enforce-delete-with-where + await db.delete(credit_transactions); + // eslint-disable-next-line drizzle/enforce-delete-with-where await db.delete(referral_codes); + // eslint-disable-next-line drizzle/enforce-delete-with-where + await db.delete(kilocode_users); }); it('should not create more than 1 code per user', async () => { @@ -314,5 +326,49 @@ describe('referrals', () => { .where(eq(credit_transactions.kilo_user_id, nonExistentUserId)); expect(creditTransactions).toHaveLength(0); }); + + it('does not grant legacy referral-code credits when a kiloclaw referral conversion exists', async () => { + const redeemingUser = await insertTestUser({ + google_user_email: 'kiloclaw-referee@example.com', + google_user_name: 'KiloClaw Referee', + google_user_image_url: 'https://example.com/referee.jpg', + stripe_customer_id: 'cus_test_kiloclaw_referee', + }); + const referringUser = await insertTestUser({ + google_user_email: 'kiloclaw-referrer@example.com', + google_user_name: 'KiloClaw Referrer', + google_user_image_url: 'https://example.com/referrer.jpg', + stripe_customer_id: 'cus_test_kiloclaw_referrer', + }); + + const { code } = await getReferralCodeForUser(referringUser.id); + await db.insert(referral_code_usages).values({ + code, + redeeming_kilo_user_id: redeemingUser.id, + referring_kilo_user_id: referringUser.id, + }); + await db.insert(kiloclaw_referral_conversions).values({ + referee_user_id: redeemingUser.id, + referrer_user_id: referringUser.id, + source_payment_id: 'kiloclaw-payment-1', + winning_touch_type: 'referral', + qualified: true, + converted_at: new Date().toISOString(), + }); + + await processReferralTopUp(redeemingUser.id); + + const legacyCredits = await db + .select() + .from(credit_transactions) + .where(eq(credit_transactions.kilo_user_id, redeemingUser.id)); + expect(legacyCredits).toHaveLength(0); + + const [usage] = await db + .select() + .from(referral_code_usages) + .where(eq(referral_code_usages.redeeming_kilo_user_id, redeemingUser.id)); + expect(usage?.paid_at).toBeNull(); + }); }); }); diff --git a/apps/web/src/lib/stripe.ts b/apps/web/src/lib/stripe.ts index 512d6744bb..c953cb9abb 100644 --- a/apps/web/src/lib/stripe.ts +++ b/apps/web/src/lib/stripe.ts @@ -55,6 +55,7 @@ import { handleKiloClawInvoicePaid, } from '@/lib/kiloclaw/stripe-handlers'; import { enqueueImpactSaleReversalForCharge } from '@/lib/affiliate-events'; +import { markPersonalKiloClawReferralPaymentAdverse } from '@/lib/kiloclaw-referrals'; import { invoiceLooksLikeKiloClawByPriceId } from '@/lib/kiloclaw/stripe-invoice-classifier.server'; import { STRIPE_TEAMS_MONTHLY_PRICE_ID, @@ -66,16 +67,23 @@ import type { OrganizationPlan, BillingCycle } from '@/lib/organizations/organiz import { isSeatLineItem } from '@/lib/organizations/stripe-seat-line-items'; import { successResult } from '@/lib/maybe-result'; -async function isKiloClawCharge(chargeId: string): Promise { - // The `invoice` field is present at runtime on charges but removed from newer - // Stripe TypeScript definitions. Read the response as a structural type. - // Errors (Stripe outage, network) propagate so the webhook returns non-2xx and - // Stripe retries delivery — avoids both silent drops and false backlog. +type KiloClawChargeContext = { + chargeId: string; + invoiceId: string; +}; + +async function getKiloClawChargeContext(chargeId: string): Promise { const charge: Stripe.Charge & { invoice?: string | Stripe.Invoice | null } = await client.charges.retrieve(chargeId, { expand: ['invoice'] }); const invoice = charge.invoice; - if (!invoice || typeof invoice === 'string') return false; - return invoiceLooksLikeKiloClawByPriceId(invoice); + if (!invoice || typeof invoice === 'string' || !invoiceLooksLikeKiloClawByPriceId(invoice)) { + return null; + } + + return { + chargeId, + invoiceId: invoice.id, + }; } if (!APP_URL) throw new Error('APP_URL constant is not set'); @@ -786,21 +794,64 @@ export async function processStripePaymentEventHook(event: Stripe.Event) { break; } - // Only enqueue reversals for KiloClaw charges — those are the only ones - // that produce affiliate sale events. Non-KiloClaw disputes (Kilo Pass, - // top-ups, etc.) would otherwise accumulate in pending_impact_sale_reversals - // forever because they will never have a matching sale row. - if (!(await isKiloClawCharge(chargeId))) { + const kiloClawCharge = await getKiloClawChargeContext(chargeId); + if (!kiloClawCharge) { break; } await enqueueImpactSaleReversalForCharge({ - stripeChargeId: chargeId, + stripeChargeId: kiloClawCharge.chargeId, disputeId: dispute.id, amount: dispute.amount / 100, currency: dispute.currency, eventDate: new Date(dispute.created * 1000), }); + await markPersonalKiloClawReferralPaymentAdverse({ + sourcePaymentId: kiloClawCharge.invoiceId, + reason: 'chargeback', + occurredAt: new Date(dispute.created * 1000), + }); + break; + } + + case 'charge.refunded': { + const charge = event.data.object; + if (charge.amount_refunded <= 0) { + break; + } + + const kiloClawCharge = await getKiloClawChargeContext(charge.id); + if (!kiloClawCharge) { + break; + } + + await markPersonalKiloClawReferralPaymentAdverse({ + sourcePaymentId: kiloClawCharge.invoiceId, + reason: 'refund', + occurredAt: new Date(charge.created * 1000), + }); + break; + } + + case 'charge.updated': { + const charge = event.data.object; + const isFraudMarked = + charge.fraud_details?.user_report === 'fraudulent' || + charge.fraud_details?.stripe_report === 'fraudulent'; + if (!isFraudMarked) { + break; + } + + const kiloClawCharge = await getKiloClawChargeContext(charge.id); + if (!kiloClawCharge) { + break; + } + + await markPersonalKiloClawReferralPaymentAdverse({ + sourcePaymentId: kiloClawCharge.invoiceId, + reason: 'fraud', + occurredAt: new Date(charge.created * 1000), + }); break; } diff --git a/apps/web/src/lib/user.server.ts b/apps/web/src/lib/user.server.ts index 9991d7c849..4792bd8ac8 100644 --- a/apps/web/src/lib/user.server.ts +++ b/apps/web/src/lib/user.server.ts @@ -4,7 +4,7 @@ import { validateAuthorizationHeader, JWT_TOKEN_VERSION } from './tokens'; import { NextResponse } from 'next/server'; import { cookies, headers } from 'next/headers'; -import type { CreateOrUpdateUserArgs } from './user'; +import type { CreateOrUpdateUserArgs, CreateOrUpdateUserTrackingContext } from './user'; import { findUserById, createOrUpdateUser, findAndSyncExistingUser } from './user'; import { db, readDb } from '@/lib/drizzle'; import type { @@ -30,6 +30,11 @@ import { PLATFORM } from '@/lib/integrations/core/constants'; import { verifyAndConsumeMagicLinkToken } from '@/lib/auth/magic-link-tokens'; import { redirect } from 'next/navigation'; import { IMPACT_CLICK_ID_COOKIE } from '@/lib/impact-affiliate-utils'; +import { countryCodeFromHeaders, localeFromHeaders } from '@/lib/impact-referral'; +import { + parseImpactAffiliateTouchFromUrl, + parseImpactReferralTouchFromUrl, +} from '@/lib/impact-referral-utils'; import { isOrganizationHardLocked } from '@/lib/organizations/trial-utils'; import { getMostRecentSeatPurchase } from '@/lib/organizations/organization-seats'; import { secondsInDay } from 'date-fns/constants'; @@ -403,10 +408,12 @@ async function getSignInRedirectContext(): Promise { return parseSignInRedirectContext(raw); } -async function getAffiliateTrackingIdFromAuthFlow(): Promise { +async function getImpactTrackingContextFromAuthFlow(requestHeaders?: Headers): Promise<{ + affiliateTrackingId: string | null; + trackingContext: CreateOrUpdateUserTrackingContext; +}> { const cookieStore = await cookies(); - // Prefer im_ref from the callback URL (explicitly passed through the auth flow) const callbackUrlCookie = cookieStore.get('__Secure-next-auth.callback-url')?.value ?? cookieStore.get('next-auth.callback-url')?.value; @@ -414,17 +421,38 @@ async function getAffiliateTrackingIdFromAuthFlow(): Promise { if (callbackUrlCookie) { try { const callbackUrl = new URL(callbackUrlCookie, 'http://localhost'); - const imRef = callbackUrl.searchParams.get('im_ref')?.trim(); - if (imRef) return imRef; + const affiliateTouch = parseImpactAffiliateTouchFromUrl(callbackUrl); + const referralTouch = parseImpactReferralTouchFromUrl(callbackUrl); + + return { + affiliateTrackingId: affiliateTouch?.trackingId ?? null, + trackingContext: { + affiliateTouch, + referralTouch, + locale: localeFromHeaders(requestHeaders), + countryCode: countryCodeFromHeaders(requestHeaders), + }, + }; } catch { // fall through to cookie fallback } } - // Fall back to the shared parent-domain cookie written by kilo.ai. This is - // our bridge cookie for auth redirects, not the native IR_ UTT - // cookie set by Impact itself. - return cookieStore.get(IMPACT_CLICK_ID_COOKIE)?.value?.trim() || null; + const cookieTrackingId = cookieStore.get(IMPACT_CLICK_ID_COOKIE)?.value?.trim() || null; + const fallbackUrl = new URL('http://localhost/users/after-sign-in'); + const affiliateTouch = cookieTrackingId + ? parseImpactAffiliateTouchFromUrl(fallbackUrl, cookieTrackingId) + : null; + + return { + affiliateTrackingId: cookieTrackingId, + trackingContext: { + affiliateTouch, + referralTouch: null, + locale: localeFromHeaders(requestHeaders), + countryCode: countryCodeFromHeaders(requestHeaders), + }, + }; } type ExtendedProfile = Profile & { @@ -709,8 +737,10 @@ const authOptions: NextAuthOptions = { // For email (magic link) auth, we auto-link to existing users since magic link // is verified by email ownership const autoLinkToExistingUser = isEmailAuth || isFakeLogin; - const affiliateTrackingId = - !isAccountLinking && !isFakeLogin ? await getAffiliateTrackingIdFromAuthFlow() : null; + const { affiliateTrackingId, trackingContext } = + !isAccountLinking && !isFakeLogin + ? await getImpactTrackingContextFromAuthFlow(requestHeaders) + : { affiliateTrackingId: null, trackingContext: {} }; const result = isAccountLinking && linkingSession ? whenOk( @@ -722,7 +752,8 @@ const authOptions: NextAuthOptions = { verifiedToken?.guid, autoLinkToExistingUser, requestHeaders, - affiliateTrackingId + affiliateTrackingId, + trackingContext ); if (result.success === false) { diff --git a/apps/web/src/lib/user.test.ts b/apps/web/src/lib/user.test.ts index 348e4a1fa8..95f6fad0b5 100644 --- a/apps/web/src/lib/user.test.ts +++ b/apps/web/src/lib/user.test.ts @@ -52,6 +52,16 @@ import { agent_environment_profiles, agent_environment_profile_mcp_servers, agent_environment_profile_skills, + deleted_user_email_tombstones, + kiloclaw_attribution_touches, + impact_advocate_participants, + impact_advocate_registration_attempts, + kiloclaw_referrals, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referral_reward_applications, + impact_conversion_reports, } from '@kilocode/db/schema'; import { eq, count } from 'drizzle-orm'; import { @@ -61,6 +71,7 @@ import { findUsersByIds, createOrUpdateUser, } from './user'; +import { hashNormalizedEmailForDeletionTombstone } from '@/lib/impact-referral'; import { createTestPaymentMethod } from '@/tests/helpers/payment-method.helper'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { forceImmediateExpirationRecomputation } from '@/lib/balanceCache'; @@ -86,6 +97,16 @@ describe('User', () => { await db.delete(user_auth_provider); await db.delete(user_affiliate_attributions); await db.delete(user_affiliate_events); + await db.delete(kiloclaw_attribution_touches); + await db.delete(impact_advocate_registration_attempts); + await db.delete(impact_advocate_participants); + await db.delete(impact_conversion_reports); + await db.delete(kiloclaw_referral_reward_applications); + await db.delete(kiloclaw_referral_rewards); + await db.delete(kiloclaw_referral_reward_decisions); + await db.delete(kiloclaw_referral_conversions); + await db.delete(kiloclaw_referrals); + await db.delete(deleted_user_email_tombstones); await db.delete(payment_methods); await db.delete(kilo_pass_issuance_items); await db.delete(kilo_pass_issuances); @@ -126,6 +147,9 @@ describe('User', () => { await db.delete(kiloclaw_subscriptions); await db.delete(kiloclaw_earlybird_purchases); await db.delete(kiloclaw_instances); + await db.delete(agent_environment_profile_skills); + await db.delete(agent_environment_profile_mcp_servers); + await db.delete(agent_environment_profiles); await db.delete(organizations); await db.delete(kilocode_users); }); @@ -378,6 +402,130 @@ describe('User', () => { expect(blockedUserAfter!.blocked_by_kilo_user_id).toBeNull(); }); + it('should tombstone normalized email hashes and delete referral program records', async () => { + const referrer = await insertTestUser({ + id: 'referrer-user', + google_user_email: 'referrer@example.com', + normalized_email: 'referrer@example.com', + }); + const user = await insertTestUser({ + id: 'referee-user', + google_user_email: 'referee@example.com', + normalized_email: 'referee@example.com', + }); + const touchId = randomUUID(); + const participantId = randomUUID(); + const conversionId = randomUUID(); + const decisionId = randomUUID(); + const rewardId = randomUUID(); + + await db.insert(kiloclaw_attribution_touches).values({ + id: touchId, + dedupe_key: 'touch-dedupe', + user_id: user.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + touched_at: '2026-04-23T00:00:00.000Z', + expires_at: '2026-05-23T00:00:00.000Z', + }); + await db.insert(impact_advocate_participants).values({ + id: participantId, + user_id: user.id, + advocate_id: user.id, + advocate_account_id: user.id, + contact_email: user.google_user_email, + registration_state: 'pending', + }); + await db.insert(impact_advocate_registration_attempts).values({ + participant_id: participantId, + dedupe_key: 'registration-dedupe', + opaque_cookie_value: 'sq-cookie', + cookie_value_length: 9, + delivery_state: 'queued', + }); + await db.insert(kiloclaw_referrals).values({ + referee_user_id: user.id, + referrer_user_id: referrer.id, + source_touch_id: touchId, + }); + await db.insert(kiloclaw_referral_conversions).values({ + id: conversionId, + referee_user_id: user.id, + referrer_user_id: referrer.id, + source_touch_id: touchId, + winning_touch_type: 'referral', + source_payment_id: 'payment-123', + qualified: true, + converted_at: '2026-04-23T00:00:00.000Z', + }); + await db.insert(kiloclaw_referral_reward_decisions).values({ + id: decisionId, + conversion_id: conversionId, + beneficiary_user_id: user.id, + beneficiary_role: 'referee', + outcome: 'granted', + months_granted: 1, + }); + await db.insert(kiloclaw_referral_rewards).values({ + id: rewardId, + conversion_id: conversionId, + decision_id: decisionId, + beneficiary_user_id: user.id, + beneficiary_role: 'referee', + months_granted: 1, + status: 'pending', + earned_at: '2026-04-23T00:00:00.000Z', + }); + await db.insert(kiloclaw_referral_reward_applications).values({ + reward_id: rewardId, + beneficiary_user_id: user.id, + previous_renewal_boundary: '2026-05-01T00:00:00.000Z', + new_renewal_boundary: '2026-06-01T00:00:00.000Z', + applied_at: '2026-04-23T00:00:00.000Z', + }); + await db.insert(impact_conversion_reports).values({ + conversion_id: conversionId, + dedupe_key: 'impact-report-dedupe', + action_tracker_id: 71659, + order_id: 'payment-123', + state: 'queued', + }); + + await softDeleteUser(user.id); + + const [tombstone] = await db + .select() + .from(deleted_user_email_tombstones) + .where( + eq( + deleted_user_email_tombstones.normalized_email_hash, + hashNormalizedEmailForDeletionTombstone('referee@example.com') + ) + ); + expect(tombstone).toBeDefined(); + + const [touchCount] = await db + .select({ count: count() }) + .from(kiloclaw_attribution_touches) + .where(eq(kiloclaw_attribution_touches.user_id, user.id)); + expect(touchCount.count).toBe(0); + + const [participantCount] = await db + .select({ count: count() }) + .from(impact_advocate_participants) + .where(eq(impact_advocate_participants.user_id, user.id)); + expect(participantCount.count).toBe(0); + + const [conversionCount] = await db + .select({ count: count() }) + .from(kiloclaw_referral_conversions) + .where(eq(kiloclaw_referral_conversions.referee_user_id, user.id)); + expect(conversionCount.count).toBe(0); + }); + it('should delete auth providers', async () => { const user = await insertTestUser(); await db.insert(user_auth_provider).values({ diff --git a/apps/web/src/lib/user.ts b/apps/web/src/lib/user.ts index 741288cc19..e87b33b672 100644 --- a/apps/web/src/lib/user.ts +++ b/apps/web/src/lib/user.ts @@ -63,6 +63,14 @@ import { contributor_champion_memberships, contributor_champion_contributors, credit_campaigns, + kiloclaw_attribution_touches, + impact_advocate_participants, + kiloclaw_referrals, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referral_reward_applications, + impact_conversion_reports, } from '@kilocode/db/schema'; import { eq, and, inArray, isNotNull, isNull, sql, or, gte, count } from 'drizzle-orm'; import { allow_fake_login, IS_DEVELOPMENT } from './constants'; @@ -82,6 +90,16 @@ import { import { normalizeEmail } from '@/lib/utils'; import { extractEmailDomain } from '@/lib/email-domain'; import { recordAffiliateAttributionAndQueueParentEvent } from '@/lib/affiliate-events'; +import { + createDeletedUserEmailTombstone, + queueImpactAdvocateParticipantRegistration, + recordImpactAffiliateTouch, + recordImpactReferralTouch, +} from '@/lib/impact-referral'; +import type { + ParsedImpactAffiliateTouch, + ParsedImpactReferralTouch, +} from '@/lib/impact-referral-utils'; const workos = new WorkOS(WORKOS_API_KEY); @@ -223,6 +241,14 @@ export type CreateOrUpdateUserArgs = { display_name?: string | null; }; +export type CreateOrUpdateUserTrackingContext = { + affiliateTouch?: ParsedImpactAffiliateTouch | null; + referralTouch?: ParsedImpactReferralTouch | null; + anonymousId?: string | null; + locale?: string | null; + countryCode?: string | null; +}; + export async function findAndSyncExistingUser(args: CreateOrUpdateUserArgs) { const timer = createTimer(); const existing_kilo_user_id = await findUserIdByAuthProvider( @@ -298,7 +324,8 @@ export async function createOrUpdateUser( turnstile_guid: UUID | undefined, autoLinkToExistingUser: boolean = false, requestHeaders?: Headers, - affiliateTrackingId?: string | null + affiliateTrackingId?: string | null, + trackingContext?: CreateOrUpdateUserTrackingContext ): Promise> { const existingUser = await findAndSyncExistingUser(args); if (existingUser) { @@ -444,14 +471,68 @@ export async function createOrUpdateUser( }); if (affiliateTrackingId?.trim()) { - await recordAffiliateAttributionAndQueueParentEvent({ - database: tx, - userId: inserted.id, - provider: 'impact', - trackingId: affiliateTrackingId, - customerEmail: inserted.google_user_email, - eventDate: new Date(inserted.created_at), - }); + try { + await recordAffiliateAttributionAndQueueParentEvent({ + database: tx, + userId: inserted.id, + provider: 'impact', + trackingId: affiliateTrackingId, + customerEmail: inserted.google_user_email, + eventDate: new Date(inserted.created_at), + }); + } catch (error) { + console.error('[user] failed to persist affiliate attribution during signup', { + userId: inserted.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (trackingContext?.affiliateTouch) { + try { + await recordImpactAffiliateTouch({ + database: tx, + userId: inserted.id, + anonymousId: trackingContext.anonymousId ?? null, + touch: trackingContext.affiliateTouch, + }); + } catch (error) { + console.error('[user] failed to record affiliate touch during signup', { + userId: inserted.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (trackingContext?.referralTouch) { + try { + await recordImpactReferralTouch({ + database: tx, + userId: inserted.id, + anonymousId: trackingContext.anonymousId ?? null, + touch: trackingContext.referralTouch, + }); + } catch (error) { + console.error('[user] failed to record referral touch during signup', { + userId: inserted.id, + error: error instanceof Error ? error.message : String(error), + }); + } + + try { + await queueImpactAdvocateParticipantRegistration({ + database: tx, + user: inserted, + referralTouch: trackingContext.referralTouch, + locale: trackingContext.locale, + countryCode: trackingContext.countryCode, + }); + } catch (error) { + console.error('[user] failed to enqueue Impact Advocate registration during signup', { + userId: inserted.id, + error: error instanceof Error ? error.message : String(error), + }); + } } return successResult({ user: inserted }); @@ -687,6 +768,11 @@ export async function softDeleteUser(userId: string) { ); } + await createDeletedUserEmailTombstone({ + database: tx, + normalizedEmail: user.normalized_email, + }); + // ── 1. Anonymize the user row ──────────────────────────────────────── await tx .update(kilocode_users) @@ -723,6 +809,43 @@ export async function softDeleteUser(userId: string) { .delete(user_affiliate_attributions) .where(eq(user_affiliate_attributions.user_id, userId)); await tx.delete(user_affiliate_events).where(eq(user_affiliate_events.user_id, userId)); + await tx + .delete(kiloclaw_attribution_touches) + .where(eq(kiloclaw_attribution_touches.user_id, userId)); + await tx + .delete(impact_advocate_participants) + .where(eq(impact_advocate_participants.user_id, userId)); + await tx + .delete(kiloclaw_referral_reward_applications) + .where(eq(kiloclaw_referral_reward_applications.beneficiary_user_id, userId)); + await tx + .delete(kiloclaw_referral_rewards) + .where(eq(kiloclaw_referral_rewards.beneficiary_user_id, userId)); + await tx + .delete(kiloclaw_referral_reward_decisions) + .where(eq(kiloclaw_referral_reward_decisions.beneficiary_user_id, userId)); + await tx.delete(impact_conversion_reports).where( + sql`${impact_conversion_reports.conversion_id} IN ( + SELECT c.id FROM ${kiloclaw_referral_conversions} c + WHERE c.referee_user_id = ${userId} OR c.referrer_user_id = ${userId} + )` + ); + await tx + .delete(kiloclaw_referral_conversions) + .where( + or( + eq(kiloclaw_referral_conversions.referee_user_id, userId), + eq(kiloclaw_referral_conversions.referrer_user_id, userId) + ) + ); + await tx + .delete(kiloclaw_referrals) + .where( + or( + eq(kiloclaw_referrals.referee_user_id, userId), + eq(kiloclaw_referrals.referrer_user_id, userId) + ) + ); await tx.delete(referral_codes).where(eq(referral_codes.kilo_user_id, userId)); await tx.delete(magic_link_tokens).where(eq(magic_link_tokens.email, originalEmail)); diff --git a/apps/web/src/types/impact.d.ts b/apps/web/src/types/impact.d.ts index 008e1f8936..80d7c50917 100644 --- a/apps/web/src/types/impact.d.ts +++ b/apps/web/src/types/impact.d.ts @@ -1,9 +1,23 @@ +import type { DetailedHTMLProps, HTMLAttributes, ReactNode } from 'react'; + declare global { interface Window { ire?: (...args: unknown[]) => void; + impactToken?: string; } function ire(...args: unknown[]): void; } +declare module 'react' { + namespace JSX { + interface IntrinsicElements { + 'impact-embed': DetailedHTMLProps, HTMLElement> & { + widget?: string; + children?: ReactNode; + }; + } + } +} + export {}; diff --git a/dev/seed/kiloclaw/referrals-cap-boundary.ts b/dev/seed/kiloclaw/referrals-cap-boundary.ts new file mode 100644 index 0000000000..d249324470 --- /dev/null +++ b/dev/seed/kiloclaw/referrals-cap-boundary.ts @@ -0,0 +1,277 @@ +import { + kiloclaw_referral_conversions, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referrals, +} from '@kilocode/db/schema'; +import { and, eq } from 'drizzle-orm'; +import { + KiloClawReferralBeneficiaryRole, + KiloClawReferralDecisionOutcome, + KiloClawReferralRewardStatus, + KiloClawReferralWinningTouchType, +} from '@kilocode/db/schema-types'; + +import { getSeedDb } from '../lib/db'; +import { + addDays, + assertUserCount, + cleanupKiloClawReferralSeedScenario, + insertImpactAdvocateParticipant, + insertPersonalSubscription, + insertSeedUsers, + seedEmail, + seedOpaqueReferralIdentifier, + seedSourcePaymentId, + seedUserId, +} from '../lib/kiloclaw-referrals'; + +const SCENARIO = 'referrals-cap-boundary'; +const SEED_SCOPE = `kiloclaw/${SCENARIO}`; +const referrerUserId = seedUserId(SCENARIO, 'referrer'); +const referrerEmail = seedEmail(SCENARIO, 'referrer'); +const currentRefereeUserId = seedUserId(SCENARIO, 'current-referee'); +const currentRefereeEmail = seedEmail(SCENARIO, 'current-referee'); +const opaqueReferralIdentifier = seedOpaqueReferralIdentifier(SCENARIO, 'primary'); + +function buildHistoricalReferee(i: number) { + const role = `historical-referee-${i}`; + return { + id: seedUserId(SCENARIO, role), + email: seedEmail(SCENARIO, role), + name: `Seed Cap Boundary Referee ${i}`, + }; +} + +export async function run(): Promise { + const db = getSeedDb(); + const historicalReferees = Array.from({ length: 12 }, (_, index) => + buildHistoricalReferee(index + 1) + ); + const userIds = [ + referrerUserId, + currentRefereeUserId, + ...historicalReferees.map(user => user.id), + ]; + + console.log(`[${SEED_SCOPE}] Resetting existing seed data`); + await cleanupKiloClawReferralSeedScenario({ + scenario: SCENARIO, + userIds, + }); + + console.log(`[${SEED_SCOPE}] Inserting referrer plus historical and current referees`); + await insertSeedUsers([ + { + id: referrerUserId, + email: referrerEmail, + name: 'Seed Cap Boundary Referrer', + }, + { + id: currentRefereeUserId, + email: currentRefereeEmail, + name: 'Seed Cap Boundary Current Referee', + }, + ...historicalReferees, + ]); + await assertUserCount({ userIds, expectedCount: userIds.length }); + + console.log( + `[${SEED_SCOPE}] Inserting the referrer participant and an active personal subscription` + ); + await insertImpactAdvocateParticipant({ + userId: referrerUserId, + email: referrerEmail, + opaqueReferralIdentifier, + registeredAt: '2026-01-01T12:00:00.000Z', + }); + const { subscription: referrerSubscription } = await insertPersonalSubscription({ + userId: referrerUserId, + sandboxId: `sandbox-${referrerUserId}`, + name: 'Seed Cap Boundary Referrer', + plan: 'standard', + status: 'active', + paymentSource: 'credits', + currentPeriodStart: '2026-12-01T00:00:00.000Z', + currentPeriodEnd: '2027-01-01T00:00:00.000Z', + creditRenewalAt: '2027-01-01T00:00:00.000Z', + }); + + console.log(`[${SEED_SCOPE}] Inserting 12 previously granted referrer months`); + for (const [index, historicalReferee] of historicalReferees.entries()) { + const convertedAt = addDays('2026-01-15T12:00:00.000Z', index * 20); + const [referral] = await db + .insert(kiloclaw_referrals) + .values({ + referee_user_id: historicalReferee.id, + referrer_user_id: referrerUserId, + impact_referral_id: opaqueReferralIdentifier, + }) + .returning({ id: kiloclaw_referrals.id }); + + const [conversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: historicalReferee.id, + referrer_user_id: referrerUserId, + winning_touch_type: KiloClawReferralWinningTouchType.Referral, + source_payment_id: seedSourcePaymentId(SCENARIO, `historical-${index + 1}`), + qualified: true, + converted_at: convertedAt, + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + const [refereeDecision, referrerDecision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values([ + { + conversion_id: conversion.id, + beneficiary_user_id: historicalReferee.id, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + outcome: KiloClawReferralDecisionOutcome.Granted, + months_granted: 1, + }, + { + conversion_id: conversion.id, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + outcome: KiloClawReferralDecisionOutcome.Granted, + months_granted: 1, + }, + ]) + .returning({ + id: kiloclaw_referral_reward_decisions.id, + beneficiaryRole: kiloclaw_referral_reward_decisions.beneficiary_role, + }); + + const refereeDecisionId = + refereeDecision.beneficiaryRole === 'referee' ? refereeDecision.id : referrerDecision.id; + const referrerDecisionId = + refereeDecision.beneficiaryRole === 'referrer' ? refereeDecision.id : referrerDecision.id; + + await db.insert(kiloclaw_referral_rewards).values([ + { + conversion_id: conversion.id, + decision_id: refereeDecisionId, + beneficiary_user_id: historicalReferee.id, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + months_granted: 1, + status: KiloClawReferralRewardStatus.Earned, + earned_at: convertedAt, + }, + { + conversion_id: conversion.id, + decision_id: referrerDecisionId, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + months_granted: 1, + status: KiloClawReferralRewardStatus.Earned, + applies_to_subscription_id: referrerSubscription.id, + earned_at: convertedAt, + }, + ]); + + console.log( + ` - historical referee ${index + 1}: referral ${referral.id}, conversion ${conversion.id}` + ); + } + + console.log( + `[${SEED_SCOPE}] Inserting the next qualified conversion with a cap-limited referrer outcome` + ); + const [currentReferral] = await db + .insert(kiloclaw_referrals) + .values({ + referee_user_id: currentRefereeUserId, + referrer_user_id: referrerUserId, + impact_referral_id: opaqueReferralIdentifier, + }) + .returning({ id: kiloclaw_referrals.id }); + + const [currentConversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: currentRefereeUserId, + referrer_user_id: referrerUserId, + winning_touch_type: KiloClawReferralWinningTouchType.Referral, + source_payment_id: seedSourcePaymentId(SCENARIO, 'current-13th'), + qualified: true, + converted_at: '2026-12-15T12:00:00.000Z', + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + const [currentRefereeDecision, currentReferrerDecision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values([ + { + conversion_id: currentConversion.id, + beneficiary_user_id: currentRefereeUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + outcome: KiloClawReferralDecisionOutcome.Granted, + months_granted: 1, + }, + { + conversion_id: currentConversion.id, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + outcome: KiloClawReferralDecisionOutcome.CapLimited, + reason: 'referral_referrer_cap_reached', + months_granted: 0, + }, + ]) + .returning({ + id: kiloclaw_referral_reward_decisions.id, + beneficiaryRole: kiloclaw_referral_reward_decisions.beneficiary_role, + }); + + const currentGrantedDecisionId = + currentRefereeDecision.beneficiaryRole === 'referee' + ? currentRefereeDecision.id + : currentReferrerDecision.id; + const currentCapLimitedDecisionId = + currentRefereeDecision.beneficiaryRole === 'referrer' + ? currentRefereeDecision.id + : currentReferrerDecision.id; + + await db.insert(kiloclaw_referral_rewards).values({ + conversion_id: currentConversion.id, + decision_id: currentGrantedDecisionId, + beneficiary_user_id: currentRefereeUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + months_granted: 1, + status: KiloClawReferralRewardStatus.Earned, + earned_at: '2026-12-15T12:00:00.000Z', + }); + + const referrerGrantedMonths = await db + .select({ id: kiloclaw_referral_reward_decisions.id }) + .from(kiloclaw_referral_reward_decisions) + .where( + and( + eq(kiloclaw_referral_reward_decisions.beneficiary_user_id, referrerUserId), + eq( + kiloclaw_referral_reward_decisions.beneficiary_role, + KiloClawReferralBeneficiaryRole.Referrer + ), + eq(kiloclaw_referral_reward_decisions.outcome, KiloClawReferralDecisionOutcome.Granted) + ) + ); + + console.log(''); + console.log(`[${SEED_SCOPE}] Seed complete`); + console.log(''); + console.log(`referrerUserId: ${referrerUserId}`); + console.log(`currentRefereeUserId: ${currentRefereeUserId}`); + console.log(`currentReferralId: ${currentReferral.id}`); + console.log(`currentConversionId: ${currentConversion.id}`); + console.log(`currentCapLimitedDecisionId: ${currentCapLimitedDecisionId}`); + console.log(`referrerSubscriptionId: ${referrerSubscription.id}`); + console.log(`grantedReferrerMonthsBeforeCapDecision: ${referrerGrantedMonths.length}`); + console.log(''); + console.log('This fixture represents:'); + console.log('- 12 previously granted referrer reward months already recorded'); + console.log('- a 13th qualified referral where the referee still gets a reward'); + console.log( + '- the referrer decision is recorded as cap-limited with no extra referrer reward row' + ); +} diff --git a/dev/seed/kiloclaw/referrals-happy-path.ts b/dev/seed/kiloclaw/referrals-happy-path.ts new file mode 100644 index 0000000000..2a00ae4b16 --- /dev/null +++ b/dev/seed/kiloclaw/referrals-happy-path.ts @@ -0,0 +1,317 @@ +import { + credit_transactions, + impact_conversion_reports, + kiloclaw_attribution_touches, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referrals, +} from '@kilocode/db/schema'; +import { + ImpactConversionReportState, + KiloClawAttributionTouchProvider, + KiloClawAttributionTouchType, + KiloClawReferralBeneficiaryRole, + KiloClawReferralDecisionOutcome, + KiloClawReferralRewardStatus, + KiloClawReferralWinningTouchType, +} from '@kilocode/db/schema-types'; + +import { getSeedDb } from '../lib/db'; +import { + assertUserCount, + cleanupKiloClawReferralSeedScenario, + insertAppliedRewardChangeLog, + insertImpactAdvocateParticipant, + insertPersonalSubscription, + insertSeedUsers, + seedEmail, + seedLabelForScenario, + seedOpaqueReferralIdentifier, + seedOrderId, + seedSourcePaymentId, + seedUserId, +} from '../lib/kiloclaw-referrals'; + +const SCENARIO = 'referrals-happy-path'; +const SEED_SCOPE = `kiloclaw/${SCENARIO}`; + +const referrerUserId = seedUserId(SCENARIO, 'referrer'); +const refereeUserId = seedUserId(SCENARIO, 'referee'); +const userIds = [referrerUserId, refereeUserId]; +const referrerEmail = seedEmail(SCENARIO, 'referrer'); +const refereeEmail = seedEmail(SCENARIO, 'referee'); +const opaqueReferralIdentifier = seedOpaqueReferralIdentifier(SCENARIO, 'primary'); +const sourcePaymentId = seedSourcePaymentId(SCENARIO, 'period-1'); +const orderId = seedOrderId(SCENARIO, 'period-1'); +const touchedAtAffiliate = '2026-04-10T12:00:00.000Z'; +const touchedAtReferral = '2026-04-11T09:00:00.000Z'; +const convertedAt = '2026-04-15T16:30:00.000Z'; +const previousRenewalBoundary = '2026-05-01T00:00:00.000Z'; +const newRenewalBoundary = '2026-06-01T00:00:00.000Z'; + +export async function run(): Promise { + const db = getSeedDb(); + + console.log(`[${SEED_SCOPE}] Resetting existing seed data`); + await cleanupKiloClawReferralSeedScenario({ + scenario: SCENARIO, + userIds, + }); + + console.log(`[${SEED_SCOPE}] Inserting referrer and referee users`); + await insertSeedUsers([ + { + id: referrerUserId, + email: referrerEmail, + name: 'Seed Referrals Happy Referrer', + }, + { + id: refereeUserId, + email: refereeEmail, + name: 'Seed Referrals Happy Referee', + }, + ]); + await assertUserCount({ userIds, expectedCount: 2 }); + + console.log(`[${SEED_SCOPE}] Inserting active personal subscriptions with applied reward state`); + const { subscription: referrerSubscription } = await insertPersonalSubscription({ + userId: referrerUserId, + sandboxId: `sandbox-${referrerUserId}`, + name: 'Seed Happy Path Referrer', + plan: 'standard', + status: 'active', + paymentSource: 'credits', + currentPeriodStart: '2026-04-01T00:00:00.000Z', + currentPeriodEnd: newRenewalBoundary, + creditRenewalAt: newRenewalBoundary, + }); + const { subscription: refereeSubscription } = await insertPersonalSubscription({ + userId: refereeUserId, + sandboxId: `sandbox-${refereeUserId}`, + name: 'Seed Happy Path Referee', + plan: 'standard', + status: 'active', + paymentSource: 'credits', + currentPeriodStart: '2026-04-01T00:00:00.000Z', + currentPeriodEnd: newRenewalBoundary, + creditRenewalAt: newRenewalBoundary, + }); + + console.log(`[${SEED_SCOPE}] Inserting registered Advocate participant for the referrer`); + await insertImpactAdvocateParticipant({ + userId: referrerUserId, + email: referrerEmail, + opaqueReferralIdentifier, + registeredAt: '2026-04-01T12:00:00.000Z', + }); + + console.log(`[${SEED_SCOPE}] Inserting affiliate and referral touches for the referee`); + const [affiliateTouch] = await db + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: `${seedLabelForScenario(SCENARIO)}:touch:affiliate`, + user_id: refereeUserId, + touch_type: KiloClawAttributionTouchType.Affiliate, + provider: KiloClawAttributionTouchProvider.ImpactPerformance, + opaque_tracking_value: `${seedLabelForScenario(SCENARIO)}:im-ref`, + tracking_value_length: 42, + is_tracking_value_accepted: true, + im_ref: `${seedLabelForScenario(SCENARIO)}:im-ref`, + landing_path: '/pricing?im_ref=seed', + touched_at: touchedAtAffiliate, + expires_at: '2026-05-10T12:00:00.000Z', + }) + .returning({ id: kiloclaw_attribution_touches.id }); + + const [referralTouch] = await db + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: `${seedLabelForScenario(SCENARIO)}:touch:referral`, + user_id: refereeUserId, + touch_type: KiloClawAttributionTouchType.Referral, + provider: KiloClawAttributionTouchProvider.ImpactAdvocate, + opaque_tracking_value: `${seedLabelForScenario(SCENARIO)}:cookie`, + tracking_value_length: 40, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + rs_share_medium: 'copy_link', + rs_engagement_medium: 'direct', + landing_path: '/pricing?_saasquatch=seed', + touched_at: touchedAtReferral, + expires_at: '2026-05-11T09:00:00.000Z', + }) + .returning({ id: kiloclaw_attribution_touches.id }); + + console.log(`[${SEED_SCOPE}] Materializing the qualified referral conversion and rewards`); + const [referral] = await db + .insert(kiloclaw_referrals) + .values({ + referee_user_id: refereeUserId, + referrer_user_id: referrerUserId, + source_touch_id: referralTouch.id, + impact_referral_id: opaqueReferralIdentifier, + }) + .returning({ id: kiloclaw_referrals.id }); + + const [conversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: refereeUserId, + referrer_user_id: referrerUserId, + source_touch_id: referralTouch.id, + winning_touch_type: KiloClawReferralWinningTouchType.Referral, + source_payment_id: sourcePaymentId, + qualified: true, + converted_at: convertedAt, + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + const [refereeDecision, referrerDecision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values([ + { + conversion_id: conversion.id, + beneficiary_user_id: refereeUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + outcome: KiloClawReferralDecisionOutcome.Granted, + months_granted: 1, + }, + { + conversion_id: conversion.id, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + outcome: KiloClawReferralDecisionOutcome.Granted, + months_granted: 1, + }, + ]) + .returning({ + id: kiloclaw_referral_reward_decisions.id, + beneficiaryRole: kiloclaw_referral_reward_decisions.beneficiary_role, + }); + + const refereeDecisionId = + refereeDecision.beneficiaryRole === 'referee' ? refereeDecision.id : referrerDecision.id; + const referrerDecisionId = + refereeDecision.beneficiaryRole === 'referrer' ? refereeDecision.id : referrerDecision.id; + + const [refereeReward, referrerReward] = await db + .insert(kiloclaw_referral_rewards) + .values([ + { + conversion_id: conversion.id, + decision_id: refereeDecisionId, + beneficiary_user_id: refereeUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + months_granted: 1, + status: KiloClawReferralRewardStatus.Applied, + applies_to_subscription_id: refereeSubscription.id, + earned_at: convertedAt, + applied_at: '2026-04-15T16:40:00.000Z', + }, + { + conversion_id: conversion.id, + decision_id: referrerDecisionId, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + months_granted: 1, + status: KiloClawReferralRewardStatus.Applied, + applies_to_subscription_id: referrerSubscription.id, + earned_at: convertedAt, + applied_at: '2026-04-15T16:42:00.000Z', + }, + ]) + .returning({ + id: kiloclaw_referral_rewards.id, + beneficiaryUserId: kiloclaw_referral_rewards.beneficiary_user_id, + }); + + const refereeRewardId = + refereeReward.beneficiaryUserId === refereeUserId ? refereeReward.id : referrerReward.id; + const referrerRewardId = + refereeReward.beneficiaryUserId === referrerUserId ? refereeReward.id : referrerReward.id; + + await db.insert(kiloclaw_referral_reward_applications).values([ + { + reward_id: refereeRewardId, + beneficiary_user_id: refereeUserId, + subscription_id: refereeSubscription.id, + previous_renewal_boundary: previousRenewalBoundary, + new_renewal_boundary: newRenewalBoundary, + local_operation_id: `${seedLabelForScenario(SCENARIO)}:reward:referee`, + applied_at: '2026-04-15T16:40:00.000Z', + }, + { + reward_id: referrerRewardId, + beneficiary_user_id: referrerUserId, + subscription_id: referrerSubscription.id, + previous_renewal_boundary: previousRenewalBoundary, + new_renewal_boundary: newRenewalBoundary, + local_operation_id: `${seedLabelForScenario(SCENARIO)}:reward:referrer`, + applied_at: '2026-04-15T16:42:00.000Z', + }, + ]); + + await insertAppliedRewardChangeLog({ + subscription: refereeSubscription, + previousBoundary: previousRenewalBoundary, + newBoundary: newRenewalBoundary, + }); + await insertAppliedRewardChangeLog({ + subscription: referrerSubscription, + previousBoundary: previousRenewalBoundary, + newBoundary: newRenewalBoundary, + }); + + await db.insert(credit_transactions).values({ + kilo_user_id: refereeUserId, + amount_microdollars: -2000000, + is_free: false, + description: 'Seed referral happy path paid period', + credit_category: sourcePaymentId, + created_at: convertedAt, + }); + + await db.insert(impact_conversion_reports).values({ + conversion_id: conversion.id, + dedupe_key: `${seedLabelForScenario(SCENARIO)}:impact-report`, + action_tracker_id: 71659, + order_id: orderId, + state: ImpactConversionReportState.Delivered, + request_payload: { + orderId, + sourcePaymentId, + scenario: SCENARIO, + winningTouchType: 'referral', + }, + response_payload: { + ok: true, + actionId: `${seedLabelForScenario(SCENARIO)}:impact-action`, + }, + response_status_code: 200, + attempt_count: 1, + delivered_at: '2026-04-15T16:35:00.000Z', + }); + + console.log(''); + console.log(`[${SEED_SCOPE}] Seed complete`); + console.log(''); + console.log(`referrerUserId: ${referrerUserId}`); + console.log(`refereeUserId: ${refereeUserId}`); + console.log(`referralId: ${referral.id}`); + console.log(`conversionId: ${conversion.id}`); + console.log(`affiliateTouchId: ${affiliateTouch.id}`); + console.log(`referralTouchId: ${referralTouch.id}`); + console.log(`sourcePaymentId: ${sourcePaymentId}`); + console.log(`orderId: ${orderId}`); + console.log(`referrerSubscriptionId: ${referrerSubscription.id}`); + console.log(`refereeSubscriptionId: ${refereeSubscription.id}`); + console.log(''); + console.log('This fixture represents:'); + console.log('- affiliate touch first, referral touch second'); + console.log('- no prior affiliate SALE attribution'); + console.log('- referral wins at first paid conversion'); + console.log('- both rewards already applied to personal credits subscriptions'); + console.log('- Impact sale report already delivered'); +} diff --git a/dev/seed/kiloclaw/referrals-pending-referrer.ts b/dev/seed/kiloclaw/referrals-pending-referrer.ts new file mode 100644 index 0000000000..4238ac413a --- /dev/null +++ b/dev/seed/kiloclaw/referrals-pending-referrer.ts @@ -0,0 +1,281 @@ +import { + credit_transactions, + impact_conversion_reports, + kiloclaw_attribution_touches, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referrals, +} from '@kilocode/db/schema'; +import { + ImpactConversionReportState, + KiloClawAttributionTouchProvider, + KiloClawAttributionTouchType, + KiloClawReferralBeneficiaryRole, + KiloClawReferralDecisionOutcome, + KiloClawReferralRewardStatus, + KiloClawReferralWinningTouchType, +} from '@kilocode/db/schema-types'; + +import { getSeedDb } from '../lib/db'; +import { + addMonthsUtc, + assertUserCount, + cleanupKiloClawReferralSeedScenario, + insertAppliedRewardChangeLog, + insertImpactAdvocateParticipant, + insertPersonalSubscription, + insertSeedUsers, + seedEmail, + seedLabelForScenario, + seedOpaqueReferralIdentifier, + seedOrderId, + seedSourcePaymentId, + seedUserId, +} from '../lib/kiloclaw-referrals'; + +const SCENARIO = 'referrals-pending-referrer'; +const SEED_SCOPE = `kiloclaw/${SCENARIO}`; + +const referrerUserId = seedUserId(SCENARIO, 'referrer'); +const refereeUserId = seedUserId(SCENARIO, 'referee'); +const userIds = [referrerUserId, refereeUserId]; +const referrerEmail = seedEmail(SCENARIO, 'referrer'); +const refereeEmail = seedEmail(SCENARIO, 'referee'); +const opaqueReferralIdentifier = seedOpaqueReferralIdentifier(SCENARIO, 'primary'); +const sourcePaymentId = seedSourcePaymentId(SCENARIO, 'period-1'); +const orderId = seedOrderId(SCENARIO, 'period-1'); +const convertedAt = '2026-04-15T16:30:00.000Z'; +const previousRenewalBoundary = '2026-05-01T00:00:00.000Z'; +const newRenewalBoundary = '2026-06-01T00:00:00.000Z'; + +export async function run(): Promise { + const db = getSeedDb(); + + console.log(`[${SEED_SCOPE}] Resetting existing seed data`); + await cleanupKiloClawReferralSeedScenario({ + scenario: SCENARIO, + userIds, + }); + + console.log(`[${SEED_SCOPE}] Inserting referrer and referee users`); + await insertSeedUsers([ + { + id: referrerUserId, + email: referrerEmail, + name: 'Seed Pending Referrer', + }, + { + id: refereeUserId, + email: refereeEmail, + name: 'Seed Pending Referee', + }, + ]); + await assertUserCount({ userIds, expectedCount: 2 }); + + console.log( + `[${SEED_SCOPE}] Inserting a trial subscription for the referrer and an active subscription for the referee` + ); + const { subscription: referrerTrialSubscription } = await insertPersonalSubscription({ + userId: referrerUserId, + sandboxId: `sandbox-${referrerUserId}`, + name: 'Seed Pending Referrer Trial', + plan: 'trial', + status: 'trialing', + trialStartedAt: '2026-04-01T00:00:00.000Z', + trialEndsAt: '2026-04-20T00:00:00.000Z', + }); + const { subscription: refereeSubscription } = await insertPersonalSubscription({ + userId: refereeUserId, + sandboxId: `sandbox-${refereeUserId}`, + name: 'Seed Pending Referee Active', + plan: 'standard', + status: 'active', + paymentSource: 'credits', + currentPeriodStart: '2026-04-01T00:00:00.000Z', + currentPeriodEnd: newRenewalBoundary, + creditRenewalAt: newRenewalBoundary, + }); + + console.log(`[${SEED_SCOPE}] Inserting registered Advocate participant for the referrer`); + await insertImpactAdvocateParticipant({ + userId: referrerUserId, + email: referrerEmail, + opaqueReferralIdentifier, + registeredAt: '2026-04-01T12:00:00.000Z', + }); + + const [referralTouch] = await db + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: `${seedLabelForScenario(SCENARIO)}:touch:referral`, + user_id: refereeUserId, + touch_type: KiloClawAttributionTouchType.Referral, + provider: KiloClawAttributionTouchProvider.ImpactAdvocate, + opaque_tracking_value: `${seedLabelForScenario(SCENARIO)}:cookie`, + tracking_value_length: 44, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + rs_share_medium: 'email', + rs_engagement_medium: 'share', + landing_path: '/pricing?_saasquatch=seed', + touched_at: '2026-04-11T09:00:00.000Z', + expires_at: '2026-05-11T09:00:00.000Z', + }) + .returning({ id: kiloclaw_attribution_touches.id }); + + console.log( + `[${SEED_SCOPE}] Materializing the qualified referral with a pending referrer reward` + ); + const [referral] = await db + .insert(kiloclaw_referrals) + .values({ + referee_user_id: refereeUserId, + referrer_user_id: referrerUserId, + source_touch_id: referralTouch.id, + impact_referral_id: opaqueReferralIdentifier, + }) + .returning({ id: kiloclaw_referrals.id }); + + const [conversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: refereeUserId, + referrer_user_id: referrerUserId, + source_touch_id: referralTouch.id, + winning_touch_type: KiloClawReferralWinningTouchType.Referral, + source_payment_id: sourcePaymentId, + qualified: true, + converted_at: convertedAt, + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + const [refereeDecision, referrerDecision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values([ + { + conversion_id: conversion.id, + beneficiary_user_id: refereeUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + outcome: KiloClawReferralDecisionOutcome.Granted, + months_granted: 1, + }, + { + conversion_id: conversion.id, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + outcome: KiloClawReferralDecisionOutcome.Granted, + months_granted: 1, + }, + ]) + .returning({ + id: kiloclaw_referral_reward_decisions.id, + beneficiaryRole: kiloclaw_referral_reward_decisions.beneficiary_role, + }); + + const refereeDecisionId = + refereeDecision.beneficiaryRole === 'referee' ? refereeDecision.id : referrerDecision.id; + const referrerDecisionId = + refereeDecision.beneficiaryRole === 'referrer' ? refereeDecision.id : referrerDecision.id; + + const [refereeReward, referrerReward] = await db + .insert(kiloclaw_referral_rewards) + .values([ + { + conversion_id: conversion.id, + decision_id: refereeDecisionId, + beneficiary_user_id: refereeUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referee, + months_granted: 1, + status: KiloClawReferralRewardStatus.Applied, + applies_to_subscription_id: refereeSubscription.id, + earned_at: convertedAt, + applied_at: '2026-04-15T16:41:00.000Z', + }, + { + conversion_id: conversion.id, + decision_id: referrerDecisionId, + beneficiary_user_id: referrerUserId, + beneficiary_role: KiloClawReferralBeneficiaryRole.Referrer, + months_granted: 1, + status: KiloClawReferralRewardStatus.Pending, + earned_at: convertedAt, + expires_at: addMonthsUtc(convertedAt, 12), + }, + ]) + .returning({ + id: kiloclaw_referral_rewards.id, + beneficiaryUserId: kiloclaw_referral_rewards.beneficiary_user_id, + }); + + const refereeRewardId = + refereeReward.beneficiaryUserId === refereeUserId ? refereeReward.id : referrerReward.id; + const referrerRewardId = + refereeReward.beneficiaryUserId === referrerUserId ? refereeReward.id : referrerReward.id; + + await db.insert(kiloclaw_referral_reward_applications).values({ + reward_id: refereeRewardId, + beneficiary_user_id: refereeUserId, + subscription_id: refereeSubscription.id, + previous_renewal_boundary: previousRenewalBoundary, + new_renewal_boundary: newRenewalBoundary, + local_operation_id: `${seedLabelForScenario(SCENARIO)}:reward:referee`, + applied_at: '2026-04-15T16:41:00.000Z', + }); + + await insertAppliedRewardChangeLog({ + subscription: refereeSubscription, + previousBoundary: previousRenewalBoundary, + newBoundary: newRenewalBoundary, + }); + + await db.insert(credit_transactions).values({ + kilo_user_id: refereeUserId, + amount_microdollars: -2000000, + is_free: false, + description: 'Seed pending-referrer paid period', + credit_category: sourcePaymentId, + created_at: convertedAt, + }); + + await db.insert(impact_conversion_reports).values({ + conversion_id: conversion.id, + dedupe_key: `${seedLabelForScenario(SCENARIO)}:impact-report`, + action_tracker_id: 71659, + order_id: orderId, + state: ImpactConversionReportState.Delivered, + request_payload: { + orderId, + sourcePaymentId, + scenario: SCENARIO, + rewardState: 'pending-referrer', + }, + response_payload: { + ok: true, + actionId: `${seedLabelForScenario(SCENARIO)}:impact-action`, + }, + response_status_code: 200, + attempt_count: 1, + delivered_at: '2026-04-15T16:35:00.000Z', + }); + + console.log(''); + console.log(`[${SEED_SCOPE}] Seed complete`); + console.log(''); + console.log(`referrerUserId: ${referrerUserId}`); + console.log(`refereeUserId: ${refereeUserId}`); + console.log(`referralId: ${referral.id}`); + console.log(`conversionId: ${conversion.id}`); + console.log(`sourcePaymentId: ${sourcePaymentId}`); + console.log(`orderId: ${orderId}`); + console.log(`referrerTrialSubscriptionId: ${referrerTrialSubscription.id}`); + console.log(`refereeSubscriptionId: ${refereeSubscription.id}`); + console.log(`pendingReferrerRewardId: ${referrerRewardId}`); + console.log(''); + console.log('This fixture represents:'); + console.log('- a qualified referral conversion'); + console.log('- the referee reward already applied'); + console.log('- the referrer still on a trial, so their reward remains pending'); + console.log('- the pending reward already has a 12-month expiration timestamp'); +} diff --git a/dev/seed/kiloclaw/referrals-support-override.ts b/dev/seed/kiloclaw/referrals-support-override.ts new file mode 100644 index 0000000000..531f82d634 --- /dev/null +++ b/dev/seed/kiloclaw/referrals-support-override.ts @@ -0,0 +1,172 @@ +import { credit_transactions, kiloclaw_attribution_touches } from '@kilocode/db/schema'; +import { + KiloClawAttributionTouchProvider, + KiloClawAttributionTouchType, +} from '@kilocode/db/schema-types'; + +import { getSeedDb } from '../lib/db'; +import { + assertUserCount, + cleanupKiloClawReferralSeedScenario, + insertImpactAdvocateParticipant, + insertPersonalSubscription, + insertSeedUsers, + seedEmail, + seedLabelForScenario, + seedOpaqueReferralIdentifier, + seedOrderId, + seedSourcePaymentId, + seedUserId, +} from '../lib/kiloclaw-referrals'; + +const SCENARIO = 'referrals-support-override'; +const SEED_SCOPE = `kiloclaw/${SCENARIO}`; +const referrerUserId = seedUserId(SCENARIO, 'referrer'); +const refereeUserId = seedUserId(SCENARIO, 'referee'); +const userIds = [referrerUserId, refereeUserId]; +const referrerEmail = seedEmail(SCENARIO, 'referrer'); +const refereeEmail = seedEmail(SCENARIO, 'referee'); +const opaqueReferralIdentifier = seedOpaqueReferralIdentifier(SCENARIO, 'primary'); +const sourcePaymentId = seedSourcePaymentId(SCENARIO, 'manual-adjustment'); +const orderId = seedOrderId(SCENARIO, 'manual-adjustment'); +const convertedAt = '2026-04-15T16:30:00.000Z'; + +export async function run(): Promise { + const db = getSeedDb(); + + console.log(`[${SEED_SCOPE}] Resetting existing seed data`); + await cleanupKiloClawReferralSeedScenario({ + scenario: SCENARIO, + userIds, + }); + + console.log(`[${SEED_SCOPE}] Inserting referrer and referee users`); + await insertSeedUsers([ + { + id: referrerUserId, + email: referrerEmail, + name: 'Seed Support Override Referrer', + }, + { + id: refereeUserId, + email: refereeEmail, + name: 'Seed Support Override Referee', + }, + ]); + await assertUserCount({ userIds, expectedCount: 2 }); + + console.log(`[${SEED_SCOPE}] Inserting active personal subscriptions for both users`); + const { subscription: referrerSubscription } = await insertPersonalSubscription({ + userId: referrerUserId, + sandboxId: `sandbox-${referrerUserId}`, + name: 'Seed Support Override Referrer', + plan: 'standard', + status: 'active', + paymentSource: 'credits', + currentPeriodStart: '2026-04-01T00:00:00.000Z', + currentPeriodEnd: '2026-05-01T00:00:00.000Z', + creditRenewalAt: '2026-05-01T00:00:00.000Z', + }); + const { subscription: refereeSubscription } = await insertPersonalSubscription({ + userId: refereeUserId, + sandboxId: `sandbox-${refereeUserId}`, + name: 'Seed Support Override Referee', + plan: 'standard', + status: 'active', + paymentSource: 'credits', + currentPeriodStart: '2026-04-01T00:00:00.000Z', + currentPeriodEnd: '2026-05-01T00:00:00.000Z', + creditRenewalAt: '2026-05-01T00:00:00.000Z', + }); + + console.log( + `[${SEED_SCOPE}] Inserting the referrer participant and a valid referral touch on the referee` + ); + await insertImpactAdvocateParticipant({ + userId: referrerUserId, + email: referrerEmail, + opaqueReferralIdentifier, + registeredAt: '2026-04-01T12:00:00.000Z', + }); + + const [affiliateTouch] = await db + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: `${seedLabelForScenario(SCENARIO)}:touch:affiliate`, + user_id: refereeUserId, + touch_type: KiloClawAttributionTouchType.Affiliate, + provider: KiloClawAttributionTouchProvider.ImpactPerformance, + opaque_tracking_value: `${seedLabelForScenario(SCENARIO)}:im-ref`, + tracking_value_length: 50, + is_tracking_value_accepted: true, + im_ref: `${seedLabelForScenario(SCENARIO)}:im-ref`, + landing_path: '/pricing?im_ref=seed', + touched_at: '2026-04-10T12:00:00.000Z', + expires_at: '2026-05-10T12:00:00.000Z', + }) + .returning({ id: kiloclaw_attribution_touches.id }); + + const [referralTouch] = await db + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: `${seedLabelForScenario(SCENARIO)}:touch:referral`, + user_id: refereeUserId, + touch_type: KiloClawAttributionTouchType.Referral, + provider: KiloClawAttributionTouchProvider.ImpactAdvocate, + opaque_tracking_value: `${seedLabelForScenario(SCENARIO)}:cookie`, + tracking_value_length: 48, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + rs_share_medium: 'support', + rs_engagement_medium: 'manual', + landing_path: '/pricing?_saasquatch=seed', + touched_at: '2026-04-11T09:00:00.000Z', + expires_at: '2026-05-11T09:00:00.000Z', + }) + .returning({ id: kiloclaw_attribution_touches.id }); + + console.log( + `[${SEED_SCOPE}] Inserting a manual-adjustment payment record ready for admin override processing` + ); + await db.insert(credit_transactions).values({ + kilo_user_id: refereeUserId, + amount_microdollars: -2000000, + is_free: false, + description: 'Manual seed adjustment for referral override verification', + credit_category: sourcePaymentId, + created_at: convertedAt, + }); + + console.log(''); + console.log(`[${SEED_SCOPE}] Seed complete`); + console.log(''); + console.log(`referrerUserId: ${referrerUserId}`); + console.log(`refereeUserId: ${refereeUserId}`); + console.log(`referrerSubscriptionId: ${referrerSubscription.id}`); + console.log(`refereeSubscriptionId: ${refereeSubscription.id}`); + console.log(`affiliateTouchId: ${affiliateTouch.id}`); + console.log(`referralTouchId: ${referralTouch.id}`); + console.log(`sourcePaymentId: ${sourcePaymentId}`); + console.log(`orderId: ${orderId}`); + console.log(''); + console.log('This fixture represents:'); + console.log('- a valid referral touch that would normally win over the affiliate touch'); + console.log('- a source payment that heuristically looks like a manual adjustment'); + console.log('- no conversion rows yet, so an authorized operator can test the override flow'); + console.log(''); + console.log('Suggested next step (requires an authenticated admin session):'); + console.log( + ` curl -X POST http://localhost:3000/admin/api/users/${refereeUserId}/kiloclaw-referral-eligibility \\\n -H 'content-type: application/json' \\\n --data '${JSON.stringify( + { + sourcePaymentId, + orderId, + amount: 20, + currencyCode: 'USD', + itemCategory: 'kiloclaw_subscription', + itemName: 'KiloClaw Standard', + convertedAt, + sourceType: 'manual_adjustment', + } + )}'` + ); +} diff --git a/dev/seed/lib/kiloclaw-referrals.ts b/dev/seed/lib/kiloclaw-referrals.ts new file mode 100644 index 0000000000..89cc0903a6 --- /dev/null +++ b/dev/seed/lib/kiloclaw-referrals.ts @@ -0,0 +1,363 @@ +import { insertKiloClawSubscriptionChangeLog } from '@kilocode/db'; +import { + credit_transactions, + impact_advocate_participants, + impact_advocate_registration_attempts, + impact_conversion_reports, + kiloclaw_attribution_touches, + kiloclaw_instances, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referrals, + kiloclaw_subscription_change_log, + kiloclaw_subscriptions, + kilocode_users, + referral_codes, +} from '@kilocode/db/schema'; +import { and, eq, inArray, like, or } from 'drizzle-orm'; + +import { getSeedDb } from './db'; + +type SeedUserFixture = { + id: string; + email: string; + name: string; + imageUrl?: string; + stripeCustomerId?: string; + normalizedEmail?: string; + isAdmin?: boolean; +}; + +type PersonalSubscriptionFixture = { + userId: string; + sandboxId: string; + name?: string | null; + organizationId?: string | null; + plan: 'trial' | 'standard' | 'commit'; + status: 'trialing' | 'active' | 'past_due' | 'suspended' | 'canceled'; + paymentSource?: 'credits' | 'hybrid' | 'stripe'; + currentPeriodStart?: string | null; + currentPeriodEnd?: string | null; + creditRenewalAt?: string | null; + trialStartedAt?: string | null; + trialEndsAt?: string | null; + commitEndsAt?: string | null; + cancelAtPeriodEnd?: boolean; +}; + +function buildOrConditions(conditions: Array): TCondition[] { + return conditions.filter(condition => condition !== undefined); +} + +export function seedLabelForScenario(scenario: string): string { + return `seed:kiloclaw:${scenario}`; +} + +export function seedUserId(scenario: string, role: string): string { + return `seed-kiloclaw-${scenario}-${role}`; +} + +export function seedEmail(scenario: string, role: string): string { + return `${seedUserId(scenario, role)}@example.com`; +} + +export function seedOpaqueReferralIdentifier(scenario: string, slug: string): string { + return `${seedLabelForScenario(scenario)}:share:${slug}`; +} + +export function seedSourcePaymentId(scenario: string, slug: string): string { + return `kiloclaw-subscription:seed-kiloclaw-${scenario}-${slug}`; +} + +export function seedOrderId(scenario: string, slug: string): string { + return `${seedLabelForScenario(scenario)}:order:${slug}`; +} + +export function addDays(iso: string, days: number): string { + const date = new Date(iso); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString(); +} + +export function addMonthsUtc(iso: string, months: number): string { + const date = new Date(iso); + const originalDay = date.getUTCDate(); + + date.setUTCDate(1); + date.setUTCMonth(date.getUTCMonth() + months); + + const lastDayOfTargetMonth = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0) + ).getUTCDate(); + + date.setUTCDate(Math.min(originalDay, lastDayOfTargetMonth)); + return date.toISOString(); +} + +export async function cleanupKiloClawReferralSeedScenario(params: { + scenario: string; + userIds: string[]; +}): Promise { + const db = getSeedDb(); + const scenarioPrefix = `${seedLabelForScenario(params.scenario)}%`; + + const conversionRows = await db + .select({ id: kiloclaw_referral_conversions.id }) + .from(kiloclaw_referral_conversions) + .where( + or( + like(kiloclaw_referral_conversions.source_payment_id, scenarioPrefix), + inArray(kiloclaw_referral_conversions.referee_user_id, params.userIds), + inArray(kiloclaw_referral_conversions.referrer_user_id, params.userIds) + ) + ); + const conversionIds = conversionRows.map(row => row.id); + + const rewardRows = conversionIds.length + ? await db + .select({ id: kiloclaw_referral_rewards.id }) + .from(kiloclaw_referral_rewards) + .where( + or( + inArray(kiloclaw_referral_rewards.conversion_id, conversionIds), + inArray(kiloclaw_referral_rewards.beneficiary_user_id, params.userIds) + ) + ) + : await db + .select({ id: kiloclaw_referral_rewards.id }) + .from(kiloclaw_referral_rewards) + .where(inArray(kiloclaw_referral_rewards.beneficiary_user_id, params.userIds)); + const rewardIds = rewardRows.map(row => row.id); + + const participantRows = await db + .select({ id: impact_advocate_participants.id }) + .from(impact_advocate_participants) + .where(inArray(impact_advocate_participants.user_id, params.userIds)); + const participantIds = participantRows.map(row => row.id); + + const subscriptionRows = await db + .select({ id: kiloclaw_subscriptions.id }) + .from(kiloclaw_subscriptions) + .where(inArray(kiloclaw_subscriptions.user_id, params.userIds)); + const subscriptionIds = subscriptionRows.map(row => row.id); + + const rewardApplicationConditions = buildOrConditions([ + rewardIds.length + ? inArray(kiloclaw_referral_reward_applications.reward_id, rewardIds) + : undefined, + params.userIds.length + ? inArray(kiloclaw_referral_reward_applications.beneficiary_user_id, params.userIds) + : undefined, + ]); + if (rewardApplicationConditions.length > 0) { + await db + .delete(kiloclaw_referral_reward_applications) + .where(or(...rewardApplicationConditions)); + } + + const reportConditions = buildOrConditions([ + conversionIds.length + ? inArray(impact_conversion_reports.conversion_id, conversionIds) + : undefined, + like(impact_conversion_reports.dedupe_key, scenarioPrefix), + ]); + await db.delete(impact_conversion_reports).where(or(...reportConditions)); + + const rewardConditions = buildOrConditions([ + rewardIds.length ? inArray(kiloclaw_referral_rewards.id, rewardIds) : undefined, + params.userIds.length + ? inArray(kiloclaw_referral_rewards.beneficiary_user_id, params.userIds) + : undefined, + ]); + if (rewardConditions.length > 0) { + await db.delete(kiloclaw_referral_rewards).where(or(...rewardConditions)); + } + + if (conversionIds.length > 0) { + await db + .delete(kiloclaw_referral_reward_decisions) + .where(inArray(kiloclaw_referral_reward_decisions.conversion_id, conversionIds)); + } + + await db + .delete(kiloclaw_referrals) + .where( + or( + inArray(kiloclaw_referrals.referee_user_id, params.userIds), + inArray(kiloclaw_referrals.referrer_user_id, params.userIds) + ) + ); + + const conversionConditions = buildOrConditions([ + like(kiloclaw_referral_conversions.source_payment_id, scenarioPrefix), + inArray(kiloclaw_referral_conversions.referee_user_id, params.userIds), + inArray(kiloclaw_referral_conversions.referrer_user_id, params.userIds), + ]); + await db.delete(kiloclaw_referral_conversions).where(or(...conversionConditions)); + + const attemptConditions = buildOrConditions([ + like(impact_advocate_registration_attempts.dedupe_key, scenarioPrefix), + participantIds.length + ? inArray(impact_advocate_registration_attempts.participant_id, participantIds) + : undefined, + ]); + await db.delete(impact_advocate_registration_attempts).where(or(...attemptConditions)); + + await db + .delete(kiloclaw_attribution_touches) + .where( + or( + like(kiloclaw_attribution_touches.dedupe_key, scenarioPrefix), + inArray(kiloclaw_attribution_touches.user_id, params.userIds) + ) + ); + + await db + .delete(credit_transactions) + .where( + or( + inArray(credit_transactions.kilo_user_id, params.userIds), + like(credit_transactions.credit_category, scenarioPrefix), + like( + credit_transactions.credit_category, + `kiloclaw-subscription:seed-kiloclaw-${params.scenario}%` + ) + ) + ); + + if (subscriptionIds.length > 0) { + await db + .delete(kiloclaw_subscription_change_log) + .where(inArray(kiloclaw_subscription_change_log.subscription_id, subscriptionIds)); + } + + await db + .delete(kiloclaw_subscriptions) + .where(inArray(kiloclaw_subscriptions.user_id, params.userIds)); + await db.delete(kiloclaw_instances).where(inArray(kiloclaw_instances.user_id, params.userIds)); + await db + .delete(impact_advocate_participants) + .where(inArray(impact_advocate_participants.user_id, params.userIds)); + await db.delete(referral_codes).where(inArray(referral_codes.kilo_user_id, params.userIds)); + await db.delete(kilocode_users).where(inArray(kilocode_users.id, params.userIds)); +} + +export async function insertSeedUsers(users: SeedUserFixture[]): Promise { + const db = getSeedDb(); + await db.insert(kilocode_users).values( + users.map(user => ({ + id: user.id, + google_user_email: user.email, + google_user_name: user.name, + google_user_image_url: + user.imageUrl ?? `https://example.com/${encodeURIComponent(user.id)}.png`, + stripe_customer_id: + user.stripeCustomerId ?? `cus_${user.id.replaceAll(/[^a-zA-Z0-9]/g, '_')}`, + normalized_email: user.normalizedEmail ?? user.email.toLowerCase(), + is_admin: user.isAdmin ?? false, + })) + ); +} + +export async function insertImpactAdvocateParticipant(params: { + userId: string; + email: string; + opaqueReferralIdentifier: string; + registeredAt?: string; +}): Promise { + const db = getSeedDb(); + await db.insert(referral_codes).values({ + kilo_user_id: params.userId, + code: params.opaqueReferralIdentifier, + }); + await db.insert(impact_advocate_participants).values({ + user_id: params.userId, + advocate_id: params.userId, + advocate_account_id: params.userId, + opaque_referral_identifier: params.opaqueReferralIdentifier, + contact_email: params.email, + registration_state: 'registered', + registered_at: params.registeredAt ?? new Date().toISOString(), + }); +} + +export async function insertPersonalSubscription(fixture: PersonalSubscriptionFixture): Promise<{ + subscription: typeof kiloclaw_subscriptions.$inferSelect; + instance: typeof kiloclaw_instances.$inferSelect; +}> { + const db = getSeedDb(); + + const [instance] = await db + .insert(kiloclaw_instances) + .values({ + user_id: fixture.userId, + sandbox_id: fixture.sandboxId, + provider: 'docker-local', + name: fixture.name ?? null, + organization_id: fixture.organizationId ?? null, + }) + .returning(); + + const [subscription] = await db + .insert(kiloclaw_subscriptions) + .values({ + user_id: fixture.userId, + instance_id: instance.id, + payment_source: fixture.paymentSource, + plan: fixture.plan, + status: fixture.status, + cancel_at_period_end: fixture.cancelAtPeriodEnd ?? false, + current_period_start: fixture.currentPeriodStart ?? null, + current_period_end: fixture.currentPeriodEnd ?? null, + credit_renewal_at: fixture.creditRenewalAt ?? null, + trial_started_at: fixture.trialStartedAt ?? null, + trial_ends_at: fixture.trialEndsAt ?? null, + commit_ends_at: fixture.commitEndsAt ?? null, + }) + .returning(); + + return { subscription, instance }; +} + +export async function insertAppliedRewardChangeLog(params: { + subscription: typeof kiloclaw_subscriptions.$inferSelect; + previousBoundary: string; + newBoundary: string; +}): Promise { + const beforeSubscription = { + ...params.subscription, + current_period_end: params.previousBoundary, + credit_renewal_at: params.previousBoundary, + }; + const afterSubscription = { + ...params.subscription, + current_period_end: params.newBoundary, + credit_renewal_at: params.newBoundary, + }; + + await insertKiloClawSubscriptionChangeLog(getSeedDb(), { + subscriptionId: params.subscription.id, + actor: { + actorType: 'system', + actorId: 'kiloclaw-referrals', + }, + action: 'period_advanced', + reason: 'referral_reward:applied', + before: beforeSubscription, + after: afterSubscription, + }); +} + +export async function assertUserCount(params: { userIds: string[]; expectedCount: number }) { + const db = getSeedDb(); + const rows = await db + .select({ id: kilocode_users.id }) + .from(kilocode_users) + .where(inArray(kilocode_users.id, params.userIds)); + + if (rows.length !== params.expectedCount) { + throw new Error(`Expected ${params.expectedCount} seed users, found ${rows.length}`); + } +} diff --git a/package.json b/package.json index b8cc165841..a3b89c9819 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,12 @@ "format:changed": "git diff --name-only $(git merge-base origin/main HEAD) --diff-filter=ACMR -- '**/*.js' '**/*.jsx' '**/*.ts' '**/*.tsx' '**/*.json' '**/*.css' '**/*.md' | xargs -r oxfmt --no-error-on-unmatched-pattern", "validate": "pnpm run typecheck && pnpm run lint && pnpm run test", "drizzle": "pnpm --filter @kilocode/db exec drizzle-kit", + "drizzle:verify-bootstrap": "bash scripts/verify-drizzle-bootstrap.sh", "test:e2e": "pnpm --filter web run test:e2e", "dependency-cycle-check": "pnpm --filter web run dependency-cycle-check", "worktree:prepare": "bash scripts/worktree-prepare.sh", "test:db": "docker compose -f dev/docker-compose.yml up -d --wait postgres && pnpm drizzle migrate", + "dev:db:reset": "pnpm --filter web db:empty-database", "dev:start": "tsx dev/local/cli.ts up", "dev:stop": "tsx dev/local/cli.ts stop", "dev:status": "tsx dev/local/cli.ts status", @@ -30,7 +32,7 @@ "dev:discord-gateway-cron": "tsx dev/discord-gateway-cron.ts", "dev:kiloclaw-fly-instances": "tsx services/kiloclaw/scripts/dev-fly-instances.ts" }, - "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", + "packageManager": "pnpm@10.33.2", "devDependencies": { "@typescript/native-preview": "catalog:", "husky": "^9.1.7", diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index f374c63bce..bea31333ab 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -225,6 +225,91 @@ export const AffiliateEventDeliveryState = { export type AffiliateEventDeliveryState = (typeof AffiliateEventDeliveryState)[keyof typeof AffiliateEventDeliveryState]; +export const KiloClawAttributionTouchType = { + Affiliate: 'affiliate', + Referral: 'referral', +} as const; + +export type KiloClawAttributionTouchType = + (typeof KiloClawAttributionTouchType)[keyof typeof KiloClawAttributionTouchType]; + +export const KiloClawAttributionTouchProvider = { + ImpactPerformance: 'impact_performance', + ImpactAdvocate: 'impact_advocate', +} as const; + +export type KiloClawAttributionTouchProvider = + (typeof KiloClawAttributionTouchProvider)[keyof typeof KiloClawAttributionTouchProvider]; + +export const ImpactAdvocateRegistrationState = { + Pending: 'pending', + Retrying: 'retrying', + Registered: 'registered', + Failed: 'failed', +} as const; + +export type ImpactAdvocateRegistrationState = + (typeof ImpactAdvocateRegistrationState)[keyof typeof ImpactAdvocateRegistrationState]; + +export const ImpactAdvocateAttemptDeliveryState = { + Queued: 'queued', + Sending: 'sending', + Succeeded: 'succeeded', + Failed: 'failed', +} as const; + +export type ImpactAdvocateAttemptDeliveryState = + (typeof ImpactAdvocateAttemptDeliveryState)[keyof typeof ImpactAdvocateAttemptDeliveryState]; + +export const KiloClawReferralBeneficiaryRole = { + Referrer: 'referrer', + Referee: 'referee', +} as const; + +export type KiloClawReferralBeneficiaryRole = + (typeof KiloClawReferralBeneficiaryRole)[keyof typeof KiloClawReferralBeneficiaryRole]; + +export const KiloClawReferralWinningTouchType = { + Referral: 'referral', + Affiliate: 'affiliate', + None: 'none', +} as const; + +export type KiloClawReferralWinningTouchType = + (typeof KiloClawReferralWinningTouchType)[keyof typeof KiloClawReferralWinningTouchType]; + +export const KiloClawReferralDecisionOutcome = { + Granted: 'granted', + CapLimited: 'cap_limited', + Disqualified: 'disqualified', +} as const; + +export type KiloClawReferralDecisionOutcome = + (typeof KiloClawReferralDecisionOutcome)[keyof typeof KiloClawReferralDecisionOutcome]; + +export const KiloClawReferralRewardStatus = { + Pending: 'pending', + Earned: 'earned', + Applied: 'applied', + Reversed: 'reversed', + Expired: 'expired', + Canceled: 'canceled', + ReviewRequired: 'review_required', +} as const; + +export type KiloClawReferralRewardStatus = + (typeof KiloClawReferralRewardStatus)[keyof typeof KiloClawReferralRewardStatus]; + +export const ImpactConversionReportState = { + Queued: 'queued', + Retrying: 'retrying', + Delivered: 'delivered', + Failed: 'failed', +} as const; + +export type ImpactConversionReportState = + (typeof ImpactConversionReportState)[keyof typeof ImpactConversionReportState]; + // NOTE: Do not change these action names. Use present tense for consistency. export const KiloClawAdminAuditAction = z.enum([ 'kiloclaw.volume.extend', diff --git a/packages/db/src/schema.test.ts b/packages/db/src/schema.test.ts index 860645307e..2a2c6f8595 100644 --- a/packages/db/src/schema.test.ts +++ b/packages/db/src/schema.test.ts @@ -122,6 +122,23 @@ describe('database schema', () => { AffiliateProvider: ['impact'], AffiliateEventType: ['signup', 'trial_start', 'trial_end', 'sale', 'sale_reversal'], AffiliateEventDeliveryState: ['queued', 'blocked', 'sending', 'delivered', 'failed'], + KiloClawAttributionTouchType: ['affiliate', 'referral'], + KiloClawAttributionTouchProvider: ['impact_advocate', 'impact_performance'], + ImpactAdvocateRegistrationState: ['pending', 'retrying', 'registered', 'failed'], + ImpactAdvocateAttemptDeliveryState: ['queued', 'sending', 'succeeded', 'failed'], + KiloClawReferralBeneficiaryRole: ['referrer', 'referee'], + KiloClawReferralWinningTouchType: ['referral', 'affiliate', 'none'], + KiloClawReferralDecisionOutcome: ['granted', 'cap_limited', 'disqualified'], + KiloClawReferralRewardStatus: [ + 'pending', + 'earned', + 'applied', + 'reversed', + 'expired', + 'canceled', + 'review_required', + ], + ImpactConversionReportState: ['queued', 'retrying', 'delivered', 'failed'], }; const actualEnumValues: Record = {}; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 55f1199b68..98759cd9fd 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -49,6 +49,15 @@ import { AffiliateProvider, AffiliateEventType, AffiliateEventDeliveryState, + KiloClawAttributionTouchType, + KiloClawAttributionTouchProvider, + ImpactAdvocateRegistrationState, + ImpactAdvocateAttemptDeliveryState, + KiloClawReferralBeneficiaryRole, + KiloClawReferralWinningTouchType, + KiloClawReferralDecisionOutcome, + KiloClawReferralRewardStatus, + ImpactConversionReportState, } from './schema-types'; import type { CustomLlmDefinition, @@ -132,6 +141,15 @@ export const SCHEMA_CHECK_ENUMS = { AffiliateProvider, AffiliateEventType, AffiliateEventDeliveryState, + KiloClawAttributionTouchType, + KiloClawAttributionTouchProvider, + ImpactAdvocateRegistrationState, + ImpactAdvocateAttemptDeliveryState, + KiloClawReferralBeneficiaryRole, + KiloClawReferralWinningTouchType, + KiloClawReferralDecisionOutcome, + KiloClawReferralRewardStatus, + ImpactConversionReportState, } as const; export type AffiliateEventPayloadJson = { @@ -433,6 +451,429 @@ export const pending_impact_sale_reversals = pgTable( export type PendingImpactSaleReversal = typeof pending_impact_sale_reversals.$inferSelect; +export const deleted_user_email_tombstones = pgTable('deleted_user_email_tombstones', { + normalized_email_hash: text().primaryKey().notNull(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}); + +export type DeletedUserEmailTombstone = typeof deleted_user_email_tombstones.$inferSelect; + +export const kiloclaw_attribution_touches = pgTable( + 'kiloclaw_attribution_touches', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + dedupe_key: text().notNull(), + anonymous_id: text(), + user_id: text().references(() => kilocode_users.id, { + onDelete: 'set null', + onUpdate: 'cascade', + }), + touch_type: text().notNull().$type(), + provider: text().notNull().$type(), + opaque_tracking_value: text(), + tracking_value_length: integer().notNull(), + is_tracking_value_accepted: boolean().notNull().default(true), + rs_code: text(), + rs_share_medium: text(), + rs_engagement_medium: text(), + im_ref: text(), + landing_path: text(), + utm_source: text(), + utm_medium: text(), + utm_campaign: text(), + utm_term: text(), + utm_content: text(), + touched_at: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + expires_at: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + sale_attributed_at: timestamp({ withTimezone: true, mode: 'string' }), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + unique('UQ_kiloclaw_attribution_touches_dedupe_key').on(table.dedupe_key), + index('IDX_kiloclaw_attribution_touches_user_id').on(table.user_id), + index('IDX_kiloclaw_attribution_touches_anonymous_id').on(table.anonymous_id), + index('IDX_kiloclaw_attribution_touches_expires_at').on(table.expires_at), + index('IDX_kiloclaw_attribution_touches_sale_attributed_at').on(table.sale_attributed_at), + enumCheck( + 'kiloclaw_attribution_touches_touch_type_check', + table.touch_type, + KiloClawAttributionTouchType + ), + enumCheck( + 'kiloclaw_attribution_touches_provider_check', + table.provider, + KiloClawAttributionTouchProvider + ), + check( + 'kiloclaw_attribution_touches_tracking_value_length_non_negative_check', + sql`${table.tracking_value_length} >= 0` + ), + ] +); + +export type KiloClawAttributionTouch = typeof kiloclaw_attribution_touches.$inferSelect; + +export const impact_advocate_participants = pgTable( + 'impact_advocate_participants', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + advocate_id: text().notNull(), + advocate_account_id: text().notNull(), + opaque_referral_identifier: text(), + contact_email: text(), + locale: text(), + country_code: text(), + registration_state: text() + .notNull() + .$type() + .default(ImpactAdvocateRegistrationState.Pending), + registered_at: timestamp({ withTimezone: true, mode: 'string' }), + last_registration_attempt_at: timestamp({ withTimezone: true, mode: 'string' }), + last_error_code: text(), + last_error_message: text(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp({ withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull() + .$onUpdateFn(() => sql`now()`), + }, + table => [ + unique('UQ_impact_advocate_participants_user_id').on(table.user_id), + unique('UQ_impact_advocate_participants_opaque_referral_identifier').on( + table.opaque_referral_identifier + ), + index('IDX_impact_advocate_participants_registration_state').on(table.registration_state), + enumCheck( + 'impact_advocate_participants_registration_state_check', + table.registration_state, + ImpactAdvocateRegistrationState + ), + ] +); + +export type ImpactAdvocateParticipant = typeof impact_advocate_participants.$inferSelect; + +export const impact_advocate_registration_attempts = pgTable( + 'impact_advocate_registration_attempts', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + participant_id: uuid() + .notNull() + .references(() => impact_advocate_participants.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + dedupe_key: text().notNull(), + opaque_cookie_value: text(), + cookie_value_length: integer().notNull(), + delivery_state: text() + .notNull() + .$type() + .default(ImpactAdvocateAttemptDeliveryState.Queued), + request_payload: jsonb().$type | null>(), + response_payload: jsonb().$type | null>(), + response_status_code: integer(), + attempt_count: integer().notNull().default(0), + next_retry_at: timestamp({ withTimezone: true, mode: 'string' }), + claimed_at: timestamp({ withTimezone: true, mode: 'string' }), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp({ withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull() + .$onUpdateFn(() => sql`now()`), + }, + table => [ + unique('UQ_impact_advocate_registration_attempts_dedupe_key').on(table.dedupe_key), + index('IDX_impact_advocate_registration_attempts_participant_id').on(table.participant_id), + index('IDX_impact_advocate_registration_attempts_delivery_state').on(table.delivery_state), + enumCheck( + 'impact_advocate_registration_attempts_delivery_state_check', + table.delivery_state, + ImpactAdvocateAttemptDeliveryState + ), + check( + 'impact_advocate_registration_attempts_cookie_value_length_non_negative_check', + sql`${table.cookie_value_length} >= 0` + ), + check( + 'impact_advocate_registration_attempts_attempt_count_non_negative_check', + sql`${table.attempt_count} >= 0` + ), + ] +); + +export type ImpactAdvocateRegistrationAttempt = + typeof impact_advocate_registration_attempts.$inferSelect; + +export const kiloclaw_referrals = pgTable( + 'kiloclaw_referrals', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + referee_user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + referrer_user_id: text().references(() => kilocode_users.id, { + onDelete: 'set null', + onUpdate: 'cascade', + }), + source_touch_id: uuid().references(() => kiloclaw_attribution_touches.id, { + onDelete: 'set null', + onUpdate: 'cascade', + }), + impact_referral_id: text(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + unique('UQ_kiloclaw_referrals_referee_user_id').on(table.referee_user_id), + index('IDX_kiloclaw_referrals_referrer_user_id').on(table.referrer_user_id), + index('IDX_kiloclaw_referrals_source_touch_id').on(table.source_touch_id), + ] +); + +export type KiloClawReferral = typeof kiloclaw_referrals.$inferSelect; + +export const kiloclaw_referral_conversions = pgTable( + 'kiloclaw_referral_conversions', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + referee_user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + referrer_user_id: text().references(() => kilocode_users.id, { + onDelete: 'set null', + onUpdate: 'cascade', + }), + source_touch_id: uuid().references(() => kiloclaw_attribution_touches.id, { + onDelete: 'set null', + onUpdate: 'cascade', + }), + winning_touch_type: text().notNull().$type(), + source_payment_id: text().notNull(), + qualified: boolean().notNull().default(false), + disqualification_reason: text(), + converted_at: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + unique('UQ_kiloclaw_referral_conversions_source_payment_id').on(table.source_payment_id), + index('IDX_kiloclaw_referral_conversions_referee_user_id').on(table.referee_user_id), + index('IDX_kiloclaw_referral_conversions_referrer_user_id').on(table.referrer_user_id), + enumCheck( + 'kiloclaw_referral_conversions_winning_touch_type_check', + table.winning_touch_type, + KiloClawReferralWinningTouchType + ), + ] +); + +export type KiloClawReferralConversion = typeof kiloclaw_referral_conversions.$inferSelect; + +export const kiloclaw_referral_reward_decisions = pgTable( + 'kiloclaw_referral_reward_decisions', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + conversion_id: uuid() + .notNull() + .references(() => kiloclaw_referral_conversions.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + beneficiary_user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + beneficiary_role: text().notNull().$type(), + outcome: text().notNull().$type(), + reason: text(), + months_granted: integer().notNull().default(0), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + unique('UQ_kiloclaw_referral_reward_decisions_conversion_role').on( + table.conversion_id, + table.beneficiary_role + ), + index('IDX_kiloclaw_referral_reward_decisions_beneficiary_user_id').on( + table.beneficiary_user_id + ), + enumCheck( + 'kiloclaw_referral_reward_decisions_beneficiary_role_check', + table.beneficiary_role, + KiloClawReferralBeneficiaryRole + ), + enumCheck( + 'kiloclaw_referral_reward_decisions_outcome_check', + table.outcome, + KiloClawReferralDecisionOutcome + ), + check( + 'kiloclaw_referral_reward_decisions_months_granted_non_negative_check', + sql`${table.months_granted} >= 0` + ), + ] +); + +export type KiloClawReferralRewardDecision = typeof kiloclaw_referral_reward_decisions.$inferSelect; + +export const kiloclaw_referral_rewards = pgTable( + 'kiloclaw_referral_rewards', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + conversion_id: uuid() + .notNull() + .references(() => kiloclaw_referral_conversions.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + decision_id: uuid() + .notNull() + .references(() => kiloclaw_referral_reward_decisions.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + beneficiary_user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + beneficiary_role: text().notNull().$type(), + months_granted: integer().notNull().default(1), + status: text() + .notNull() + .$type() + .default(KiloClawReferralRewardStatus.Pending), + applies_to_subscription_id: uuid(), + earned_at: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + applied_at: timestamp({ withTimezone: true, mode: 'string' }), + reversed_at: timestamp({ withTimezone: true, mode: 'string' }), + expires_at: timestamp({ withTimezone: true, mode: 'string' }), + review_reason: text(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + unique('UQ_kiloclaw_referral_rewards_conversion_role').on( + table.conversion_id, + table.beneficiary_role + ), + unique('UQ_kiloclaw_referral_rewards_decision_id').on(table.decision_id), + index('IDX_kiloclaw_referral_rewards_beneficiary_user_id').on(table.beneficiary_user_id), + index('IDX_kiloclaw_referral_rewards_status').on(table.status), + enumCheck( + 'kiloclaw_referral_rewards_beneficiary_role_check', + table.beneficiary_role, + KiloClawReferralBeneficiaryRole + ), + enumCheck('kiloclaw_referral_rewards_status_check', table.status, KiloClawReferralRewardStatus), + check( + 'kiloclaw_referral_rewards_months_granted_positive_check', + sql`${table.months_granted} > 0` + ), + ] +); + +export type KiloClawReferralReward = typeof kiloclaw_referral_rewards.$inferSelect; + +export const kiloclaw_referral_reward_applications = pgTable( + 'kiloclaw_referral_reward_applications', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + reward_id: uuid() + .notNull() + .references(() => kiloclaw_referral_rewards.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + beneficiary_user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + subscription_id: uuid(), + previous_renewal_boundary: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + new_renewal_boundary: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + local_operation_id: text(), + stripe_operation_id: text(), + stripe_idempotency_key: text(), + applied_at: timestamp({ withTimezone: true, mode: 'string' }).notNull(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + }, + table => [ + index('IDX_kiloclaw_referral_reward_applications_reward_id').on(table.reward_id), + index('IDX_kiloclaw_referral_reward_applications_beneficiary_user_id').on( + table.beneficiary_user_id + ), + ] +); + +export type KiloClawReferralRewardApplication = + typeof kiloclaw_referral_reward_applications.$inferSelect; + +export const impact_conversion_reports = pgTable( + 'impact_conversion_reports', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + conversion_id: uuid().references(() => kiloclaw_referral_conversions.id, { + onDelete: 'set null', + onUpdate: 'cascade', + }), + dedupe_key: text().notNull(), + action_tracker_id: integer().notNull(), + order_id: text().notNull(), + state: text() + .notNull() + .$type() + .default(ImpactConversionReportState.Queued), + request_payload: jsonb().$type | null>(), + response_payload: jsonb().$type | null>(), + response_status_code: integer(), + attempt_count: integer().notNull().default(0), + next_retry_at: timestamp({ withTimezone: true, mode: 'string' }), + delivered_at: timestamp({ withTimezone: true, mode: 'string' }), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp({ withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull() + .$onUpdateFn(() => sql`now()`), + }, + table => [ + unique('UQ_impact_conversion_reports_dedupe_key').on(table.dedupe_key), + index('IDX_impact_conversion_reports_conversion_id').on(table.conversion_id), + index('IDX_impact_conversion_reports_state').on(table.state), + enumCheck('impact_conversion_reports_state_check', table.state, ImpactConversionReportState), + check( + 'impact_conversion_reports_attempt_count_non_negative_check', + sql`${table.attempt_count} >= 0` + ), + ] +); + +export type ImpactConversionReport = typeof impact_conversion_reports.$inferSelect; + export const kilo_pass_subscriptions = pgTable( 'kilo_pass_subscriptions', { diff --git a/scripts/verify-drizzle-bootstrap.sh b/scripts/verify-drizzle-bootstrap.sh new file mode 100755 index 0000000000..f5034d2340 --- /dev/null +++ b/scripts/verify-drizzle-bootstrap.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +docker compose -f dev/docker-compose.yml up -d --wait postgres >/dev/null + +BASE_POSTGRES_URL="$({ + if [ -n "${POSTGRES_URL:-}" ]; then + printf '%s' "$POSTGRES_URL" + else + node <<'NODE' +const fs = require('fs'); + +for (const path of ['.env.local', '.env']) { + if (!fs.existsSync(path)) continue; + + for (const line of fs.readFileSync(path, 'utf8').split(/\r?\n/)) { + if (!line.startsWith('POSTGRES_URL=')) continue; + + let value = line.slice('POSTGRES_URL='.length).trim(); + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); + } + + process.stdout.write(value); + process.exit(0); + } +} + +console.error('POSTGRES_URL is not set in the environment or .env.local'); +process.exit(1); +NODE + fi +})" + +TEMP_DB="drizzle_bootstrap_$(date +%s)_${RANDOM}" +TEMP_POSTGRES_URL="$(node -e "const u = new URL(process.argv[1]); u.pathname = '/${TEMP_DB}'; process.stdout.write(u.toString());" "$BASE_POSTGRES_URL")" + +cleanup() { + docker compose -f dev/docker-compose.yml exec -T postgres \ + psql -U postgres -d postgres -v ON_ERROR_STOP=1 \ + -c "DROP DATABASE IF EXISTS \"${TEMP_DB}\" WITH (FORCE);" >/dev/null +} +trap cleanup EXIT + +docker compose -f dev/docker-compose.yml exec -T postgres \ + psql -U postgres -d postgres -v ON_ERROR_STOP=1 \ + -c "CREATE DATABASE \"${TEMP_DB}\";" >/dev/null + +POSTGRES_URL="$TEMP_POSTGRES_URL" pnpm drizzle migrate + +echo "Verified pnpm drizzle migrate against empty database: ${TEMP_DB}" diff --git a/services/kiloclaw-billing/src/lifecycle.test.ts b/services/kiloclaw-billing/src/lifecycle.test.ts index eac2ba0013..04f5283b8e 100644 --- a/services/kiloclaw-billing/src/lifecycle.test.ts +++ b/services/kiloclaw-billing/src/lifecycle.test.ts @@ -1140,11 +1140,19 @@ describe('credit renewal sweep affiliate tracking', () => { status: 200, headers: { 'content-type': 'application/json' }, }); - case 'enqueue_affiliate_event': - return new Response(JSON.stringify({ enqueued: true }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); + case 'process_paid_conversion': + return new Response( + JSON.stringify({ + affiliateSaleEnqueued: true, + winningTouchType: 'affiliate', + conversionId: null, + disqualificationReason: null, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + } + ); default: throw new Error(`Unexpected side effect action: ${body.action}`); } @@ -1195,14 +1203,12 @@ describe('credit renewal sweep affiliate tracking', () => { input: Record; } ) - .find(call => call.action === 'enqueue_affiliate_event'); + .find(call => call.action === 'process_paid_conversion'); expect(saleCall).toEqual({ - action: 'enqueue_affiliate_event', + action: 'process_paid_conversion', input: { userId: 'user-1', - provider: 'impact', - eventType: 'sale', dedupeKey: 'affiliate:impact:sale:kiloclaw-subscription:instance-1:2026-04', eventDateIso: renewalAt, orderId: 'kiloclaw-subscription:instance-1:2026-04', @@ -1264,11 +1270,19 @@ describe('credit renewal sweep affiliate tracking', () => { status: 200, headers: { 'content-type': 'application/json' }, }); - case 'enqueue_affiliate_event': - return new Response(JSON.stringify({ enqueued: true }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); + case 'process_paid_conversion': + return new Response( + JSON.stringify({ + affiliateSaleEnqueued: true, + winningTouchType: 'affiliate', + conversionId: null, + disqualificationReason: null, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + } + ); default: throw new Error(`Unexpected side effect action: ${body.action}`); } @@ -1308,11 +1322,9 @@ describe('credit renewal sweep affiliate tracking', () => { }, }, { - action: 'enqueue_affiliate_event', + action: 'process_paid_conversion', input: { userId: 'user-1', - provider: 'impact', - eventType: 'sale', dedupeKey: 'affiliate:impact:sale:kiloclaw-subscription:instance-1:2026-04', eventDateIso: renewalAt, orderId: 'kiloclaw-subscription:instance-1:2026-04', diff --git a/services/kiloclaw-billing/src/lifecycle.ts b/services/kiloclaw-billing/src/lifecycle.ts index f2e53f931a..05858cfa40 100644 --- a/services/kiloclaw-billing/src/lifecycle.ts +++ b/services/kiloclaw-billing/src/lifecycle.ts @@ -211,6 +211,20 @@ type SideEffectRequest = itemName?: string; }; } + | { + action: 'process_paid_conversion'; + input: { + userId: string; + dedupeKey: string; + eventDateIso: string; + orderId: string; + amount: number; + currencyCode: string; + itemCategory: string; + itemName: string; + itemSku?: string; + }; + } | { action: 'project_pending_kilo_pass_bonus'; input: { @@ -232,9 +246,16 @@ type SideEffectResponse = T['action'] extends 'send ? { repaired: boolean } : T['action'] extends 'enqueue_affiliate_event' ? { enqueued: boolean } - : T['action'] extends 'project_pending_kilo_pass_bonus' - ? { projectedBonusMicrodollars: number } - : { ok: true }; + : T['action'] extends 'process_paid_conversion' + ? { + affiliateSaleEnqueued: boolean; + winningTouchType: 'referral' | 'affiliate' | 'none'; + conversionId: string | null; + disqualificationReason: string | null; + } + : T['action'] extends 'project_pending_kilo_pass_bonus' + ? { projectedBonusMicrodollars: number } + : { ok: true }; export class KiloClawApiError extends Error { readonly statusCode: number; @@ -1004,6 +1025,32 @@ async function enqueueAffiliateEvent( ); } +async function processPaidConversion( + env: BillingWorkerEnv, + context: SweepExecutionContext, + params: { + userId: string; + dedupeKey: string; + eventDateIso: string; + orderId: string; + amount: number; + currencyCode: string; + itemCategory: string; + itemName: string; + itemSku?: string; + } +): Promise { + await callBillingSideEffect( + env, + context, + { + action: 'process_paid_conversion', + input: params, + }, + { userId: params.userId } + ); +} + async function autoResumeIfSuspended( env: BillingWorkerEnv, database: WorkerDb, @@ -1302,10 +1349,8 @@ async function processCreditRenewalRow( }); if (!deductionIsNew) { - await enqueueAffiliateEvent(env, context, { + await processPaidConversion(env, context, { userId, - provider: 'impact', - eventType: 'sale', dedupeKey: `affiliate:impact:sale:${deductionCategory}`, eventDateIso: renewalAt, orderId: deductionCategory, @@ -1357,10 +1402,8 @@ async function processCreditRenewalRow( }); } - await enqueueAffiliateEvent(env, context, { + await processPaidConversion(env, context, { userId, - provider: 'impact', - eventType: 'sale', dedupeKey: `affiliate:impact:sale:${deductionCategory}`, eventDateIso: renewalAt, orderId: deductionCategory, From 250d9e7e37e38c68091442a13e71c6e071c71d4d Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Fri, 24 Apr 2026 18:29:35 +0200 Subject: [PATCH 05/32] test(kiloclaw): seed affiliate touches in billing router tests --- .../routers/kiloclaw-billing-router.test.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/routers/kiloclaw-billing-router.test.ts b/apps/web/src/routers/kiloclaw-billing-router.test.ts index c3ad1562b6..4d80989b9c 100644 --- a/apps/web/src/routers/kiloclaw-billing-router.test.ts +++ b/apps/web/src/routers/kiloclaw-billing-router.test.ts @@ -308,12 +308,32 @@ async function insertPersonalSubscriptionFixture(params: PersonalSubscriptionFix async function seedDeliveredImpactSignupEvent(userId: string, email: string) { const { recordAffiliateAttributionAndQueueParentEvent } = await import('@/lib/affiliate-events'); + const { recordImpactAffiliateTouch } = await import('@/lib/impact-referral'); + const eventDate = new Date('2026-04-09T10:00:00.000Z'); + const parentEvent = await recordAffiliateAttributionAndQueueParentEvent({ userId, provider: 'impact', trackingId: 'impact-click-123', customerEmail: email, - eventDate: new Date('2026-04-09T10:00:00.000Z'), + eventDate, + }); + + await recordImpactAffiliateTouch({ + userId, + touch: { + trackingId: 'impact-click-123', + trackingValueLength: 'impact-click-123'.length, + isTrackingValueAccepted: true, + landingPath: '/pricing?im_ref=impact-click-123', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: eventDate, + expiresAt: new Date('2026-05-09T10:00:00.000Z'), + }, }); expect(parentEvent).not.toBeNull(); From 4e82fba5a6a737a59011f556910ba00d55e53de5 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Fri, 24 Apr 2026 18:53:52 +0200 Subject: [PATCH 06/32] fix(kiloclaw): address referral review feedback --- .../profile/ImpactAdvocateReferralCard.tsx | 4 + apps/web/src/lib/impact-referral.test.ts | 69 +++++++++++++++++ apps/web/src/lib/impact-referral.ts | 42 ++++++----- apps/web/src/lib/kiloclaw-referrals.test.ts | 74 +++++++++++++++++++ apps/web/src/lib/kiloclaw-referrals.ts | 26 +++---- 5 files changed, 183 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/profile/ImpactAdvocateReferralCard.tsx b/apps/web/src/components/profile/ImpactAdvocateReferralCard.tsx index 301b0e39fa..103d9ac3bb 100644 --- a/apps/web/src/components/profile/ImpactAdvocateReferralCard.tsx +++ b/apps/web/src/components/profile/ImpactAdvocateReferralCard.tsx @@ -15,6 +15,7 @@ export function ImpactAdvocateReferralCard() { useEffect(() => { let cancelled = false; + delete window.impactToken; const loadWidgetToken = async () => { try { @@ -37,6 +38,7 @@ export function ImpactAdvocateReferralCard() { } if (!response.ok || !payload?.token || !payload.widgetId) { + delete window.impactToken; setState({ status: 'unavailable', message: @@ -59,6 +61,7 @@ export function ImpactAdvocateReferralCard() { return; } + delete window.impactToken; setState({ status: 'unavailable', message: error instanceof Error ? error.message : 'Failed to load referral sharing.', @@ -70,6 +73,7 @@ export function ImpactAdvocateReferralCard() { return () => { cancelled = true; + delete window.impactToken; }; }, []); diff --git a/apps/web/src/lib/impact-referral.test.ts b/apps/web/src/lib/impact-referral.test.ts index c9c308c553..f04722da10 100644 --- a/apps/web/src/lib/impact-referral.test.ts +++ b/apps/web/src/lib/impact-referral.test.ts @@ -186,6 +186,75 @@ describe('impact referral participant registration dispatch', () => { expect(registeredParticipant.registration_state).toBe('registered'); }); + it('does not regress a registered participant when the same referral touch is queued again', async () => { + const fetchMock = jest + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ participantId: 'impact-participant-1' }), { status: 200 }) + ); + global.fetch = fetchMock; + + const user = await insertTestUser({ + google_user_email: 'already-registered@example.com', + normalized_email: 'already-registered@example.com', + }); + + const { + dispatchQueuedImpactAdvocateRegistrationAttempts, + queueImpactAdvocateParticipantRegistration, + } = await import('@/lib/impact-referral'); + + const referralTouch = { + opaqueTrackingValue: 'sq-cookie', + trackingValueLength: 9, + isTrackingValueAccepted: true, + rsCode: 'ref-code', + rsShareMedium: 'email', + rsEngagementMedium: 'link', + landingPath: '/get-started?_saasquatch=sq-cookie', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: new Date('2026-04-23T00:00:00.000Z'), + expiresAt: new Date('2026-05-23T00:00:00.000Z'), + } as const; + + await queueImpactAdvocateParticipantRegistration({ + user, + referralTouch, + locale: 'en-US', + countryCode: 'US', + }); + + const firstSummary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(firstSummary).toEqual({ + claimed: 1, + delivered: 1, + retried: 0, + failed: 0, + }); + + const [registeredParticipant] = await db.select().from(impact_advocate_participants); + expect(registeredParticipant.registration_state).toBe('registered'); + + await queueImpactAdvocateParticipantRegistration({ + user, + referralTouch, + locale: 'en-US', + countryCode: 'US', + }); + + const participants = await db.select().from(impact_advocate_participants); + expect(participants).toHaveLength(1); + expect(participants[0]?.registration_state).toBe('registered'); + + const attempts = await db.select().from(impact_advocate_registration_attempts); + expect(attempts).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('marks 4xx failures terminal, logs them, and does not retry unchanged payloads', async () => { const fetchMock = jest .fn() diff --git a/apps/web/src/lib/impact-referral.ts b/apps/web/src/lib/impact-referral.ts index a4a3757063..7caa5ce48b 100644 --- a/apps/web/src/lib/impact-referral.ts +++ b/apps/web/src/lib/impact-referral.ts @@ -242,6 +242,7 @@ export async function queueImpactAdvocateParticipantRegistration(params: { countryCode: params.countryCode, }); const nowIso = new Date().toISOString(); + const isConfigured = isImpactAdvocateConfigured(); const participant = await ensureImpactAdvocateParticipantProfile({ database, user: params.user, @@ -249,43 +250,46 @@ export async function queueImpactAdvocateParticipantRegistration(params: { countryCode: params.countryCode, }); - await database - .update(impact_advocate_participants) - .set({ - registration_state: isImpactAdvocateConfigured() - ? ImpactAdvocateRegistrationState.Pending - : ImpactAdvocateRegistrationState.Failed, - last_error_code: isImpactAdvocateConfigured() ? null : 'missing_configuration', - last_error_message: isImpactAdvocateConfigured() - ? null - : 'Impact Advocate configuration is incomplete', - last_registration_attempt_at: nowIso, - }) - .where(eq(impact_advocate_participants.id, participant.id)); - const attemptDedupeKey = buildHashedDedupeKey([ 'impact-advocate-registration', params.user.id, params.referralTouch.opaqueTrackingValue, ]); - await database + const [insertedAttempt] = await database .insert(impact_advocate_registration_attempts) .values({ participant_id: participant.id, dedupe_key: attemptDedupeKey, opaque_cookie_value: params.referralTouch.opaqueTrackingValue, cookie_value_length: params.referralTouch.trackingValueLength, - delivery_state: isImpactAdvocateConfigured() + delivery_state: isConfigured ? ImpactAdvocateAttemptDeliveryState.Queued : ImpactAdvocateAttemptDeliveryState.Failed, request_payload: payload satisfies Record, - response_payload: isImpactAdvocateConfigured() + response_payload: isConfigured ? null : ({ error: 'missing_configuration' } satisfies Record), - response_status_code: isImpactAdvocateConfigured() ? null : 503, + response_status_code: isConfigured ? null : 503, }) - .onConflictDoNothing({ target: [impact_advocate_registration_attempts.dedupe_key] }); + .onConflictDoNothing({ target: [impact_advocate_registration_attempts.dedupe_key] }) + .returning({ id: impact_advocate_registration_attempts.id }); + + if (!insertedAttempt) { + return; + } + + await database + .update(impact_advocate_participants) + .set({ + registration_state: isConfigured + ? ImpactAdvocateRegistrationState.Pending + : ImpactAdvocateRegistrationState.Failed, + last_error_code: isConfigured ? null : 'missing_configuration', + last_error_message: isConfigured ? null : 'Impact Advocate configuration is incomplete', + last_registration_attempt_at: nowIso, + }) + .where(eq(impact_advocate_participants.id, participant.id)); } export async function createDeletedUserEmailTombstone(params: { diff --git a/apps/web/src/lib/kiloclaw-referrals.test.ts b/apps/web/src/lib/kiloclaw-referrals.test.ts index 8130059d3d..d45b65abfe 100644 --- a/apps/web/src/lib/kiloclaw-referrals.test.ts +++ b/apps/web/src/lib/kiloclaw-referrals.test.ts @@ -1036,6 +1036,80 @@ describe('kiloclaw referrals', () => { expect(referrerReward.status).toBe('applied'); }); + it('leaves local reward state unchanged when Stripe reward application fails', async () => { + const referrer = await insertTestUser({ + google_user_email: 'stripe-failure-referrer@example.com', + normalized_email: 'stripe-failure-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'stripe-failure-referee@example.com', + normalized_email: 'stripe-failure-referee@example.com', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-stripe-failure:2026-04'; + + mockStripeSubscriptionUpdate.mockRejectedValueOnce(new Error('stripe exploded')); + + await insertActivePersonalSubscription(referee.id, { + stripe_subscription_id: 'sub_referee_failure_123', + }); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: '54545454-5454-4545-8545-545454545454', + dedupe_key: 'stripe-failure-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + const [subscription] = await db + .select() + .from(kiloclaw_subscriptions) + .where(eq(kiloclaw_subscriptions.user_id, referee.id)); + expect(subscription.current_period_end).toBe('2026-05-01 00:00:00+00'); + + const refereeRewards = await db + .select({ + status: kiloclaw_referral_rewards.status, + appliedAt: kiloclaw_referral_rewards.applied_at, + }) + .from(kiloclaw_referral_rewards) + .where(eq(kiloclaw_referral_rewards.beneficiary_user_id, referee.id)); + expect(refereeRewards).toEqual([ + { + status: 'earned', + appliedAt: null, + }, + ]); + + const applications = await db.select().from(kiloclaw_referral_reward_applications); + expect(applications).toHaveLength(0); + }); + it('keeps stripe-funded reward application in sync with Stripe trial-end billing delays', async () => { const referrer = await insertTestUser({ google_user_email: 'stripe-referrer@example.com', diff --git a/apps/web/src/lib/kiloclaw-referrals.ts b/apps/web/src/lib/kiloclaw-referrals.ts index 406cef3024..7be0359690 100644 --- a/apps/web/src/lib/kiloclaw-referrals.ts +++ b/apps/web/src/lib/kiloclaw-referrals.ts @@ -623,19 +623,6 @@ async function applyReferralRewardById( const localOperationId = `kiloclaw-referral-reward:${reward.id}:apply`; const stripeIdempotencyKey = `kiloclaw-referral-reward:${reward.id}:stripe-apply`; - if (subscription.stripe_subscription_id) { - await stripe.subscriptions.update( - subscription.stripe_subscription_id, - { - trial_end: Math.floor(new Date(newBoundary).getTime() / 1000), - proration_behavior: 'none', - }, - { - idempotencyKey: stripeIdempotencyKey, - } - ); - } - const [beforeSubscription] = await tx .select() .from(kiloclaw_subscriptions) @@ -712,6 +699,19 @@ async function applyReferralRewardById( }); } + if (subscription.stripe_subscription_id) { + await stripe.subscriptions.update( + subscription.stripe_subscription_id, + { + trial_end: Math.floor(new Date(newBoundary).getTime() / 1000), + proration_behavior: 'none', + }, + { + idempotencyKey: stripeIdempotencyKey, + } + ); + } + return 'applied'; }); } From 1fed0b6b482a713bf7c2809a97b81dd6582edcd1 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 10:41:25 +0200 Subject: [PATCH 07/32] chore: ignore .beads workspace artifacts The beads task tracker writes per-agent state under .beads/ that should not be checked in. Add it next to the existing agent-plan ignore. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 70b43ab195..7271efded3 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,9 @@ supabase/.temp # agent plans .opencode/plans/ +# beads task workspace (per-agent local state) +.beads/ + # misc TMP_CI_commit_msg.txt *.csv From 89c7336826728f853d94ba35d2a8170e0a06c9dc Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 10:41:33 +0200 Subject: [PATCH 08/32] docs(agents): require kilo-design skill for apps/web UI work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a top-level AGENTS.md section and a nested apps/web/AGENTS.md so agents working anywhere under apps/web read design.md and load the kilo-design skill before touching components, routes, layouts, styling, Storybook stories, copy, interaction states, responsive behaviour, theming, or accessibility — even when the prompt does not explicitly mention design. --- AGENTS.md | 4 ++++ apps/web/AGENTS.md | 11 +++++++++++ 2 files changed, 15 insertions(+) create mode 100644 apps/web/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 3f31170073..cd19621320 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,10 @@ Target a specific test file: `pnpm test -- `. Run tests for a specific ser **Before running tests**, ensure the test database is running. If there is no active Postgres instance, run `pnpm test:db` first — this starts the Postgres container and applies migrations. You can check whether Postgres is already running with `docker compose -f dev/docker-compose.yml ps postgres`. +## apps/web UI Work + +Before making or reviewing UI changes under `apps/web` — components, routes/pages, layouts, styling, Storybook, visual polish, UX copy, interaction states, responsive behavior, theming, or accessibility — read `design.md` and use `.agents/skills/kilo-design/SKILL.md`. This applies even when the prompt does not explicitly mention design. Skip only for backend-only or non-visual logic changes. + ## Coding Standards - Prefer `type` over `interface`. diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md new file mode 100644 index 0000000000..4efac6c5be --- /dev/null +++ b/apps/web/AGENTS.md @@ -0,0 +1,11 @@ +# AGENTS.md + +## UI Design Requirements + +When doing UI work in `apps/web` — including React components, routes/pages, layouts, styling, Storybook stories, visual polish, UX copy, interaction states, responsive behavior, theming, or accessibility — you must: + +1. Read `../../design.md` before changing or reviewing UI. +2. Load and follow the `kilo-design` skill at `../../.agents/skills/kilo-design/SKILL.md`. +3. Prefer existing Kilo tokens, components, and utilities before adding new visual primitives. + +This applies even when the prompt does not explicitly mention design. Skip only for backend-only or non-visual logic changes. From d9b321ee3fcbb847ce19b566ab51cee153580e90 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 10:41:40 +0200 Subject: [PATCH 09/32] docs(kiloclaw-referrals): tighten Impact Advocate identity contract Pin the Verified Access JWT contract: header MUST set kid to the Impact Account SID, payload MUST contain a top-level user object, and the JWT MUST be signed with the Impact Advocate Auth Token. Switch the Advocate identity contract from Kilo user ID to plain user email for both id and accountId so the identifier remains stable across our internal user-ID rotations. --- .specs/kiloclaw-referrals.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.specs/kiloclaw-referrals.md b/.specs/kiloclaw-referrals.md index b323e120d6..253ef0fc27 100644 --- a/.specs/kiloclaw-referrals.md +++ b/.specs/kiloclaw-referrals.md @@ -132,9 +132,11 @@ interventions, and non-KiloClaw purchases are out of scope. 7. Logged-in users MUST access referral sharing through the Impact Verified Access widget. -8. The system MUST authenticate users to Impact Advocate using the configured Verified Access contract. +8. The system MUST authenticate users to Impact Advocate using the configured Verified Access contract: the JWT header + MUST set `kid` to the Impact Account SID, the JWT payload MUST contain the top-level `user` object, and the JWT MUST + be signed with the Impact Advocate Auth Token. -9. The Impact Advocate identity contract for Kilo is: `id = Kilo user ID`, `accountId = Kilo user ID`, and +9. The Impact Advocate identity contract for Kilo is: `id = plain user email`, `accountId = plain user email`, and `email = plain user email`. 10. The system MUST NOT allow users to alter the identity payload used to establish Advocate identity. @@ -255,7 +257,7 @@ interventions, and non-KiloClaw purchases are out of scope. 47. Register Participant requests that fail with client errors MUST be logged and MUST NOT be retried until the request payload or configuration is corrected. -48. Register Participant requests MUST use the Kilo user ID for Advocate `id` and `accountId`. +48. Register Participant requests MUST use the user's plain email for Advocate `id` and `accountId`. 49. Register Participant requests MUST include plain-text email only as the Advocate contact email. From 8bd618fc192402b12a5e1045d62208906aa77bad Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 10:42:02 +0200 Subject: [PATCH 10/32] feat(impact): identity, advocate, and affiliate tracking pipeline Wire the end-to-end Impact integration that Kilo's referral program relies on: - impact.ts / impact-advocate.ts / impact-affiliate-utils.ts / impact-referral.ts: Verified Access token signing, Register Participant payload construction, normalized email tombstoning, affiliate touch parsing/persistence, and locale/country resolution. - impact-debug.ts: shared debug logger gated by IMPACT_ADVOCATE_DEBUG_LOGGING / IMPACT_REFERRAL_DEBUG_LOGGING so we never leak tokens, cookies, or full URLs in production logs. - affiliate-events.ts: dedupe-key based attribution + parent-event enqueue with structured logging. - ImpactIdentify.tsx: SHA-1-hash the customer email before calling window.ire('identify', ...), with debug logs around the retry path. - getSignInCallbackUrl + after-sign-in/route.tsx: preserve im_ref, rsCode, rsShareMedium, rsEngagementMedium, _saasquatch, and utm_* through the sign-in callback so we can attribute on first auth. - user.server.ts / user.ts: extract Impact tracking context from the callback URL cookie (with affiliate-touch suppression when an Advocate referral cookie is present) and forward it into user upsert; on signup, persist affiliate touches, referral touches, and queue an Advocate Register Participant call inside the transaction. PII handling stays GDPR-friendly: email is the Advocate identifier per the spec, and createDeletedUserEmailTombstone hashes it on soft-delete. No tokens, cookies, or auth headers are logged. --- .../web/src/app/users/after-sign-in/route.tsx | 94 +++++++++++++- apps/web/src/components/ImpactIdentify.tsx | 22 +++- apps/web/src/lib/affiliate-events.ts | 62 ++++++++++ apps/web/src/lib/config.server.ts | 2 + apps/web/src/lib/getSignInCallbackUrl.test.ts | 15 +++ apps/web/src/lib/getSignInCallbackUrl.ts | 9 ++ apps/web/src/lib/impact-advocate.test.ts | 64 ++++++++-- apps/web/src/lib/impact-advocate.ts | 116 ++++++++++++++---- .../src/lib/impact-affiliate-utils.test.ts | 42 +++++++ apps/web/src/lib/impact-affiliate-utils.ts | 13 +- apps/web/src/lib/impact-debug.ts | 10 ++ apps/web/src/lib/impact-referral.test.ts | 4 +- apps/web/src/lib/impact-referral.ts | 114 ++++++++++++++--- apps/web/src/lib/impact.ts | 46 ++++++- apps/web/src/lib/user.server.ts | 47 ++++++- apps/web/src/lib/user.ts | 26 ++++ 16 files changed, 624 insertions(+), 62 deletions(-) create mode 100644 apps/web/src/lib/impact-debug.ts diff --git a/apps/web/src/app/users/after-sign-in/route.tsx b/apps/web/src/app/users/after-sign-in/route.tsx index 2147e19fbb..65edb585e6 100644 --- a/apps/web/src/app/users/after-sign-in/route.tsx +++ b/apps/web/src/app/users/after-sign-in/route.tsx @@ -4,6 +4,7 @@ import { maybeInterceptWithSurvey } from '@/lib/survey-redirect'; import PostHogClient from '@/lib/posthog'; import { getAffiliateAttribution } from '@/lib/affiliate-attribution'; import { recordAffiliateAttributionAndQueueParentEvent } from '@/lib/affiliate-events'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; import { IMPACT_APP_TRACKED_CLICK_ID_COOKIE, IMPACT_CLICK_ID_COOKIE, @@ -31,6 +32,42 @@ import { isCreditCampaignCallback, lookupCampaignBySlug } from '@/lib/credit-cam * the entry point is generic (e.g. /get-started, /profile) so we leave the * property unset rather than guessing. */ +const TRACKING_REDIRECT_PARAMS = [ + 'source', + 'im_ref', + '_saasquatch', + 'rsCode', + 'rsShareMedium', + 'rsEngagementMedium', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', +] as const; + +function signInPathWithPreservedTrackingParams(url: URL): string { + const params = new URLSearchParams(); + const callbackPath = url.searchParams.get('callbackPath'); + + if (callbackPath && isValidCallbackPath(callbackPath)) { + params.set('callbackPath', callbackPath); + } + + if (url.searchParams.get('signup') === 'true') { + params.set('signup', 'true'); + } + + for (const param of TRACKING_REDIRECT_PARAMS) { + const value = url.searchParams.get(param)?.trim(); + if (value) { + params.set(param, value); + } + } + + return `/users/sign_in${params.size > 0 ? `?${params.toString()}` : ''}`; +} + async function resolveSignupProduct( callbackPath: string | null, hasSource: boolean @@ -71,7 +108,7 @@ export async function GET(request: NextRequest) { let responsePath: string; if (!user) { - responsePath = '/users/sign_in'; + responsePath = signInPathWithPreservedTrackingParams(url); } else if (user.blocked_reason) { responsePath = '/account-blocked'; } else { @@ -119,21 +156,47 @@ export async function GET(request: NextRequest) { } } + const referralTouch = parseImpactReferralTouchFromUrl(url); + const urlImRefParam = url.searchParams.get('im_ref')?.trim() || null; + const ignoreUrlImRefForReferralTouch = Boolean( + referralTouch?.opaqueTrackingValue && urlImRefParam + ); + // Resolve the Impact click ID: prefer the explicit URL param, fall back to // the shared parent-domain cookie written by kilo.ai. This is intentionally // separate from Impact's native IR_ UTT cookie. const { affiliateTrackingId, impactCookieValue } = resolveImpactAffiliateTrackingId({ - imRefParam: url.searchParams.get('im_ref')?.trim() || null, + imRefParam: urlImRefParam, sharedImpactCookieValue: request.cookies.get(IMPACT_CLICK_ID_COOKIE)?.value?.trim() || null, appTrackedImpactCookieValue: request.cookies.get(IMPACT_APP_TRACKED_CLICK_ID_COOKIE)?.value?.trim() || null, + ignoreImRefParam: ignoreUrlImRefForReferralTouch, }); - const affiliateTouch = parseImpactAffiliateTouchFromUrl(url, affiliateTrackingId); - const referralTouch = parseImpactReferralTouchFromUrl(url); + const affiliateTouch = affiliateTrackingId + ? parseImpactAffiliateTouchFromUrl(url, affiliateTrackingId) + : null; + + logImpactReferralDebug('After sign-in resolved Impact tracking context', { + userId: user?.id ?? null, + responsePath, + affiliateTrackingIdPresent: Boolean(affiliateTrackingId?.trim()), + impactCookieValuePresent: Boolean(impactCookieValue?.trim()), + affiliateTouchPresent: Boolean(affiliateTouch), + referralTouchPresent: Boolean(referralTouch), + referralCookieValuePresent: Boolean(referralTouch?.opaqueTrackingValue), + ignoredUrlImRefForReferralTouch: ignoreUrlImRefForReferralTouch, + callbackPath: url.searchParams.get('callbackPath') ?? null, + }); if (user && affiliateTouch) { try { + logImpactReferralDebug('After sign-in recording Impact affiliate touch', { + userId: user.id, + landingPath: affiliateTouch.landingPath, + trackingValueLength: affiliateTouch.trackingValueLength, + isTrackingValueAccepted: affiliateTouch.isTrackingValueAccepted, + }); await recordImpactAffiliateTouch({ userId: user.id, touch: affiliateTouch, @@ -148,6 +211,13 @@ export async function GET(request: NextRequest) { if (user && referralTouch) { try { + logImpactReferralDebug('After sign-in recording Impact Advocate referral touch', { + userId: user.id, + landingPath: referralTouch.landingPath, + rsCodePresent: Boolean(referralTouch.rsCode?.trim()), + trackingValueLength: referralTouch.trackingValueLength, + isTrackingValueAccepted: referralTouch.isTrackingValueAccepted, + }); await recordImpactReferralTouch({ userId: user.id, touch: referralTouch, @@ -160,6 +230,12 @@ export async function GET(request: NextRequest) { } try { + logImpactReferralDebug('After sign-in queueing Impact Advocate participant registration', { + userId: user.id, + landingPath: referralTouch.landingPath, + localePresent: Boolean(localeFromHeaders(request.headers)?.trim()), + countryCode: countryCodeFromHeaders(request.headers), + }); await queueImpactAdvocateParticipantRegistration({ user, referralTouch, @@ -177,6 +253,12 @@ export async function GET(request: NextRequest) { if (user && affiliateTrackingId) { const existingAttribution = await getAffiliateAttribution(user.id, 'impact'); + logImpactReferralDebug('After sign-in checked Impact affiliate attribution row', { + userId: user.id, + existingAttributionPresent: Boolean(existingAttribution), + trackingIdLength: affiliateTrackingId.length, + }); + if (!existingAttribution) { try { await recordAffiliateAttributionAndQueueParentEvent({ @@ -203,6 +285,10 @@ export async function GET(request: NextRequest) { // hit would burn the marker and suppress the fallback on the next real // sign-in. if (user && impactCookieValue) { + logImpactReferralDebug('After sign-in setting app-tracked Impact click cookie marker', { + userId: user.id, + impactCookieValueLength: impactCookieValue.length, + }); response.cookies.set(IMPACT_APP_TRACKED_CLICK_ID_COOKIE, impactCookieValue, { path: '/', httpOnly: true, diff --git a/apps/web/src/components/ImpactIdentify.tsx b/apps/web/src/components/ImpactIdentify.tsx index 2023df550c..2ac0467b21 100644 --- a/apps/web/src/components/ImpactIdentify.tsx +++ b/apps/web/src/components/ImpactIdentify.tsx @@ -35,7 +35,18 @@ export function ImpactIdentify() { if (cancelled) return; if (typeof window.ire !== 'function') { - if (retriesRemaining <= 0) return; + if (retriesRemaining <= 0) { + if (process.env.NODE_ENV === 'development') { + console.log( + '[impact-referral-debug]', + 'Impact UTT identify skipped; window.ire unavailable', + { + userId: user?.id ?? null, + } + ); + } + return; + } retryTimeout = setTimeout(() => { void runIdentify(retriesRemaining - 1); @@ -49,6 +60,15 @@ export function ImpactIdentify() { if (cancelled || typeof window.ire !== 'function') return; + if (process.env.NODE_ENV === 'development') { + console.log('[impact-referral-debug]', 'Calling Impact UTT identify', { + userId: user?.id ?? null, + customerIdPresent: Boolean(customerId), + customerEmailHashPresent: Boolean(customerEmail), + customProfileIdPresent: Boolean(customProfileId), + }); + } + window.ire('identify', { customerId, customerEmail, diff --git a/apps/web/src/lib/affiliate-events.ts b/apps/web/src/lib/affiliate-events.ts index 6fdd810f41..3a7b439bab 100644 --- a/apps/web/src/lib/affiliate-events.ts +++ b/apps/web/src/lib/affiliate-events.ts @@ -16,6 +16,7 @@ import { reverseImpactAction, sendImpactConversionPayload, } from '@/lib/impact'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; import { sentryLogger } from '@/lib/utils.server'; import { kilocode_users, @@ -792,6 +793,10 @@ export async function findOrCreateParentEvent( logInfo(inserted ? 'Enqueued affiliate parent event' : 'Affiliate parent event already exists', { ...buildAffiliateEventLogFields(event), }); + logImpactReferralDebug( + inserted ? 'Enqueued affiliate parent event' : 'Affiliate parent event already exists', + buildAffiliateEventLogFields(event) + ); return event; } @@ -801,11 +806,22 @@ export async function recordAffiliateAttributionAndQueueParentEvent( const database = getDatabaseClient(params.database); const trackingId = params.trackingId.trim(); + logImpactReferralDebug('Recording affiliate attribution and queueing parent event', { + userId: params.userId, + affiliateProvider: params.provider, + trackingIdPresent: Boolean(trackingId), + trackingIdLength: trackingId.length, + }); + if (!trackingId) { logWarning('Skipped affiliate attribution enqueue because tracking ID was empty', { user_id: params.userId, affiliate_provider: params.provider, }); + logImpactReferralDebug('Skipped affiliate attribution enqueue because tracking ID was empty', { + userId: params.userId, + affiliateProvider: params.provider, + }); return null; } @@ -847,6 +863,12 @@ export async function enqueueAffiliateEventForUser( affiliate_event_type: params.eventType, affiliate_dedupe_key: params.dedupeKey, }); + logImpactReferralDebug('Skipped affiliate child enqueue because user was missing', { + userId: params.userId, + affiliateProvider: params.provider, + affiliateEventType: params.eventType, + affiliateDedupeKey: params.dedupeKey, + }); return null; } @@ -865,6 +887,12 @@ export async function enqueueAffiliateEventForUser( .limit(1); if (!attribution) { + logImpactReferralDebug('Skipped affiliate child enqueue because attribution row was missing', { + userId: params.userId, + affiliateProvider: params.provider, + affiliateEventType: params.eventType, + affiliateDedupeKey: params.dedupeKey, + }); return null; } @@ -922,6 +950,10 @@ export async function enqueueAffiliateEventForUser( logInfo(inserted ? 'Enqueued affiliate child event' : 'Affiliate child event already exists', { ...buildAffiliateEventLogFields(event), }); + logImpactReferralDebug( + inserted ? 'Enqueued affiliate child event' : 'Affiliate child event already exists', + buildAffiliateEventLogFields(event) + ); return event; } @@ -1266,6 +1298,10 @@ export async function dispatchQueuedAffiliateEvents(params?: { }): Promise { const database = getDatabaseClient(params?.database); const limit = params?.limit ?? DEFAULT_CLAIM_LIMIT; + logImpactReferralDebug('Processing affiliate event dispatch queue', { + limit, + impactConfigured: isImpactConfigured(), + }); const summary: AffiliateEventDispatchSummary = { reclaimed: 0, claimed: 0, @@ -1335,6 +1371,10 @@ export async function dispatchQueuedAffiliateEvents(params?: { ...buildAffiliateEventLogFields(event), dispatch_source: 'cron', }); + logImpactReferralDebug('Claimed affiliate event for dispatch', { + ...buildAffiliateEventLogFields(event), + dispatch_source: 'cron', + }); if (event.event_type === 'sale_reversal') { const reversalOutcome = await dispatchSaleReversalEvent(database, event); @@ -1373,6 +1413,16 @@ export async function dispatchQueuedAffiliateEvents(params?: { dispatch_source: 'cron', } ); + logImpactReferralDebug( + result.skipped === 'unconfigured' + ? 'Skipped affiliate event delivery because Impact is unconfigured' + : 'Delivered affiliate event', + { + ...buildAffiliateEventLogFields(deliveredEvent), + dispatch_source: 'cron', + delivery: result.skipped ?? result.delivery ?? null, + } + ); if ( event.event_type === getParentEventType(event.provider) || @@ -1392,6 +1442,12 @@ export async function dispatchQueuedAffiliateEvents(params?: { } if (result.failureKind === 'http_4xx' || result.failureKind === 'submission_failed') { + logImpactReferralDebug('Affiliate event delivery failed permanently', { + ...buildAffiliateEventLogFields(event), + dispatch_source: 'cron', + failureKind: result.failureKind, + statusCode: result.statusCode ?? null, + }); await handlePermanentFailure(database, event, result.failureKind, { statusCode: result.statusCode, error: result.error ?? result.responseBody, @@ -1400,6 +1456,12 @@ export async function dispatchQueuedAffiliateEvents(params?: { continue; } + logImpactReferralDebug('Affiliate event delivery scheduled for retry', { + ...buildAffiliateEventLogFields(event), + dispatch_source: 'cron', + failureKind: result.failureKind, + statusCode: result.statusCode ?? null, + }); await handleRetryableFailure(database, event, result.failureKind, result.statusCode); summary.retried += 1; } diff --git a/apps/web/src/lib/config.server.ts b/apps/web/src/lib/config.server.ts index 8970db3c70..83c58a9742 100644 --- a/apps/web/src/lib/config.server.ts +++ b/apps/web/src/lib/config.server.ts @@ -48,6 +48,8 @@ export const IMPACT_ADVOCATE_PROGRAM_ID = getEnvVariable('IMPACT_ADVOCATE_PROGRA export const IMPACT_ADVOCATE_ACCOUNT_SID = getEnvVariable('IMPACT_ADVOCATE_ACCOUNT_SID') || ''; export const IMPACT_ADVOCATE_AUTH_TOKEN = getEnvVariable('IMPACT_ADVOCATE_AUTH_TOKEN') || ''; export const IMPACT_ADVOCATE_WIDGET_ID = getEnvVariable('IMPACT_ADVOCATE_WIDGET_ID') || ''; +export const IMPACT_ADVOCATE_DEBUG_LOGGING = + getEnvVariable('IMPACT_ADVOCATE_DEBUG_LOGGING') === 'true'; if (!NEXTAUTH_SECRET) throw new Error('NEXTAUTH_SECRET is required JWT signing'); if (!TURNSTILE_SECRET_KEY) throw new Error('TURNSTILE_SECRET_KEY is required'); diff --git a/apps/web/src/lib/getSignInCallbackUrl.test.ts b/apps/web/src/lib/getSignInCallbackUrl.test.ts index 431f7f7855..c732ec75b0 100644 --- a/apps/web/src/lib/getSignInCallbackUrl.test.ts +++ b/apps/web/src/lib/getSignInCallbackUrl.test.ts @@ -230,6 +230,21 @@ describe('getSignInCallbackUrl', () => { '/users/after-sign-in?_saasquatch=opaque-referral-cookie&rsCode=ref-code&rsShareMedium=email&rsEngagementMedium=link' ); }); + + test('preserves KiloClaw callback paths and referral UTM metadata', () => { + const result = getSignInCallbackUrl({ + callbackPath: '/claw/new', + _saasquatch: 'opaque-referral-cookie', + rsCode: 'ref-code', + utm_source: 'invite', + utm_medium: 'link', + utm_campaign: 'saasquatch', + }); + + expect(result).toBe( + '/users/after-sign-in?_saasquatch=opaque-referral-cookie&rsCode=ref-code&utm_source=invite&utm_medium=link&utm_campaign=saasquatch&callbackPath=%2Fclaw%2Fnew' + ); + }); }); describe('stripHost', () => { diff --git a/apps/web/src/lib/getSignInCallbackUrl.ts b/apps/web/src/lib/getSignInCallbackUrl.ts index 63c1d6812f..52934512d3 100644 --- a/apps/web/src/lib/getSignInCallbackUrl.ts +++ b/apps/web/src/lib/getSignInCallbackUrl.ts @@ -16,6 +16,8 @@ export function isValidCallbackPath(path: string): boolean { path.startsWith('/get-started') || path.startsWith('/welcome/landing') || path.startsWith('/organizations/') || + path === '/claw' || + path.startsWith('/claw/') || path.startsWith('/cloud') || path.startsWith('/integrations/') || // Admin-managed URL bonus campaigns. Stricter shape enforcement @@ -56,6 +58,13 @@ export default function getSignInCallbackUrl(searchParams?: NextAppSearchParams) callbackParams.set('rsEngagementMedium', searchParams.rsEngagementMedium); } + for (const utmParam of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) { + const value = searchParams?.[utmParam]; + if (typeof value === 'string' && value) { + callbackParams.set(utmParam, value); + } + } + // Always route through /users/after-sign-in to ensure stytch verification check if ( typeof searchParams?.callbackPath === 'string' && diff --git a/apps/web/src/lib/impact-advocate.test.ts b/apps/web/src/lib/impact-advocate.test.ts index 4b01591c73..0605837dba 100644 --- a/apps/web/src/lib/impact-advocate.test.ts +++ b/apps/web/src/lib/impact-advocate.test.ts @@ -5,6 +5,7 @@ describe('impact advocate', () => { const originalEnv = { IMPACT_ADVOCATE_ACCOUNT_SID: process.env.IMPACT_ADVOCATE_ACCOUNT_SID, IMPACT_ADVOCATE_AUTH_TOKEN: process.env.IMPACT_ADVOCATE_AUTH_TOKEN, + IMPACT_ADVOCATE_DEBUG_LOGGING: process.env.IMPACT_ADVOCATE_DEBUG_LOGGING, IMPACT_ADVOCATE_PROGRAM_ID: process.env.IMPACT_ADVOCATE_PROGRAM_ID, IMPACT_ADVOCATE_TENANT_ALIAS: process.env.IMPACT_ADVOCATE_TENANT_ALIAS, IMPACT_ADVOCATE_WIDGET_ID: process.env.IMPACT_ADVOCATE_WIDGET_ID, @@ -12,8 +13,10 @@ describe('impact advocate', () => { }; afterEach(() => { + jest.restoreAllMocks(); process.env.IMPACT_ADVOCATE_ACCOUNT_SID = originalEnv.IMPACT_ADVOCATE_ACCOUNT_SID; process.env.IMPACT_ADVOCATE_AUTH_TOKEN = originalEnv.IMPACT_ADVOCATE_AUTH_TOKEN; + process.env.IMPACT_ADVOCATE_DEBUG_LOGGING = originalEnv.IMPACT_ADVOCATE_DEBUG_LOGGING; process.env.IMPACT_ADVOCATE_PROGRAM_ID = originalEnv.IMPACT_ADVOCATE_PROGRAM_ID; process.env.IMPACT_ADVOCATE_TENANT_ALIAS = originalEnv.IMPACT_ADVOCATE_TENANT_ALIAS; process.env.IMPACT_ADVOCATE_WIDGET_ID = originalEnv.IMPACT_ADVOCATE_WIDGET_ID; @@ -37,8 +40,8 @@ describe('impact advocate', () => { countryCode: 'US', }) ).toEqual({ - id: 'user_123', - accountId: 'user_123', + id: 'referee@example.com', + accountId: 'referee@example.com', programId: '51699', email: 'referee@example.com', cookies: 'opaque-cookie-value', @@ -47,6 +50,52 @@ describe('impact advocate', () => { }); }); + it('normalizes bare widget IDs to the full Impact embed widget path', async () => { + process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; + process.env.IMPACT_ADVOCATE_WIDGET_ID = '51699'; + + const { getImpactAdvocateWidgetId } = await import('@/lib/impact-advocate'); + + expect(getImpactAdvocateWidgetId()).toBe('p/51699/w/referrerWidget'); + }); + + it('logs debug data without tokens, credentials, authorization headers, or cookie values', async () => { + process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; + process.env.IMPACT_ADVOCATE_DEBUG_LOGGING = 'true'; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + const { + buildImpactAdvocateRegisterParticipantPayload, + issueImpactAdvocateVerifiedAccessToken, + } = await import('@/lib/impact-advocate'); + + buildImpactAdvocateRegisterParticipantPayload({ + user: { id: 'user_123', google_user_email: 'referee@example.com' }, + referralCookieValue: 'opaque-cookie-value', + }); + issueImpactAdvocateVerifiedAccessToken( + { id: 'user_456', google_user_email: 'referrer@example.com' }, + new Date('2026-04-23T12:00:00.000Z') + ); + + const loggedData = JSON.stringify(warnSpy.mock.calls); + expect(loggedData).toContain('[impact-advocate] built register participant payload'); + expect(loggedData).toContain('[impact-advocate] issued verified access token'); + expect(loggedData).toContain('referee@example.com'); + expect(loggedData).toContain('referrer@example.com'); + expect(loggedData).toContain('impact-account-sid'); + expect(loggedData).toContain('segmentLengths'); + expect(loggedData).toContain('[omitted: cookie value is sensitive]'); + expect(loggedData).not.toContain('opaque-cookie-value'); + expect(loggedData).not.toContain('secret'); + }); + it('issues verified access JWTs with the account sid in the kid header', async () => { process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; @@ -71,15 +120,14 @@ describe('impact advocate', () => { } expect(decoded.header.kid).toBe('impact-account-sid'); - expect(decoded.payload).toMatchObject({ - iss: 'tenant-alias', - aud: 'impact-advocate', - sub: 'user_123', + expect(decoded.payload).toEqual({ user: { - id: 'user_123', - accountId: 'user_123', + id: 'referrer@example.com', + accountId: 'referrer@example.com', email: 'referrer@example.com', + referable: false, }, + exp: Math.floor(new Date('2026-04-23T12:00:00.000Z').getTime() / 1000) + 60 * 60, }); }); }); diff --git a/apps/web/src/lib/impact-advocate.ts b/apps/web/src/lib/impact-advocate.ts index 63c4ce516e..081b4a8fcf 100644 --- a/apps/web/src/lib/impact-advocate.ts +++ b/apps/web/src/lib/impact-advocate.ts @@ -14,11 +14,14 @@ import { export const IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID = '51699'; export const IMPACT_ADVOCATE_DEFAULT_WIDGET_ID = 'p/51699/w/referrerWidget'; +const IMPACT_ADVOCATE_WIDGET_NAME = 'referrerWidget'; +const IMPACT_ADVOCATE_VERIFIED_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; export type ImpactAdvocateIdentityPayload = { id: string; accountId: string; email: string; + referable: boolean; }; export type ImpactAdvocateRegisterParticipantPayload = { @@ -31,6 +34,16 @@ export type ImpactAdvocateRegisterParticipantPayload = { countryCode?: string; }; +type ImpactAdvocateVerifiedAccessTokenPayload = { + user: ImpactAdvocateIdentityPayload; + exp: number; +}; + +type ImpactAdvocateJwtHeaderInput = { + alg: 'HS256'; + kid: string; +}; + export type ImpactAdvocateDispatchResult = | { ok: true; @@ -45,12 +58,38 @@ export type ImpactAdvocateDispatchResult = error?: string; }; +function getDebuggableRegisterParticipantPayload( + payload: ImpactAdvocateRegisterParticipantPayload +) { + return { + ...payload, + cookies: '[omitted: cookie value is sensitive]', + }; +} + +function isImpactAdvocateDebugLoggingEnabled(): boolean { + const value = process.env.IMPACT_ADVOCATE_DEBUG_LOGGING?.trim().toLowerCase(); + return value === 'true' || value === '1' || value === 'yes'; +} + +function logImpactAdvocateDebug(message: string, details: Record): void { + if (!isImpactAdvocateDebugLoggingEnabled()) return; + console.warn(`${message} ${JSON.stringify(details)}`); +} + +function getImpactAdvocateWidgetPath(widgetId: string, programId: string): string { + const trimmedWidgetId = widgetId.trim(); + if (!trimmedWidgetId) return `p/${programId}/w/${IMPACT_ADVOCATE_WIDGET_NAME}`; + if (trimmedWidgetId.includes('/')) return trimmedWidgetId; + return `p/${trimmedWidgetId}/w/${IMPACT_ADVOCATE_WIDGET_NAME}`; +} + function getImpactAdvocateConfig() { const accountSid = IMPACT_ADVOCATE_ACCOUNT_SID || IMPACT_ACCOUNT_SID; const authToken = IMPACT_ADVOCATE_AUTH_TOKEN; const tenantAlias = IMPACT_ADVOCATE_TENANT_ALIAS; const programId = IMPACT_ADVOCATE_PROGRAM_ID || IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID; - const widgetId = IMPACT_ADVOCATE_WIDGET_ID || IMPACT_ADVOCATE_DEFAULT_WIDGET_ID; + const widgetId = getImpactAdvocateWidgetPath(IMPACT_ADVOCATE_WIDGET_ID, programId); if (!accountSid || !authToken || !tenantAlias) { return null; @@ -74,12 +113,13 @@ export function getImpactAdvocateWidgetId(): string { } export function buildImpactAdvocateIdentityPayload( - user: Pick + user: Pick ): ImpactAdvocateIdentityPayload { return { - id: user.id, - accountId: user.id, + id: user.google_user_email, + accountId: user.google_user_email, email: user.google_user_email, + referable: false, }; } @@ -90,16 +130,21 @@ export function buildImpactAdvocateRegisterParticipantPayload(params: { countryCode?: string | null; }): ImpactAdvocateRegisterParticipantPayload { const config = getImpactAdvocateConfig(); - - return { - id: params.user.id, - accountId: params.user.id, + const payload: ImpactAdvocateRegisterParticipantPayload = { + id: params.user.google_user_email, + accountId: params.user.google_user_email, programId: config?.programId ?? IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID, email: params.user.google_user_email, cookies: params.referralCookieValue, ...(params.locale ? { locale: params.locale } : {}), ...(params.countryCode ? { countryCode: params.countryCode } : {}), }; + + logImpactAdvocateDebug('[impact-advocate] built register participant payload', { + payload: getDebuggableRegisterParticipantPayload(payload), + }); + + return payload; } function getImpactAdvocateAuthorizationHeader( @@ -127,7 +172,19 @@ export async function sendImpactAdvocateRegisterParticipantPayload( } try { - const response = await fetch(getImpactAdvocateRegisterParticipantUrl(config), { + const url = getImpactAdvocateRegisterParticipantUrl(config); + logImpactAdvocateDebug('[impact-advocate] sending register participant request', { + url, + method: 'POST', + headers: { + Authorization: 'not_logged', + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + payload: getDebuggableRegisterParticipantPayload(payload), + }); + + const response = await fetch(url, { method: 'POST', headers: { Authorization: getImpactAdvocateAuthorizationHeader(config), @@ -168,24 +225,35 @@ export function issueImpactAdvocateVerifiedAccessToken( const config = getImpactAdvocateConfig(); if (!config) return null; + const header: ImpactAdvocateJwtHeaderInput = { + alg: 'HS256', + kid: config.accountSid, + }; const options: SignOptions = { algorithm: 'HS256', - expiresIn: '5m', - header: { - alg: 'HS256', - kid: config.accountSid, - }, - subject: user.id, + header, + noTimestamp: true, }; + const payload: ImpactAdvocateVerifiedAccessTokenPayload = { + user: buildImpactAdvocateIdentityPayload(user), + exp: Math.floor(now.getTime() / 1000) + IMPACT_ADVOCATE_VERIFIED_ACCESS_TOKEN_TTL_SECONDS, + }; + const token = jwt.sign(payload, config.authToken, options); - return jwt.sign( - { - iss: config.tenantAlias, - aud: 'impact-advocate', - iat: Math.floor(now.getTime() / 1000), - user: buildImpactAdvocateIdentityPayload(user), + logImpactAdvocateDebug('[impact-advocate] issued verified access token', { + jwtHeader: header, + jwtPayload: payload, + signOptions: { + algorithm: options.algorithm, + noTimestamp: options.noTimestamp, + expiresIn: options.expiresIn ?? null, + subject: options.subject ?? null, + }, + token: { + omitted: 'not_logged', + segmentLengths: token.split('.').map(segment => segment.length), }, - config.authToken, - options - ); + }); + + return token; } diff --git a/apps/web/src/lib/impact-affiliate-utils.test.ts b/apps/web/src/lib/impact-affiliate-utils.test.ts index 4d9bae14ad..00bf383cfb 100644 --- a/apps/web/src/lib/impact-affiliate-utils.test.ts +++ b/apps/web/src/lib/impact-affiliate-utils.test.ts @@ -30,6 +30,48 @@ describe('impact affiliate utils', () => { }); }); + it('can ignore URL im_ref when it belongs to the current referral touch', () => { + expect( + resolveImpactAffiliateTrackingId({ + imRefParam: 'impact-click-from-referral-url', + sharedImpactCookieValue: null, + appTrackedImpactCookieValue: null, + ignoreImRefParam: true, + }) + ).toEqual({ + affiliateTrackingId: null, + impactCookieValue: null, + }); + }); + + it('falls back to a prior shared cookie when ignoring the current URL im_ref', () => { + expect( + resolveImpactAffiliateTrackingId({ + imRefParam: 'impact-click-from-referral-url', + sharedImpactCookieValue: 'impact-click-from-cookie', + appTrackedImpactCookieValue: null, + ignoreImRefParam: true, + }) + ).toEqual({ + affiliateTrackingId: 'impact-click-from-cookie', + impactCookieValue: 'impact-click-from-cookie', + }); + }); + + it('does not recover the ignored URL im_ref from the shared cookie', () => { + expect( + resolveImpactAffiliateTrackingId({ + imRefParam: 'impact-click-from-referral-url', + sharedImpactCookieValue: 'impact-click-from-referral-url', + appTrackedImpactCookieValue: null, + ignoreImRefParam: true, + }) + ).toEqual({ + affiliateTrackingId: null, + impactCookieValue: null, + }); + }); + it('suppresses the shared cookie when the app already tracked that exact value', () => { expect( resolveImpactAffiliateTrackingId({ diff --git a/apps/web/src/lib/impact-affiliate-utils.ts b/apps/web/src/lib/impact-affiliate-utils.ts index a7aef8fa7e..12cb8df0d0 100644 --- a/apps/web/src/lib/impact-affiliate-utils.ts +++ b/apps/web/src/lib/impact-affiliate-utils.ts @@ -13,16 +13,23 @@ export function resolveImpactAffiliateTrackingId(params: { imRefParam: string | null; sharedImpactCookieValue: string | null; appTrackedImpactCookieValue: string | null; + ignoreImRefParam?: boolean; }) { - const impactCookieValue = params.imRefParam + const ignoredImRefParam = params.ignoreImRefParam ? params.imRefParam : null; + const imRefParam = params.ignoreImRefParam ? null : params.imRefParam; + const sharedCookieMatchesIgnoredImRef = Boolean( + ignoredImRefParam && params.sharedImpactCookieValue === ignoredImRefParam + ); + const impactCookieValue = imRefParam ? null : params.sharedImpactCookieValue && - params.sharedImpactCookieValue !== params.appTrackedImpactCookieValue + params.sharedImpactCookieValue !== params.appTrackedImpactCookieValue && + !sharedCookieMatchesIgnoredImRef ? params.sharedImpactCookieValue : null; return { - affiliateTrackingId: params.imRefParam || impactCookieValue, + affiliateTrackingId: imRefParam || impactCookieValue, impactCookieValue, }; } diff --git a/apps/web/src/lib/impact-debug.ts b/apps/web/src/lib/impact-debug.ts new file mode 100644 index 0000000000..3b8582718c --- /dev/null +++ b/apps/web/src/lib/impact-debug.ts @@ -0,0 +1,10 @@ +export function logImpactReferralDebug(message: string, fields?: Record): void { + if (process.env.NODE_ENV !== 'development' && process.env.IMPACT_REFERRAL_DEBUG !== 'true') { + return; + } + + console.log('[impact-referral-debug]', message, { + at: new Date().toISOString(), + ...(fields ?? {}), + }); +} diff --git a/apps/web/src/lib/impact-referral.test.ts b/apps/web/src/lib/impact-referral.test.ts index f04722da10..05072c3e60 100644 --- a/apps/web/src/lib/impact-referral.test.ts +++ b/apps/web/src/lib/impact-referral.test.ts @@ -107,8 +107,8 @@ describe('impact referral participant registration dispatch', () => { const requestBody = fetchMock.mock.calls[0]?.[1]?.body; expect(typeof requestBody).toBe('string'); expect(JSON.parse(String(requestBody))).toEqual({ - id: user.id, - accountId: user.id, + id: user.google_user_email, + accountId: user.google_user_email, programId: '51699', email: user.google_user_email, cookies: 'sq-cookie', diff --git a/apps/web/src/lib/impact-referral.ts b/apps/web/src/lib/impact-referral.ts index 7caa5ce48b..66396ca9e2 100644 --- a/apps/web/src/lib/impact-referral.ts +++ b/apps/web/src/lib/impact-referral.ts @@ -8,6 +8,7 @@ import { sendImpactAdvocateRegisterParticipantPayload, type ImpactAdvocateRegisterParticipantPayload, } from '@/lib/impact-advocate'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; import type { ParsedImpactAffiliateTouch, ParsedImpactReferralTouch, @@ -41,6 +42,8 @@ export type ImpactAdvocateRegistrationDispatchSummary = { failed: number; }; +const IMPACT_ADVOCATE_REGISTRATION_CLAIM_STALE_MS = 15 * 60 * 1000; + function getDatabaseClient(database?: DatabaseClient): DatabaseClient { return database ?? db; } @@ -98,7 +101,7 @@ export async function recordImpactAffiliateTouch(params: { touchMinuteBucket(params.touch.touchedAt), ]); - await database + const [insertedTouch] = await database .insert(kiloclaw_attribution_touches) .values({ dedupe_key: dedupeKey, @@ -119,7 +122,22 @@ export async function recordImpactAffiliateTouch(params: { touched_at: params.touch.touchedAt.toISOString(), expires_at: params.touch.expiresAt.toISOString(), }) - .onConflictDoNothing({ target: [kiloclaw_attribution_touches.dedupe_key] }); + .onConflictDoNothing({ target: [kiloclaw_attribution_touches.dedupe_key] }) + .returning({ id: kiloclaw_attribution_touches.id }); + + logImpactReferralDebug( + insertedTouch + ? 'Recorded Impact affiliate attribution touch' + : 'Impact affiliate touch already existed', + { + userId: params.userId ?? null, + anonymousIdPresent: Boolean(params.anonymousId?.trim()), + touchId: insertedTouch?.id ?? null, + landingPath: params.touch.landingPath, + trackingValueLength: params.touch.trackingValueLength, + isTrackingValueAccepted: params.touch.isTrackingValueAccepted, + } + ); } export async function recordImpactReferralTouch(params: { @@ -139,7 +157,7 @@ export async function recordImpactReferralTouch(params: { touchMinuteBucket(params.touch.touchedAt), ]); - await database + const [insertedTouch] = await database .insert(kiloclaw_attribution_touches) .values({ dedupe_key: dedupeKey, @@ -162,7 +180,23 @@ export async function recordImpactReferralTouch(params: { touched_at: params.touch.touchedAt.toISOString(), expires_at: params.touch.expiresAt.toISOString(), }) - .onConflictDoNothing({ target: [kiloclaw_attribution_touches.dedupe_key] }); + .onConflictDoNothing({ target: [kiloclaw_attribution_touches.dedupe_key] }) + .returning({ id: kiloclaw_attribution_touches.id }); + + logImpactReferralDebug( + insertedTouch + ? 'Recorded Impact Advocate referral touch' + : 'Impact Advocate referral touch already existed', + { + userId: params.userId ?? null, + anonymousIdPresent: Boolean(params.anonymousId?.trim()), + touchId: insertedTouch?.id ?? null, + landingPath: params.touch.landingPath, + rsCodePresent: Boolean(params.touch.rsCode?.trim()), + trackingValueLength: params.touch.trackingValueLength, + isTrackingValueAccepted: params.touch.isTrackingValueAccepted, + } + ); } export async function ensureImpactAdvocateParticipantProfile(params: { @@ -174,23 +208,23 @@ export async function ensureImpactAdvocateParticipantProfile(params: { }): Promise<{ id: string }> { const database = getDatabaseClient(params.database); + const isConfigured = isImpactAdvocateConfigured(); + const [insertedParticipant] = await database .insert(impact_advocate_participants) .values({ user_id: params.user.id, - advocate_id: params.user.id, - advocate_account_id: params.user.id, + advocate_id: params.user.google_user_email, + advocate_account_id: params.user.google_user_email, opaque_referral_identifier: params.opaqueReferralIdentifier ?? null, contact_email: params.user.google_user_email, locale: params.locale ?? null, country_code: params.countryCode ?? null, - registration_state: isImpactAdvocateConfigured() + registration_state: isConfigured ? ImpactAdvocateRegistrationState.Pending : ImpactAdvocateRegistrationState.Failed, - last_error_code: isImpactAdvocateConfigured() ? null : 'missing_configuration', - last_error_message: isImpactAdvocateConfigured() - ? null - : 'Impact Advocate configuration is incomplete', + last_error_code: isConfigured ? null : 'missing_configuration', + last_error_message: isConfigured ? null : 'Impact Advocate configuration is incomplete', }) .onConflictDoNothing({ target: [impact_advocate_participants.user_id] }) .returning({ id: impact_advocate_participants.id }); @@ -209,8 +243,8 @@ export async function ensureImpactAdvocateParticipantProfile(params: { await database .update(impact_advocate_participants) .set({ - advocate_id: params.user.id, - advocate_account_id: params.user.id, + advocate_id: params.user.google_user_email, + advocate_account_id: params.user.google_user_email, contact_email: params.user.google_user_email, locale: params.locale ?? null, country_code: params.countryCode ?? null, @@ -231,6 +265,13 @@ export async function queueImpactAdvocateParticipantRegistration(params: { countryCode?: string | null; }): Promise { if (!params.referralTouch.opaqueTrackingValue) { + logImpactReferralDebug( + 'Skipped Impact Advocate participant registration queue; missing referral cookie value', + { + userId: params.user.id, + landingPath: params.referralTouch.landingPath, + } + ); return; } @@ -275,6 +316,21 @@ export async function queueImpactAdvocateParticipantRegistration(params: { .onConflictDoNothing({ target: [impact_advocate_registration_attempts.dedupe_key] }) .returning({ id: impact_advocate_registration_attempts.id }); + logImpactReferralDebug( + insertedAttempt + ? 'Queued Impact Advocate participant registration attempt' + : 'Impact Advocate participant registration attempt already existed', + { + userId: params.user.id, + participantId: participant.id, + attemptId: insertedAttempt?.id ?? null, + impactAdvocateConfigured: isConfigured, + trackingValueLength: params.referralTouch.trackingValueLength, + localePresent: Boolean(params.locale?.trim()), + countryCode: params.countryCode ?? null, + } + ); + if (!insertedAttempt) { return; } @@ -386,10 +442,26 @@ async function dispatchImpactAdvocateRegistrationAttemptById( }) .where(eq(impact_advocate_registration_attempts.id, attempt.id)); + logImpactReferralDebug('Dispatching Impact Advocate participant registration attempt', { + attemptId: attempt.id, + participantId: participant.id, + userId: participant.user_id, + attemptCount: attempt.attempt_count, + }); + const result = await sendImpactAdvocateRegisterParticipantPayload(payload); const attemptCount = attempt.attempt_count + 1; const completedAt = new Date().toISOString(); + logImpactReferralDebug('Impact Advocate participant registration dispatch result', { + attemptId: attempt.id, + participantId: participant.id, + userId: participant.user_id, + ok: result.ok, + failureKind: result.ok ? null : result.failureKind, + statusCode: result.statusCode ?? null, + }); + if (result.ok) { await db.transaction(async tx => { await tx @@ -472,7 +544,9 @@ export async function dispatchQueuedImpactAdvocateRegistrationAttempts(params?: limit?: number; }): Promise { const limit = params?.limit ?? 100; - const nowIso = new Date().toISOString(); + const now = Date.now(); + const nowIso = new Date(now).toISOString(); + const staleClaimedAt = new Date(now - IMPACT_ADVOCATE_REGISTRATION_CLAIM_STALE_MS).toISOString(); const rows = await db .select({ id: impact_advocate_registration_attempts.id }) .from(impact_advocate_registration_attempts) @@ -499,6 +573,13 @@ export async function dispatchQueuedImpactAdvocateRegistrationAttempts(params?: sql`${impact_advocate_registration_attempts.next_retry_at} IS NULL`, lte(impact_advocate_registration_attempts.next_retry_at, nowIso) ) + ), + and( + eq( + impact_advocate_registration_attempts.delivery_state, + ImpactAdvocateAttemptDeliveryState.Sending + ), + lte(impact_advocate_registration_attempts.claimed_at, staleClaimedAt) ) ) ) @@ -515,6 +596,11 @@ export async function dispatchQueuedImpactAdvocateRegistrationAttempts(params?: failed: 0, }; + logImpactReferralDebug('Claimed queued Impact Advocate participant registration attempts', { + claimed: summary.claimed, + limit, + }); + for (const row of rows) { const outcome = await dispatchImpactAdvocateRegistrationAttemptById(row.id); if (outcome === 'delivered') { diff --git a/apps/web/src/lib/impact.ts b/apps/web/src/lib/impact.ts index d5d150fcaf..f779f0b37f 100644 --- a/apps/web/src/lib/impact.ts +++ b/apps/web/src/lib/impact.ts @@ -2,6 +2,7 @@ import 'server-only'; import { createHash } from 'crypto'; import { IMPACT_ACCOUNT_SID, IMPACT_AUTH_TOKEN, IMPACT_CAMPAIGN_ID } from '@/lib/config.server'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; const IMPACT_REVERSAL_DISPOSITION_CODE = 'REJECTED'; @@ -546,6 +547,18 @@ function getNormalizedStatus(value: unknown): string | null { export async function sendImpactConversionPayload( payload: ImpactConversionPayload ): Promise { + logImpactReferralDebug('Sending Impact conversion payload', { + actionTrackerId: payload.ActionTrackerId, + orderId: payload.OrderId, + clickIdPresent: Boolean(payload.ClickId?.trim()), + customerIdPresent: Boolean(payload.CustomerId?.trim()), + customerEmailHashPresent: Boolean(payload.CustomerEmail?.trim()), + amount: payload.ItemSubTotal1 ?? null, + currencyCode: payload.CurrencyCode ?? null, + itemCategory: payload.ItemCategory1 ?? null, + impactConfigured: isImpactConfigured(), + }); + const result = await sendImpactRequest({ method: 'POST', path: `/Advertisers/${IMPACT_ACCOUNT_SID}/Conversions`, @@ -553,6 +566,15 @@ export async function sendImpactConversionPayload( contentType: 'application/json', }); + logImpactReferralDebug('Impact conversion payload result', { + actionTrackerId: payload.ActionTrackerId, + orderId: payload.OrderId, + ok: result.ok, + delivery: result.ok ? (result.skipped ?? result.delivery ?? null) : null, + failureKind: result.ok ? null : result.failureKind, + statusCode: result.ok ? null : (result.statusCode ?? null), + }); + if ( result.ok && payload.ActionTrackerId === IMPACT_ACTION_TRACKER_IDS.sale && @@ -560,6 +582,12 @@ export async function sendImpactConversionPayload( result.delivery !== 'immediate' && result.delivery !== 'queued' ) { + logImpactReferralDebug('Impact sale response missing required action mapping', { + actionTrackerId: payload.ActionTrackerId, + orderId: payload.OrderId, + delivery: result.delivery ?? null, + }); + return { ok: false, failureKind: 'submission_failed', @@ -645,17 +673,33 @@ export async function resolveImpactSubmissionUri( export async function reverseImpactAction(params: { actionId: string; }): Promise { + logImpactReferralDebug('Sending Impact action reversal', { + actionId: params.actionId, + dispositionCode: IMPACT_REVERSAL_DISPOSITION_CODE, + impactConfigured: isImpactConfigured(), + }); + const formData = new URLSearchParams({ ActionId: params.actionId, DispositionCode: IMPACT_REVERSAL_DISPOSITION_CODE, }); - return await sendImpactRequest({ + const result = await sendImpactRequest({ method: 'DELETE', path: `/Advertisers/${IMPACT_ACCOUNT_SID}/Actions`, body: formData.toString(), contentType: 'application/x-www-form-urlencoded', }); + + logImpactReferralDebug('Impact action reversal result', { + actionId: params.actionId, + ok: result.ok, + delivery: result.ok ? (result.skipped ?? result.delivery ?? null) : null, + failureKind: result.ok ? null : result.failureKind, + statusCode: result.ok ? null : (result.statusCode ?? null), + }); + + return result; } function throwIfImpactDispatchFailed(eventName: string, result: ImpactDispatchResult): void { diff --git a/apps/web/src/lib/user.server.ts b/apps/web/src/lib/user.server.ts index 4792bd8ac8..99413ebaba 100644 --- a/apps/web/src/lib/user.server.ts +++ b/apps/web/src/lib/user.server.ts @@ -30,6 +30,7 @@ import { PLATFORM } from '@/lib/integrations/core/constants'; import { verifyAndConsumeMagicLinkToken } from '@/lib/auth/magic-link-tokens'; import { redirect } from 'next/navigation'; import { IMPACT_CLICK_ID_COOKIE } from '@/lib/impact-affiliate-utils'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; import { countryCodeFromHeaders, localeFromHeaders } from '@/lib/impact-referral'; import { parseImpactAffiliateTouchFromUrl, @@ -421,8 +422,23 @@ async function getImpactTrackingContextFromAuthFlow(requestHeaders?: Headers): P if (callbackUrlCookie) { try { const callbackUrl = new URL(callbackUrlCookie, 'http://localhost'); - const affiliateTouch = parseImpactAffiliateTouchFromUrl(callbackUrl); const referralTouch = parseImpactReferralTouchFromUrl(callbackUrl); + const urlImRefParam = callbackUrl.searchParams.get('im_ref')?.trim() || null; + const ignoreUrlImRefForReferralTouch = Boolean( + referralTouch?.opaqueTrackingValue && urlImRefParam + ); + const affiliateTouch = ignoreUrlImRefForReferralTouch + ? null + : parseImpactAffiliateTouchFromUrl(callbackUrl); + + logImpactReferralDebug('Auth flow parsed Impact tracking context from callback URL cookie', { + affiliateTouchPresent: Boolean(affiliateTouch), + referralTouchPresent: Boolean(referralTouch), + referralCookieValuePresent: Boolean(referralTouch?.opaqueTrackingValue), + affiliateTrackingIdPresent: Boolean(affiliateTouch?.trackingId?.trim()), + ignoredUrlImRefForReferralTouch: ignoreUrlImRefForReferralTouch, + callbackPath: callbackUrl.pathname, + }); return { affiliateTrackingId: affiliateTouch?.trackingId ?? null, @@ -444,6 +460,13 @@ async function getImpactTrackingContextFromAuthFlow(requestHeaders?: Headers): P ? parseImpactAffiliateTouchFromUrl(fallbackUrl, cookieTrackingId) : null; + logImpactReferralDebug('Auth flow parsed Impact tracking context from cookie fallback', { + affiliateTouchPresent: Boolean(affiliateTouch), + referralTouchPresent: false, + affiliateTrackingIdPresent: Boolean(cookieTrackingId?.trim()), + cookieTrackingIdLength: cookieTrackingId?.length ?? 0, + }); + return { affiliateTrackingId: cookieTrackingId, trackingContext: { @@ -737,10 +760,24 @@ const authOptions: NextAuthOptions = { // For email (magic link) auth, we auto-link to existing users since magic link // is verified by email ownership const autoLinkToExistingUser = isEmailAuth || isFakeLogin; - const { affiliateTrackingId, trackingContext } = - !isAccountLinking && !isFakeLogin - ? await getImpactTrackingContextFromAuthFlow(requestHeaders) - : { affiliateTrackingId: null, trackingContext: {} }; + if (isAccountLinking) { + logImpactReferralDebug('Auth flow skipped Impact tracking context extraction', { + provider: accountInfo.provider, + isAccountLinking: Boolean(isAccountLinking), + isFakeLogin, + }); + } + + const { affiliateTrackingId, trackingContext } = !isAccountLinking + ? await getImpactTrackingContextFromAuthFlow(requestHeaders) + : { affiliateTrackingId: null, trackingContext: {} }; + + logImpactReferralDebug('Auth flow forwarding Impact tracking context to user upsert', { + provider: accountInfo.provider, + affiliateTrackingIdPresent: Boolean(affiliateTrackingId?.trim()), + affiliateTouchPresent: Boolean(trackingContext.affiliateTouch), + referralTouchPresent: Boolean(trackingContext.referralTouch), + }); const result = isAccountLinking && linkingSession ? whenOk( diff --git a/apps/web/src/lib/user.ts b/apps/web/src/lib/user.ts index e87b33b672..0a1f57f566 100644 --- a/apps/web/src/lib/user.ts +++ b/apps/web/src/lib/user.ts @@ -90,6 +90,7 @@ import { import { normalizeEmail } from '@/lib/utils'; import { extractEmailDomain } from '@/lib/email-domain'; import { recordAffiliateAttributionAndQueueParentEvent } from '@/lib/affiliate-events'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; import { createDeletedUserEmailTombstone, queueImpactAdvocateParticipantRegistration, @@ -472,6 +473,10 @@ export async function createOrUpdateUser( if (affiliateTrackingId?.trim()) { try { + logImpactReferralDebug('Signup recording Impact affiliate attribution and parent event', { + userId: inserted.id, + trackingIdLength: affiliateTrackingId.trim().length, + }); await recordAffiliateAttributionAndQueueParentEvent({ database: tx, userId: inserted.id, @@ -490,6 +495,13 @@ export async function createOrUpdateUser( if (trackingContext?.affiliateTouch) { try { + logImpactReferralDebug('Signup recording Impact affiliate touch', { + userId: inserted.id, + anonymousIdPresent: Boolean(trackingContext.anonymousId?.trim()), + landingPath: trackingContext.affiliateTouch.landingPath, + trackingValueLength: trackingContext.affiliateTouch.trackingValueLength, + isTrackingValueAccepted: trackingContext.affiliateTouch.isTrackingValueAccepted, + }); await recordImpactAffiliateTouch({ database: tx, userId: inserted.id, @@ -506,6 +518,14 @@ export async function createOrUpdateUser( if (trackingContext?.referralTouch) { try { + logImpactReferralDebug('Signup recording Impact Advocate referral touch', { + userId: inserted.id, + anonymousIdPresent: Boolean(trackingContext.anonymousId?.trim()), + landingPath: trackingContext.referralTouch.landingPath, + rsCodePresent: Boolean(trackingContext.referralTouch.rsCode?.trim()), + trackingValueLength: trackingContext.referralTouch.trackingValueLength, + isTrackingValueAccepted: trackingContext.referralTouch.isTrackingValueAccepted, + }); await recordImpactReferralTouch({ database: tx, userId: inserted.id, @@ -520,6 +540,12 @@ export async function createOrUpdateUser( } try { + logImpactReferralDebug('Signup queueing Impact Advocate participant registration', { + userId: inserted.id, + landingPath: trackingContext.referralTouch.landingPath, + localePresent: Boolean(trackingContext.locale?.trim()), + countryCode: trackingContext.countryCode ?? null, + }); await queueImpactAdvocateParticipantRegistration({ database: tx, user: inserted, From dd513ceeab348eb3bb80b619041f6b70d75345eb Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 10:42:22 +0200 Subject: [PATCH 11/32] feat(referrals): KiloClaw referral lifecycle and rewards backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the server side of the KiloClaw referral program — the attribution touches, referral records, and reward decisions that turn an Impact Advocate touch into a free-month renewal extension. - kiloclaw-referrals.ts: end-to-end lifecycle covering source-touch promotion at signup, referral creation gated by a 10-minute touch- capture grace window, reward eligibility checks against the current KiloClaw subscription, and reward application via insertKiloClawSubscriptionChangeLog. All operations use a system actor (actorType: 'system', actorId: 'kiloclaw-referrals') so the audit trail is unambiguous. - kiloclaw-router.ts: tRPC procedures backing the referral surface — getReferralRewardSummary for the user's reward view, plus internal flows that the billing-side-effects route consumes. - billing-side-effects/route.ts: webhook-driven entry point that applies pending rewards on subscription state transitions. - services/kiloclaw-billing/lifecycle.ts: hook the referral application into the renewal lifecycle so rewards extend the current period rather than refunding cash. - migration 0109_panoramic_vapor: introduce kiloclaw_attribution_touches, kiloclaw_referrals, kiloclaw_referral_rewards, and kiloclaw_referral_reward_decisions with the indexes the lifecycle queries need; enable pgcrypto for tombstone hashing; backfill existing attribution touches. - empty-database.ts: keep the dev reset path schema-aware so the new tables drop cleanly. Per AGENTS.md, this introduces new PII-bearing tables; the GDPR soft-delete tombstoning lives in lib/impact-referral.ts and is wired through user.ts in the previous commit. --- .../kiloclaw/billing-side-effects/route.ts | 28 ++ apps/web/src/db/empty-database.ts | 8 +- apps/web/src/lib/kiloclaw-referrals.test.ts | 119 +++++ apps/web/src/lib/kiloclaw-referrals.ts | 235 ++++++++-- apps/web/src/routers/kiloclaw-router.test.ts | 443 ++++++++++++++++++ apps/web/src/routers/kiloclaw-router.ts | 294 +++++++++++- services/kiloclaw-billing/src/lifecycle.ts | 41 +- 7 files changed, 1106 insertions(+), 62 deletions(-) diff --git a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts index 3fb1f17539..7674558003 100644 --- a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts +++ b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts @@ -15,6 +15,7 @@ import { ensureAutoIntroSchedule } from '@/lib/kiloclaw/stripe-handlers'; import { isIntroPriceId } from '@/lib/kiloclaw/stripe-price-ids.server'; import { client as stripe } from '@/lib/stripe-client'; import { enqueueAffiliateEventForUser } from '@/lib/affiliate-events'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; import { processPersonalKiloClawPaidConversion } from '@/lib/kiloclaw-referrals'; import { projectPendingKiloPassBonusMicrodollars } from '@/lib/kiloclaw/credit-billing'; import { maybeIssueKiloPassBonusFromUsageThreshold } from '@/lib/kilo-pass/usage-triggered-bonus'; @@ -272,6 +273,16 @@ export async function POST(request: NextRequest) { } case 'enqueue_affiliate_event': + logImpactReferralDebug('KiloClaw billing side effect enqueueing affiliate event', { + userId: parsed.data.input.userId, + provider: parsed.data.input.provider, + eventType: parsed.data.input.eventType, + dedupeKey: parsed.data.input.dedupeKey, + orderId: parsed.data.input.orderId, + amount: parsed.data.input.amount, + currencyCode: parsed.data.input.currencyCode, + itemCategory: parsed.data.input.itemCategory, + }); await enqueueAffiliateEventForUser({ userId: parsed.data.input.userId, provider: parsed.data.input.provider, @@ -290,6 +301,14 @@ export async function POST(request: NextRequest) { break; case 'process_paid_conversion': { + logImpactReferralDebug('KiloClaw billing side effect processing paid conversion', { + userId: parsed.data.input.userId, + dedupeKey: parsed.data.input.dedupeKey, + orderId: parsed.data.input.orderId, + amount: parsed.data.input.amount, + currencyCode: parsed.data.input.currencyCode, + itemCategory: parsed.data.input.itemCategory, + }); const disposition = await processPersonalKiloClawPaidConversion({ userId: parsed.data.input.userId, sourcePaymentId: parsed.data.input.orderId, @@ -302,6 +321,15 @@ export async function POST(request: NextRequest) { convertedAt: new Date(parsed.data.input.eventDateIso), }); + logImpactReferralDebug('KiloClaw billing side effect paid conversion disposition', { + userId: parsed.data.input.userId, + orderId: parsed.data.input.orderId, + shouldEnqueueAffiliateSale: disposition.shouldEnqueueAffiliateSale, + winningTouchType: disposition.winningTouchType, + conversionId: disposition.conversionId, + disqualificationReason: disposition.disqualificationReason, + }); + if (disposition.shouldEnqueueAffiliateSale) { await enqueueAffiliateEventForUser({ userId: parsed.data.input.userId, diff --git a/apps/web/src/db/empty-database.ts b/apps/web/src/db/empty-database.ts index 960afcb9f6..586c0c84d7 100644 --- a/apps/web/src/db/empty-database.ts +++ b/apps/web/src/db/empty-database.ts @@ -2,6 +2,10 @@ import '../lib/load-env'; import { sql } from 'drizzle-orm'; import { db } from '../lib/drizzle'; +function quotePostgresIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + async function main() { console.log('Resetting database (drop and recreate app schemas)...'); @@ -18,7 +22,9 @@ async function main() { } console.log(`Dropping schema ${row.nspname}...`); - await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${row.nspname}" CASCADE`)); + await db.execute( + sql.raw(`DROP SCHEMA IF EXISTS ${quotePostgresIdentifier(row.nspname)} CASCADE`) + ); } await db.execute(sql.raw('CREATE SCHEMA "public"')); diff --git a/apps/web/src/lib/kiloclaw-referrals.test.ts b/apps/web/src/lib/kiloclaw-referrals.test.ts index d45b65abfe..b1001a792f 100644 --- a/apps/web/src/lib/kiloclaw-referrals.test.ts +++ b/apps/web/src/lib/kiloclaw-referrals.test.ts @@ -440,6 +440,125 @@ describe('kiloclaw referrals', () => { expect(mockSendImpactConversionPayload).toHaveBeenCalledTimes(1); }); + it('resolves referrers through referral_codes when no participant mapping exists', async () => { + const referrer = await insertTestUser({ + google_user_email: 'referral-code-referrer@example.com', + normalized_email: 'referral-code-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'referral-code-referee@example.com', + normalized_email: 'referral-code-referee@example.com', + }); + const impactReferralId = 'REFERRER5616'; + const sourcePaymentId = 'kiloclaw-subscription:instance-referral-code:2026-04'; + + await db.insert(referral_codes).values({ + kilo_user_id: referrer.id, + code: impactReferralId, + }); + await insertActivePersonalSubscription(referrer.id); + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: 'abababab-abab-4bab-8bab-abababababab', + dedupe_key: 'referral-code-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: impactReferralId, + touched_at: '2026-03-31T00:00:00.000Z', + expires_at: '2026-04-30T00:00:00.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toMatchObject({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + disqualificationReason: null, + }); + + const [conversion] = await db.select().from(kiloclaw_referral_conversions); + expect(conversion.referrer_user_id).toBe(referrer.id); + expect(conversion.qualified).toBe(true); + }); + + it('allows signup referral touches captured shortly after user creation', async () => { + const referrer = await insertTestUser({ + google_user_email: 'signup-race-referrer@example.com', + normalized_email: 'signup-race-referrer@example.com', + }); + const referee = await insertTestUser({ + google_user_email: 'signup-race-referee@example.com', + normalized_email: 'signup-race-referee@example.com', + created_at: '2026-04-01T00:00:00.000Z', + updated_at: '2026-04-01T00:00:00.000Z', + }); + const opaqueReferralIdentifier = await insertImpactAdvocateParticipant(referrer.id); + const sourcePaymentId = 'kiloclaw-subscription:instance-signup-race:2026-04'; + + await insertActivePersonalSubscription(referrer.id); + await insertActivePersonalSubscription(referee.id); + await db.insert(credit_transactions).values({ + kilo_user_id: referee.id, + amount_microdollars: -9_000_000, + is_free: false, + description: 'KiloClaw standard enrollment', + credit_category: sourcePaymentId, + }); + await db.insert(kiloclaw_attribution_touches).values({ + id: 'cdcdcdcd-cdcd-4dcd-8dcd-cdcdcdcdcdcd', + dedupe_key: 'signup-race-referral-touch', + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'sq-cookie', + tracking_value_length: 9, + is_tracking_value_accepted: true, + rs_code: opaqueReferralIdentifier, + landing_path: '/users/after-sign-in?signup=true&callbackPath=%2Fclaw%2Fnew', + touched_at: '2026-04-01T00:00:02.000Z', + expires_at: '2026-05-01T00:00:02.000Z', + }); + + const disposition = await processPersonalKiloClawPaidConversion({ + userId: referee.id, + sourcePaymentId, + orderId: sourcePaymentId, + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + convertedAt: new Date('2026-04-09T00:00:00.000Z'), + }); + + expect(disposition).toMatchObject({ + shouldEnqueueAffiliateSale: false, + winningTouchType: 'referral', + disqualificationReason: null, + }); + }); + it('logs terminal 4xx Impact conversion report failures and stops retrying unchanged payloads', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); mockSendImpactConversionPayload.mockResolvedValueOnce({ diff --git a/apps/web/src/lib/kiloclaw-referrals.ts b/apps/web/src/lib/kiloclaw-referrals.ts index 7be0359690..bb2730cc58 100644 --- a/apps/web/src/lib/kiloclaw-referrals.ts +++ b/apps/web/src/lib/kiloclaw-referrals.ts @@ -15,6 +15,7 @@ import { type ImpactDispatchResult, } from '@/lib/impact'; import { isImpactAdvocateConfigured } from '@/lib/impact-advocate'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; import { hashNormalizedEmailForDeletionTombstone } from '@/lib/impact-referral'; import { resolveCurrentPersonalSubscriptionRow } from '@/lib/kiloclaw/current-personal-subscription'; import { client as stripe } from '@/lib/stripe-client'; @@ -33,6 +34,7 @@ import { kiloclaw_subscription_change_log, kiloclaw_subscriptions, kilocode_users, + referral_codes, type KiloClawAttributionTouch, type KiloClawSubscription, } from '@kilocode/db/schema'; @@ -105,6 +107,8 @@ const REFERRAL_REWARD_ACTOR = { actorId: 'kiloclaw-referrals', } as const; +const SIGNUP_REFERRAL_TOUCH_CAPTURE_GRACE_MS = 10 * 60 * 1000; + function getDatabaseClient(database?: DatabaseClient): DatabaseClient { return database ?? db; } @@ -119,6 +123,10 @@ function nextReportRetryAt(attemptCount: number): string { return new Date(Date.now() + reportBackoffDelayMs(attemptCount)).toISOString(); } +function nextReportClaimExpiresAt(): string { + return new Date(Date.now() + 15 * 60 * 1000).toISOString(); +} + function referralDisqualificationReason(reason: string): string { return `referral_${reason}`; } @@ -261,13 +269,49 @@ async function resolveReferrerUserIdFromReferralTouch(params: { return null; } - const [row] = await params.database + const [participant] = await params.database .select({ userId: impact_advocate_participants.user_id }) .from(impact_advocate_participants) .where(eq(impact_advocate_participants.opaque_referral_identifier, opaqueReferralIdentifier)) .limit(1); - return row?.userId ?? null; + if (participant) { + return participant.userId; + } + + const [referralCode] = await params.database + .select({ userId: referral_codes.kilo_user_id }) + .from(referral_codes) + .where(eq(referral_codes.code, opaqueReferralIdentifier)) + .limit(1); + + return referralCode?.userId ?? null; +} + +function wasReferralTouchCapturedDuringSignup(params: { + userCreatedAt: string; + referralTouch: KiloClawAttributionTouch; +}): boolean { + if (!params.referralTouch.landing_path) { + return false; + } + + const touchTime = new Date(params.referralTouch.touched_at).getTime(); + const userCreatedTime = new Date(params.userCreatedAt).getTime(); + if (touchTime < userCreatedTime) { + return false; + } + + if (touchTime - userCreatedTime > SIGNUP_REFERRAL_TOUCH_CAPTURE_GRACE_MS) { + return false; + } + + try { + const landingUrl = new URL(params.referralTouch.landing_path, 'http://localhost'); + return landingUrl.searchParams.get('signup') === 'true'; + } catch { + return false; + } } async function hasDeletedUserEmailTombstone(params: { @@ -512,9 +556,10 @@ function requiresDeferredStripeRewardApplication(subscription: KiloClawSubscript } async function applyReferralRewardById( - rewardId: string + rewardId: string, + options?: { stripeAlreadyApplied?: boolean } ): Promise<'applied' | 'expired' | 'pending' | 'noop'> { - return await db.transaction(async tx => { + const result = await db.transaction(async tx => { const [reward] = await tx .select() .from(kiloclaw_referral_rewards) @@ -623,6 +668,17 @@ async function applyReferralRewardById( const localOperationId = `kiloclaw-referral-reward:${reward.id}:apply`; const stripeIdempotencyKey = `kiloclaw-referral-reward:${reward.id}:stripe-apply`; + if (subscription.stripe_subscription_id && !options?.stripeAlreadyApplied) { + return { + outcome: 'stripe_pending' as const, + stripeUpdate: { + stripeSubscriptionId: subscription.stripe_subscription_id, + trialEnd: Math.floor(new Date(newBoundary).getTime() / 1000), + idempotencyKey: stripeIdempotencyKey, + }, + }; + } + const [beforeSubscription] = await tx .select() .from(kiloclaw_subscriptions) @@ -699,21 +755,25 @@ async function applyReferralRewardById( }); } - if (subscription.stripe_subscription_id) { - await stripe.subscriptions.update( - subscription.stripe_subscription_id, - { - trial_end: Math.floor(new Date(newBoundary).getTime() / 1000), - proration_behavior: 'none', - }, - { - idempotencyKey: stripeIdempotencyKey, - } - ); - } - return 'applied'; }); + + if (typeof result === 'string') { + return result; + } + + await stripe.subscriptions.update( + result.stripeUpdate.stripeSubscriptionId, + { + trial_end: result.stripeUpdate.trialEnd, + proration_behavior: 'none', + }, + { + idempotencyKey: result.stripeUpdate.idempotencyKey, + } + ); + + return applyReferralRewardById(rewardId, { stripeAlreadyApplied: true }); } export async function processQueuedKiloClawReferralRewards(params?: { @@ -1026,8 +1086,15 @@ async function persistImpactConversionReportResult(params: { async function dispatchImpactConversionReportById( reportId: string ): Promise<'delivered' | 'retried' | 'failed'> { + logImpactReferralDebug('Dispatching Impact referral conversion report', { + reportId, + }); + const report = await getImpactConversionReportById(reportId, db); if (!report) { + logImpactReferralDebug('Impact referral conversion report missing before dispatch', { + reportId, + }); return 'failed'; } @@ -1045,7 +1112,20 @@ async function dispatchImpactConversionReportById( const result = await sendImpactConversionPayload(payload); await persistImpactConversionReportResult({ reportId: report.id, result }); - return result.ok ? 'delivered' : result.failureKind === 'http_4xx' ? 'failed' : 'retried'; + const outcome = result.ok + ? 'delivered' + : result.failureKind === 'http_4xx' + ? 'failed' + : 'retried'; + logImpactReferralDebug('Impact referral conversion report dispatch result', { + reportId: report.id, + conversionId: report.conversion_id, + outcome, + ok: result.ok, + failureKind: result.ok ? null : result.failureKind, + statusCode: result.ok ? null : (result.statusCode ?? null), + }); + return outcome; } export async function dispatchQueuedImpactConversionReports(params?: { @@ -1054,21 +1134,28 @@ export async function dispatchQueuedImpactConversionReports(params?: { const limit = params?.limit ?? 100; const nowIso = new Date().toISOString(); const rows = await db - .select({ id: impact_conversion_reports.id }) - .from(impact_conversion_reports) + .update(impact_conversion_reports) + .set({ + state: ImpactConversionReportState.Retrying, + next_retry_at: nextReportClaimExpiresAt(), + }) .where( - and( - or( + sql`${impact_conversion_reports.id} IN ( + SELECT ${impact_conversion_reports.id} + FROM ${impact_conversion_reports} + WHERE ${or( eq(impact_conversion_reports.state, ImpactConversionReportState.Queued), eq(impact_conversion_reports.state, ImpactConversionReportState.Retrying) - ), - or( - sql`${impact_conversion_reports.next_retry_at} IS NULL`, - lte(impact_conversion_reports.next_retry_at, nowIso) - ) - ) + )} + AND ${or( + sql`${impact_conversion_reports.next_retry_at} IS NULL`, + lte(impact_conversion_reports.next_retry_at, nowIso) + )} + ORDER BY ${impact_conversion_reports.created_at}, ${impact_conversion_reports.id} + LIMIT ${limit} + )` ) - .limit(limit); + .returning({ id: impact_conversion_reports.id }); const summary: ImpactConversionReportDispatchSummary = { claimed: rows.length, @@ -1103,6 +1190,20 @@ export async function processPersonalKiloClawPaidConversion(params: { convertedAt: Date; qualificationContext?: PaidConversionQualificationContext; }): Promise { + logImpactReferralDebug( + 'Processing personal KiloClaw paid conversion for Impact referral attribution', + { + userId: params.userId, + sourcePaymentId: params.sourcePaymentId, + orderId: params.orderId, + amount: params.amount, + currencyCode: params.currencyCode, + itemCategory: params.itemCategory, + qualificationSourceType: params.qualificationContext?.sourceType ?? null, + qualificationOverrideEligible: params.qualificationContext?.overrideEligible ?? null, + } + ); + let impactReportId: string | null = null; const rewardBeneficiaryUserIds = new Set(); const disposition = await db.transaction(async tx => { @@ -1111,13 +1212,29 @@ export async function processPersonalKiloClawPaidConversion(params: { }); if (existingConversion) { - return { - shouldEnqueueAffiliateSale: - existingConversion.winning_touch_type === KiloClawReferralWinningTouchType.Affiliate, - winningTouchType: existingConversion.winning_touch_type, - conversionId: existingConversion.id, - disqualificationReason: existingConversion.disqualification_reason, - } satisfies KiloClawPaidConversionDisposition; + const overrideDisqualificationReason = + params.qualificationContext?.sourceType && + params.qualificationContext.sourceType !== 'normal' + ? getQualificationDisqualificationReason(params.qualificationContext.sourceType) + : null; + const canReprocessWithAdminOverride = + params.qualificationContext?.overrideEligible === true && + existingConversion.qualified === false && + existingConversion.disqualification_reason === overrideDisqualificationReason; + + if (canReprocessWithAdminOverride) { + await tx + .delete(kiloclaw_referral_conversions) + .where(eq(kiloclaw_referral_conversions.id, existingConversion.id)); + } else { + return { + shouldEnqueueAffiliateSale: + existingConversion.winning_touch_type === KiloClawReferralWinningTouchType.Affiliate, + winningTouchType: existingConversion.winning_touch_type, + conversionId: existingConversion.id, + disqualificationReason: existingConversion.disqualification_reason, + } satisfies KiloClawPaidConversionDisposition; + } } const [user] = await tx @@ -1221,6 +1338,21 @@ export async function processPersonalKiloClawPaidConversion(params: { convertedAt: params.convertedAt, }); + logImpactReferralDebug('Resolved KiloClaw Impact attribution touches for paid conversion', { + userId: params.userId, + sourcePaymentId: params.sourcePaymentId, + touchCount: touches.length, + affiliateTouchCount: touches.filter( + touch => touch.touch_type === KiloClawAttributionTouchType.Affiliate + ).length, + referralTouchCount: touches.filter( + touch => touch.touch_type === KiloClawAttributionTouchType.Referral + ).length, + winner: resolution.winner, + affiliateTouchId: resolution.affiliateTouch?.id ?? null, + referralTouchId: resolution.referralTouch?.id ?? null, + }); + if (resolution.winner === 'none') { const [conversion] = await tx .insert(kiloclaw_referral_conversions) @@ -1284,13 +1416,24 @@ export async function processPersonalKiloClawPaidConversion(params: { impactReferralId: buildImpactReferralId(resolution.referralTouch), database: tx, }); + logImpactReferralDebug('Upserted KiloClaw Impact referral relationship', { + refereeUserId: params.userId, + referrerUserId, + sourceTouchId: resolution.referralTouch.id, + impactReferralIdPresent: Boolean(buildImpactReferralId(resolution.referralTouch)?.trim()), + }); const deletedUser = await hasDeletedUserEmailTombstone({ normalizedEmail: user.normalizedEmail, database: tx, }); const userExistedBeforeReferral = - new Date(user.createdAt).getTime() < new Date(resolution.referralTouch.touched_at).getTime(); + new Date(user.createdAt).getTime() < + new Date(resolution.referralTouch.touched_at).getTime() && + !wasReferralTouchCapturedDuringSignup({ + userCreatedAt: user.createdAt, + referralTouch: resolution.referralTouch, + }); const isSelfReferral = referrerUserId !== null && referrerUserId === params.userId; if (deletedUser || userExistedBeforeReferral || !referrerUserId || isSelfReferral) { @@ -1541,12 +1684,30 @@ export async function processPersonalKiloClawPaidConversion(params: { } satisfies KiloClawPaidConversionDisposition; }); + logImpactReferralDebug( + 'Processed personal KiloClaw paid conversion for Impact referral attribution', + { + userId: params.userId, + sourcePaymentId: params.sourcePaymentId, + shouldEnqueueAffiliateSale: disposition.shouldEnqueueAffiliateSale, + winningTouchType: disposition.winningTouchType, + conversionId: disposition.conversionId, + disqualificationReason: disposition.disqualificationReason, + impactReportId, + rewardBeneficiaryCount: rewardBeneficiaryUserIds.size, + } + ); + if (impactReportId) { await dispatchImpactConversionReportById(impactReportId); } if (rewardBeneficiaryUserIds.size > 0) { try { + logImpactReferralDebug('Processing queued KiloClaw Impact referral rewards', { + sourcePaymentId: params.sourcePaymentId, + beneficiaryCount: rewardBeneficiaryUserIds.size, + }); await processQueuedKiloClawReferralRewards({ beneficiaryUserIds: Array.from(rewardBeneficiaryUserIds), }); diff --git a/apps/web/src/routers/kiloclaw-router.test.ts b/apps/web/src/routers/kiloclaw-router.test.ts index 08f5c3202f..ab7cdf70ab 100644 --- a/apps/web/src/routers/kiloclaw-router.test.ts +++ b/apps/web/src/routers/kiloclaw-router.test.ts @@ -12,6 +12,12 @@ import { kiloclaw_inbound_email_aliases, kiloclaw_inbound_email_reserved_aliases, kiloclaw_instances, + kiloclaw_attribution_touches, + kiloclaw_referrals, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, kiloclaw_subscription_change_log, kiloclaw_subscriptions, } from '@kilocode/db/schema'; @@ -116,6 +122,59 @@ let createCaller: (ctx: { user: Awaited> }) => startedAt: number | null; }>; destroy: () => Promise<{ ok: true }>; + getActivePersonalBillingStatus: () => Promise<{ + subscription: { + referralRewards: { + totalAppliedMonths: number; + applications: Array<{ + role: string; + appliedAt: string; + monthsGranted: number; + previousRenewalBoundary: string; + newRenewalBoundary: string; + }>; + }; + } | null; + }>; + getSubscriptionDetail: (input: { instanceId: string }) => Promise<{ + referralRewards: { + totalAppliedMonths: number; + applications: Array<{ + role: string; + appliedAt: string; + monthsGranted: number; + previousRenewalBoundary: string; + newRenewalBoundary: string; + }>; + }; + }>; + getReferralRewardSummary: () => Promise<{ + rewards: Array<{ + role: string; + status: string; + monthsGranted: number; + earnedAt: string; + appliedAt: string | null; + application: { + previousRenewalBoundary: string; + newRenewalBoundary: string; + } | null; + }>; + totals: { + totalRewards: number; + pendingRewards: number; + totalAppliedMonths: number; + }; + referredPeople: Array<{ + maskedEmail: string | null; + state: string; + rewardGranted: boolean; + }>; + pendingRewardAction: { + showStartReactivateCta: boolean; + pendingRewardCount: number; + }; + }>; }; const kiloclawClientMock = jest.requireMock( '@/lib/kiloclaw/kiloclaw-internal-client' @@ -507,6 +566,390 @@ describe('kiloclawRouter start', () => { }); }); +describe('kiloclawRouter getActivePersonalBillingStatus referral rewards', () => { + beforeEach(async () => { + await cleanupDbForTest(); + }); + + async function insertActivePersonalSubscription(userId: string) { + const instanceId = crypto.randomUUID(); + await db.insert(kiloclaw_instances).values({ + id: instanceId, + user_id: userId, + sandbox_id: `ki_${instanceId.replace(/-/g, '')}`, + }); + const [subscription] = await db + .insert(kiloclaw_subscriptions) + .values({ + user_id: userId, + instance_id: instanceId, + payment_source: 'credits', + plan: 'standard', + status: 'active', + current_period_start: '2026-04-01T00:00:00.000Z', + current_period_end: '2026-06-01T00:00:00.000Z', + credit_renewal_at: '2026-06-01T00:00:00.000Z', + }) + .returning({ id: kiloclaw_subscriptions.id, instanceId: kiloclaw_subscriptions.instance_id }); + + return { subscriptionId: subscription.id, instanceId: subscription.instanceId ?? instanceId }; + } + + async function insertAppliedReferralReward(params: { + beneficiaryUserId: string; + subscriptionId: string; + role: 'referrer' | 'referee'; + sourcePaymentId: string; + }) { + const referee = await insertTestUser({ + google_user_email: `kiloclaw-reward-referee-${Math.random()}@example.com`, + }); + const [conversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: referee.id, + referrer_user_id: params.role === 'referrer' ? params.beneficiaryUserId : null, + winning_touch_type: 'referral', + source_payment_id: params.sourcePaymentId, + qualified: true, + converted_at: '2026-04-10T00:00:00.000Z', + }) + .returning({ id: kiloclaw_referral_conversions.id }); + const [decision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values({ + conversion_id: conversion.id, + beneficiary_user_id: params.beneficiaryUserId, + beneficiary_role: params.role, + outcome: 'granted', + months_granted: 1, + }) + .returning({ id: kiloclaw_referral_reward_decisions.id }); + const [reward] = await db + .insert(kiloclaw_referral_rewards) + .values({ + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: params.beneficiaryUserId, + beneficiary_role: params.role, + months_granted: 1, + status: 'applied', + applies_to_subscription_id: params.subscriptionId, + earned_at: '2026-04-10T00:00:00.000Z', + applied_at: '2026-04-10T00:05:00.000Z', + }) + .returning({ id: kiloclaw_referral_rewards.id }); + await db.insert(kiloclaw_referral_reward_applications).values({ + reward_id: reward.id, + beneficiary_user_id: params.beneficiaryUserId, + subscription_id: params.subscriptionId, + previous_renewal_boundary: '2026-05-01T00:00:00.000Z', + new_renewal_boundary: '2026-06-01T00:00:00.000Z', + applied_at: '2026-04-10T00:05:00.000Z', + }); + } + + it('returns applied referral rewards for the active personal subscription', async () => { + const user = await insertTestUser({ + google_user_email: `kiloclaw-reward-status-${Math.random()}@example.com`, + }); + const { subscriptionId, instanceId } = await insertActivePersonalSubscription(user.id); + await insertAppliedReferralReward({ + beneficiaryUserId: user.id, + subscriptionId, + role: 'referrer', + sourcePaymentId: `kiloclaw-subscription:${instanceId}:2026-04`, + }); + + const billing = await createCaller({ user }).getActivePersonalBillingStatus(); + + expect(billing.subscription?.referralRewards).toEqual({ + totalAppliedMonths: 1, + applications: [ + { + role: 'referrer', + appliedAt: '2026-04-10T00:05:00.000Z', + monthsGranted: 1, + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + }, + ], + }); + }); + + it('returns an empty referral reward summary when no applications belong to the subscription owner', async () => { + const user = await insertTestUser({ + google_user_email: `kiloclaw-empty-reward-status-${Math.random()}@example.com`, + }); + const otherUser = await insertTestUser({ + google_user_email: `kiloclaw-other-reward-status-${Math.random()}@example.com`, + }); + const { subscriptionId, instanceId } = await insertActivePersonalSubscription(user.id); + await insertAppliedReferralReward({ + beneficiaryUserId: otherUser.id, + subscriptionId, + role: 'referrer', + sourcePaymentId: `kiloclaw-subscription:${instanceId}:other-user`, + }); + + const billing = await createCaller({ user }).getActivePersonalBillingStatus(); + + expect(billing.subscription?.referralRewards).toEqual({ + totalAppliedMonths: 0, + applications: [], + }); + }); + + it('returns rewards for an explicitly viewed user-owned subscription', async () => { + const user = await insertTestUser({ + google_user_email: `kiloclaw-detail-reward-status-${Math.random()}@example.com`, + }); + const { subscriptionId, instanceId } = await insertActivePersonalSubscription(user.id); + await insertAppliedReferralReward({ + beneficiaryUserId: user.id, + subscriptionId, + role: 'referee', + sourcePaymentId: `kiloclaw-subscription:${instanceId}:detail`, + }); + + const detail = await createCaller({ user }).getSubscriptionDetail({ instanceId }); + + expect(detail.referralRewards).toEqual({ + totalAppliedMonths: 1, + applications: [ + { + role: 'referee', + appliedAt: '2026-04-10T00:05:00.000Z', + monthsGranted: 1, + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + }, + ], + }); + }); +}); + +describe('kiloclawRouter getReferralRewardSummary', () => { + beforeEach(async () => { + await cleanupDbForTest(); + }); + + async function insertRewardSummaryReward(params: { + userId: string; + role: 'referrer' | 'referee'; + status: 'pending' | 'applied'; + sourcePaymentId: string; + }) { + const otherUser = await insertTestUser({ + google_user_email: `kiloclaw-summary-other-${Math.random()}@example.com`, + }); + const [conversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: params.role === 'referee' ? params.userId : otherUser.id, + referrer_user_id: params.role === 'referrer' ? params.userId : otherUser.id, + winning_touch_type: 'referral', + source_payment_id: params.sourcePaymentId, + qualified: true, + converted_at: '2026-04-10T00:00:00.000Z', + }) + .returning({ id: kiloclaw_referral_conversions.id }); + const [decision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values({ + conversion_id: conversion.id, + beneficiary_user_id: params.userId, + beneficiary_role: params.role, + outcome: 'granted', + months_granted: 1, + }) + .returning({ id: kiloclaw_referral_reward_decisions.id }); + const [reward] = await db + .insert(kiloclaw_referral_rewards) + .values({ + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: params.userId, + beneficiary_role: params.role, + months_granted: 1, + status: params.status, + earned_at: '2026-04-10T00:00:00.000Z', + applied_at: params.status === 'applied' ? '2026-04-10T00:05:00.000Z' : null, + }) + .returning({ id: kiloclaw_referral_rewards.id }); + + if (params.status === 'applied') { + await db.insert(kiloclaw_referral_reward_applications).values({ + reward_id: reward.id, + beneficiary_user_id: params.userId, + subscription_id: crypto.randomUUID(), + previous_renewal_boundary: '2026-05-01T00:00:00.000Z', + new_renewal_boundary: '2026-06-01T00:00:00.000Z', + applied_at: '2026-04-10T00:05:00.000Z', + }); + } + } + + async function insertReferralRelationship(params: { + referrerId: string; + refereeEmail: string; + sourcePaymentId?: string; + qualified?: boolean; + disqualificationReason?: string | null; + }) { + const referee = await insertTestUser({ + google_user_email: params.refereeEmail, + normalized_email: params.refereeEmail, + }); + const [touch] = await db + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: `summary-relationship-touch-${params.refereeEmail}`, + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'private-cookie-value', + tracking_value_length: 20, + is_tracking_value_accepted: true, + rs_code: 'RS-CUSTOMER', + im_ref: 'private-impact-click', + touched_at: '2026-04-01T00:00:00.000Z', + expires_at: '2026-05-01T00:00:00.000Z', + }) + .returning({ id: kiloclaw_attribution_touches.id }); + await db.insert(kiloclaw_referrals).values({ + referee_user_id: referee.id, + referrer_user_id: params.referrerId, + source_touch_id: touch.id, + impact_referral_id: 'RS-CUSTOMER', + }); + + if (params.sourcePaymentId) { + await db.insert(kiloclaw_referral_conversions).values({ + referee_user_id: referee.id, + referrer_user_id: params.referrerId, + source_touch_id: touch.id, + winning_touch_type: 'referral', + source_payment_id: params.sourcePaymentId, + qualified: params.qualified ?? true, + disqualification_reason: params.disqualificationReason ?? null, + converted_at: '2026-04-10T00:00:00.000Z', + }); + } + } + + it('lists current-user rewards with status and application details', async () => { + const user = await insertTestUser({ + google_user_email: `kiloclaw-summary-${Math.random()}@example.com`, + }); + const otherUser = await insertTestUser({ + google_user_email: `kiloclaw-summary-hidden-${Math.random()}@example.com`, + }); + await insertRewardSummaryReward({ + userId: user.id, + role: 'referrer', + status: 'applied', + sourcePaymentId: 'summary-applied', + }); + await insertRewardSummaryReward({ + userId: user.id, + role: 'referee', + status: 'pending', + sourcePaymentId: 'summary-pending', + }); + await insertRewardSummaryReward({ + userId: otherUser.id, + role: 'referrer', + status: 'applied', + sourcePaymentId: 'summary-other', + }); + + const summary = await createCaller({ user }).getReferralRewardSummary(); + + expect(summary.totals).toEqual({ + totalRewards: 2, + pendingRewards: 1, + totalAppliedMonths: 1, + }); + expect(summary.rewards).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: 'referrer', + status: 'applied', + monthsGranted: 1, + earnedAt: '2026-04-10T00:00:00.000Z', + appliedAt: '2026-04-10T00:05:00.000Z', + application: expect.objectContaining({ + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + }), + }), + expect.objectContaining({ + role: 'referee', + status: 'pending', + monthsGranted: 1, + application: null, + }), + ]) + ); + }); + + it('returns customer-safe referred people and pending reward CTA state', async () => { + const user = await insertTestUser({ + google_user_email: `kiloclaw-summary-referrer-${Math.random()}@example.com`, + }); + await insertRewardSummaryReward({ + userId: user.id, + role: 'referrer', + status: 'pending', + sourcePaymentId: 'summary-pending-cta', + }); + await insertReferralRelationship({ + referrerId: user.id, + refereeEmail: 'qualified-referee@example.com', + sourcePaymentId: 'summary-qualified-referee', + qualified: true, + }); + await insertReferralRelationship({ + referrerId: user.id, + refereeEmail: 'signed-up-referee@example.com', + }); + await insertReferralRelationship({ + referrerId: user.id, + refereeEmail: 'disqualified-referee@example.com', + sourcePaymentId: 'summary-disqualified-referee', + qualified: false, + disqualificationReason: 'referral_self_referral', + }); + + const summary = await createCaller({ user }).getReferralRewardSummary(); + + expect(summary.pendingRewardAction).toEqual({ + showStartReactivateCta: true, + pendingRewardCount: 1, + }); + expect(summary.referredPeople).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + maskedEmail: 'q***@example.com', + state: 'reward_granted', + rewardGranted: true, + }), + expect.objectContaining({ + maskedEmail: 's***@example.com', + state: 'waiting_for_paid_conversion', + rewardGranted: false, + }), + ]) + ); + expect(summary.referredPeople).toHaveLength(2); + expect(JSON.stringify(summary.referredPeople)).not.toContain('qualified-referee@example.com'); + expect(JSON.stringify(summary.referredPeople)).not.toContain('private-cookie-value'); + expect(JSON.stringify(summary.referredPeople)).not.toContain('private-impact-click'); + expect(JSON.stringify(summary.referredPeople)).not.toContain('referral_self_referral'); + }); +}); + describe('kiloclawRouter destroy', () => { beforeEach(async () => { await cleanupDbForTest(); diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index d8588670e0..7e0dc12e14 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -27,6 +27,10 @@ import { kiloclaw_earlybird_purchases, kiloclaw_subscriptions, kiloclaw_instances, + kiloclaw_referrals, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_rewards, kiloclaw_email_log, kiloclaw_cli_runs, kiloclaw_scheduled_actions, @@ -38,7 +42,7 @@ import { credit_transactions, organizations, } from '@kilocode/db/schema'; -import { and, eq, ne, asc, desc, isNull, inArray, sql, like, or } from 'drizzle-orm'; +import { and, asc, eq, ne, desc, isNull, inArray, sql, like, or } from 'drizzle-orm'; import { alias } from 'drizzle-orm/pg-core'; import { deleteWorkerTrigger } from '@/lib/webhook-agent/webhook-agent-client'; import { sentryLogger } from '@/lib/utils.server'; @@ -1017,6 +1021,31 @@ function createNoInstanceStatus(userId: string, workerUrl: string): KiloClawDash } satisfies KiloClawDashboardStatus; } +function isFakeSeedInstance(instance: ActiveKiloClawInstance): boolean { + return instance.sandboxId.startsWith('ki_fake_'); +} + +function createFakeSeedInstanceStatus( + instance: ActiveKiloClawInstance, + workerUrl: string +): KiloClawDashboardStatus { + return { + ...createNoInstanceStatus(instance.userId, workerUrl), + sandboxId: instance.sandboxId, + provider: 'docker-local', + runtimeId: instance.sandboxId, + storageId: instance.sandboxId, + region: 'local', + status: 'stopped', + provisionedAt: Date.now(), + trackedImageTag: 'fake-local-instance', + workerUrl, + name: instance.name ?? null, + instanceId: instance.id, + inboundEmailEnabled: instance.inboundEmailEnabled, + } satisfies KiloClawDashboardStatus; +} + function sanitizeKiloCodeConfigResponse( response: KiloCodeConfigResponse ): KiloCodeConfigPublicResponse { @@ -1376,6 +1405,28 @@ const KiloclawInstanceSwitchPlanInputSchema = z.object({ toPlan: z.enum(['commit', 'standard']), }); const KiloclawActivationStateSchema = z.enum(['pending_settlement', 'activated']); +const KiloclawReferralRewardRoleSchema = z.enum(['referrer', 'referee']); +const KiloclawReferralRewardStatusSchema = z.enum([ + 'pending', + 'earned', + 'applied', + 'expired', + 'canceled', + 'reversed', + 'review_required', +]); +const KiloclawSubscriptionReferralRewardsSchema = z.object({ + totalAppliedMonths: z.number(), + applications: z.array( + z.object({ + role: KiloclawReferralRewardRoleSchema, + appliedAt: z.string(), + monthsGranted: z.number(), + previousRenewalBoundary: z.string(), + newRenewalBoundary: z.string(), + }) + ), +}); const KiloclawPersonalSubscriptionSchema = z.object({ instanceId: z.string().uuid(), @@ -1401,10 +1452,49 @@ const KiloclawPersonalSubscriptionSchema = z.object({ hasStripeFunding: z.boolean(), renewalCostMicrodollars: z.number().nullable(), showConversionPrompt: z.boolean(), + referralRewards: KiloclawSubscriptionReferralRewardsSchema, }); const KiloclawPersonalSubscriptionsOutputSchema = z.object({ subscriptions: z.array(KiloclawPersonalSubscriptionSchema), }); +const KiloclawReferredPersonStateSchema = z.enum(['reward_granted', 'waiting_for_paid_conversion']); +const KiloclawReferralRewardSummarySchema = z.object({ + totals: z.object({ + totalRewards: z.number(), + pendingRewards: z.number(), + totalAppliedMonths: z.number(), + }), + pendingRewardAction: z.object({ + showStartReactivateCta: z.boolean(), + pendingRewardCount: z.number(), + }), + referredPeople: z.array( + z.object({ + maskedEmail: z.string().nullable(), + state: KiloclawReferredPersonStateSchema, + rewardGranted: z.boolean(), + }) + ), + rewards: z.array( + z.object({ + role: KiloclawReferralRewardRoleSchema, + status: KiloclawReferralRewardStatusSchema, + monthsGranted: z.number(), + earnedAt: z.string(), + appliedAt: z.string().nullable(), + expiresAt: z.string().nullable(), + reviewReason: z.string().nullable(), + application: z + .object({ + appliedAt: z.string(), + subscriptionId: z.string().uuid().nullable(), + previousRenewalBoundary: z.string(), + newRenewalBoundary: z.string(), + }) + .nullable(), + }) + ), +}); const KiloclawBillingHistoryInputSchema = KiloclawInstanceInputSchema.extend({ cursor: z.string().optional(), @@ -1415,6 +1505,11 @@ const KiloclawCustomerPortalInputSchema = KiloclawInstanceInputSchema.extend({ }); const KiloclawMutationResultSchema = z.object({ success: z.boolean() }); +type KiloclawSubscriptionReferralRewards = z.infer< + typeof KiloclawSubscriptionReferralRewardsSchema +>; +type KiloclawReferralRewardSummary = z.infer; + type KiloclawPersonalSubscriptionRow = { subscription: typeof kiloclaw_subscriptions.$inferSelect; instance: { @@ -1494,6 +1589,12 @@ async function getPersonalBillingStatus(user: { hasPaidSubscription && (sub.plan === 'standard' || sub.plan === 'commit') ? KILOCLAW_PLAN_COST_MICRODOLLARS[sub.plan] : null; + const referralRewards = hasPaidSubscription + ? await getAppliedReferralRewardsForSubscription({ + userId: user.id, + subscriptionId: sub.id, + }) + : null; const subscriptionData = hasPaidSubscription ? { @@ -1511,6 +1612,7 @@ async function getPersonalBillingStatus(user: { renewalCostMicrodollars, showConversionPrompt, pendingConversion: sub.pending_conversion ?? false, + referralRewards: referralRewards ?? { totalAppliedMonths: 0, applications: [] }, } : null; @@ -1607,6 +1709,19 @@ async function getPersonalBillingStatus(user: { } satisfies ClawBillingStatus; } +function maskCustomerEmail(email: string | null): string | null { + if (!email) return null; + const [localPart, domain] = email.toLowerCase().split('@'); + if (!localPart || !domain) return null; + return `${localPart.slice(0, 1)}***@${domain}`; +} + +function referredPersonState( + qualified: boolean | null +): 'reward_granted' | 'waiting_for_paid_conversion' { + return qualified === true ? 'reward_granted' : 'waiting_for_paid_conversion'; +} + function summarizePersonalBillingStatus(billing: ClawBillingStatus) { const hasActiveInstance = billing.instance?.exists ?? false; const activeInstanceId = hasActiveInstance ? (billing.instance?.id ?? null) : null; @@ -1622,12 +1737,170 @@ function summarizePersonalBillingStatus(billing: ClawBillingStatus) { }; } -function serializeKiloclawPersonalSubscription( +async function hasEligiblePersonalSubscriptionForReferralReward(userId: string): Promise { + let currentRow: Awaited>; + try { + currentRow = await resolveCurrentPersonalSubscriptionRow({ userId, dbOrTx: db }); + } catch (error) { + mapCurrentSubscriptionResolutionError(error); + } + const subscription = currentRow?.subscription; + if (!subscription) return false; + + return ( + subscription.plan !== 'trial' && + subscription.status === 'active' && + !subscription.cancel_at_period_end && + subscription.suspended_at === null && + subscription.past_due_since === null + ); +} + +async function getCustomerReferralRewardSummary( + userId: string +): Promise { + const rows = await db + .select({ + role: kiloclaw_referral_rewards.beneficiary_role, + status: kiloclaw_referral_rewards.status, + monthsGranted: kiloclaw_referral_rewards.months_granted, + earnedAt: kiloclaw_referral_rewards.earned_at, + appliedAt: kiloclaw_referral_rewards.applied_at, + expiresAt: kiloclaw_referral_rewards.expires_at, + reviewReason: kiloclaw_referral_rewards.review_reason, + applicationAppliedAt: kiloclaw_referral_reward_applications.applied_at, + applicationSubscriptionId: kiloclaw_referral_reward_applications.subscription_id, + previousRenewalBoundary: kiloclaw_referral_reward_applications.previous_renewal_boundary, + newRenewalBoundary: kiloclaw_referral_reward_applications.new_renewal_boundary, + }) + .from(kiloclaw_referral_rewards) + .leftJoin( + kiloclaw_referral_reward_applications, + eq(kiloclaw_referral_reward_applications.reward_id, kiloclaw_referral_rewards.id) + ) + .where(eq(kiloclaw_referral_rewards.beneficiary_user_id, userId)) + .orderBy(desc(kiloclaw_referral_rewards.earned_at), desc(kiloclaw_referral_rewards.created_at)); + + const rewards = rows.map(row => ({ + role: row.role, + status: row.status, + monthsGranted: row.monthsGranted, + earnedAt: normalizeTimestamp(row.earnedAt) ?? row.earnedAt, + appliedAt: normalizeTimestamp(row.appliedAt), + expiresAt: normalizeTimestamp(row.expiresAt), + reviewReason: row.reviewReason, + application: + row.applicationAppliedAt && row.previousRenewalBoundary && row.newRenewalBoundary + ? { + appliedAt: normalizeTimestamp(row.applicationAppliedAt) ?? row.applicationAppliedAt, + subscriptionId: row.applicationSubscriptionId, + previousRenewalBoundary: + normalizeTimestamp(row.previousRenewalBoundary) ?? row.previousRenewalBoundary, + newRenewalBoundary: + normalizeTimestamp(row.newRenewalBoundary) ?? row.newRenewalBoundary, + } + : null, + })); + + const referredRows = await db + .select({ + refereeEmail: kilocode_users.google_user_email, + qualified: kiloclaw_referral_conversions.qualified, + }) + .from(kiloclaw_referrals) + .innerJoin(kilocode_users, eq(kilocode_users.id, kiloclaw_referrals.referee_user_id)) + .leftJoin( + kiloclaw_referral_conversions, + and( + eq(kiloclaw_referral_conversions.referee_user_id, kiloclaw_referrals.referee_user_id), + eq(kiloclaw_referral_conversions.referrer_user_id, userId) + ) + ) + .where(eq(kiloclaw_referrals.referrer_user_id, userId)) + .orderBy(desc(kiloclaw_referrals.created_at)); + const referredPeople = referredRows + .filter(row => row.qualified !== false) + .map(row => ({ + maskedEmail: maskCustomerEmail(row.refereeEmail), + state: referredPersonState(row.qualified), + rewardGranted: row.qualified === true, + })); + const pendingRewardCount = rewards.filter( + reward => reward.role === 'referrer' && reward.status === 'pending' + ).length; + const hasEligibleSubscription = await hasEligiblePersonalSubscriptionForReferralReward(userId); + + return { + totals: { + totalRewards: rewards.length, + pendingRewards: rewards.filter(reward => reward.status === 'pending').length, + totalAppliedMonths: rewards + .filter(reward => reward.status === 'applied') + .reduce((total, reward) => total + reward.monthsGranted, 0), + }, + pendingRewardAction: { + showStartReactivateCta: pendingRewardCount > 0 && !hasEligibleSubscription, + pendingRewardCount, + }, + referredPeople, + rewards, + }; +} + +async function getAppliedReferralRewardsForSubscription(params: { + userId: string; + subscriptionId: string; +}): Promise { + const rows = await db + .select({ + role: kiloclaw_referral_rewards.beneficiary_role, + appliedAt: kiloclaw_referral_reward_applications.applied_at, + monthsGranted: kiloclaw_referral_rewards.months_granted, + previousRenewalBoundary: kiloclaw_referral_reward_applications.previous_renewal_boundary, + newRenewalBoundary: kiloclaw_referral_reward_applications.new_renewal_boundary, + }) + .from(kiloclaw_referral_reward_applications) + .innerJoin( + kiloclaw_referral_rewards, + eq(kiloclaw_referral_rewards.id, kiloclaw_referral_reward_applications.reward_id) + ) + .where( + and( + eq(kiloclaw_referral_reward_applications.subscription_id, params.subscriptionId), + eq(kiloclaw_referral_reward_applications.beneficiary_user_id, params.userId), + eq(kiloclaw_referral_rewards.applies_to_subscription_id, params.subscriptionId), + eq(kiloclaw_referral_rewards.beneficiary_user_id, params.userId), + eq(kiloclaw_referral_rewards.status, 'applied') + ) + ) + .orderBy( + asc(kiloclaw_referral_reward_applications.applied_at), + asc(kiloclaw_referral_reward_applications.created_at) + ); + + return { + totalAppliedMonths: rows.reduce((total, row) => total + row.monthsGranted, 0), + applications: rows.map(row => ({ + role: row.role, + appliedAt: normalizeTimestamp(row.appliedAt) ?? row.appliedAt, + monthsGranted: row.monthsGranted, + previousRenewalBoundary: + normalizeTimestamp(row.previousRenewalBoundary) ?? row.previousRenewalBoundary, + newRenewalBoundary: normalizeTimestamp(row.newRenewalBoundary) ?? row.newRenewalBoundary, + })), + }; +} + +async function serializeKiloclawPersonalSubscription( row: KiloclawPersonalSubscriptionRow, hasActiveKiloPass: boolean ) { const hasStripeFunding = Boolean(row.subscription.stripe_subscription_id); const activationState = getKiloClawSubscriptionActivationState(row.subscription); + const referralRewards = await getAppliedReferralRewardsForSubscription({ + userId: row.subscription.user_id, + subscriptionId: row.subscription.id, + }); return { instanceId: row.instance.id, @@ -1653,6 +1926,7 @@ function serializeKiloclawPersonalSubscription( hasStripeFunding, renewalCostMicrodollars: getKiloclawRenewalCostMicrodollars(row.subscription.plan), showConversionPrompt: hasStripeFunding && hasActiveKiloPass, + referralRewards, }; } @@ -2456,6 +2730,10 @@ export const kiloclawRouter = createTRPCRouter({ return createNoInstanceStatus(ctx.user.id, legacyWorkerUrl); } + if (isFakeSeedInstance(instance)) { + return createFakeSeedInstanceStatus(instance, workerUrl); + } + const client = new KiloClawInternalClient(); const [status, inboundEmailAddress, scheduledAction] = await Promise.all([ client.getStatus(ctx.user.id, workerInstanceId(instance)), @@ -3735,6 +4013,12 @@ export const kiloclawRouter = createTRPCRouter({ return summarizePersonalBillingStatus(billing); }), + getReferralRewardSummary: baseProcedure + .output(KiloclawReferralRewardSummarySchema) + .query(async ({ ctx }) => { + return await getCustomerReferralRewardSummary(ctx.user.id); + }), + // ── Personal subscription management ───────────────────────────────── listPersonalSubscriptions: baseProcedure @@ -3746,8 +4030,8 @@ export const kiloclawRouter = createTRPCRouter({ ]); return { - subscriptions: rows.map(row => - serializeKiloclawPersonalSubscription(row, hasActiveKiloPass) + subscriptions: await Promise.all( + rows.map(row => serializeKiloclawPersonalSubscription(row, hasActiveKiloPass)) ), }; }), @@ -3761,7 +4045,7 @@ export const kiloclawRouter = createTRPCRouter({ getHasActiveKiloPassForUser(ctx.user.id), ]); - return serializeKiloclawPersonalSubscription(row, hasActiveKiloPass); + return await serializeKiloclawPersonalSubscription(row, hasActiveKiloPass); }), getBillingHistory: baseProcedure diff --git a/services/kiloclaw-billing/src/lifecycle.ts b/services/kiloclaw-billing/src/lifecycle.ts index 05858cfa40..61a2ea902d 100644 --- a/services/kiloclaw-billing/src/lifecycle.ts +++ b/services/kiloclaw-billing/src/lifecycle.ts @@ -1359,13 +1359,13 @@ async function processCreditRenewalRow( itemCategory: getKiloClawAffiliateItemCategory(effectivePlan), itemName: getKiloClawAffiliateItemName(effectivePlan), itemSku: getKiloClawAffiliateItemSku(env, effectivePlan), - }).catch(error => { - log('warn', 'Affiliate sale enqueue recovery failed during duplicate credit renewal', { - userId, - error: error instanceof Error ? error.message : String(error), - }); }); + await database + .update(kiloclaw_subscriptions) + .set({ credit_renewal_at: newPeriodEnd }) + .where(eq(kiloclaw_subscriptions.id, row.id)); + summary.credit_renewals_skipped_duplicate++; return; } @@ -1402,22 +1402,25 @@ async function processCreditRenewalRow( }); } - await processPaidConversion(env, context, { - userId, - dedupeKey: `affiliate:impact:sale:${deductionCategory}`, - eventDateIso: renewalAt, - orderId: deductionCategory, - amount: costMicrodollars / 1_000_000, - currencyCode: 'usd', - itemCategory: getKiloClawAffiliateItemCategory(effectivePlan), - itemName: getKiloClawAffiliateItemName(effectivePlan), - itemSku: getKiloClawAffiliateItemSku(env, effectivePlan), - }).catch(error => { - log('warn', 'Affiliate sale enqueue failed during credit renewal', { + try { + await processPaidConversion(env, context, { userId, - error: error instanceof Error ? error.message : String(error), + dedupeKey: `affiliate:impact:sale:${deductionCategory}`, + eventDateIso: renewalAt, + orderId: deductionCategory, + amount: costMicrodollars / 1_000_000, + currencyCode: 'usd', + itemCategory: getKiloClawAffiliateItemCategory(effectivePlan), + itemName: getKiloClawAffiliateItemName(effectivePlan), + itemSku: getKiloClawAffiliateItemSku(env, effectivePlan), }); - }); + } catch (error) { + await database + .update(kiloclaw_subscriptions) + .set({ credit_renewal_at: renewalAt }) + .where(eq(kiloclaw_subscriptions.id, row.id)); + throw error; + } summary.credit_renewals++; return; From 5440c6802ad8f765a2755ea752d7ef18f210b39e Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 10:42:35 +0200 Subject: [PATCH 12/32] feat(admin): KiloClaw referrals investigation panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an internal admin surface for inspecting referral attribution and reward state. Surfaces the touch chain, referral records, reward decisions, and Advocate registration status keyed by user, tracking ID, or referral code — what we'll need when debugging missing rewards or duplicate Advocate registrations. - /admin/kiloclaw-referrals page + KiloclawReferralsInvestigation component (read-only). - adminKiloclawReferralsRouter: typed tRPC procedures over the same data the user-facing reward summary consumes, with admin-only guards inherited from adminProcedure. - Wire the router into adminRouter and add a sidebar entry. --- .../src/app/admin/components/AppSidebar.tsx | 5 + .../KiloclawReferralsInvestigation.test.ts | 123 ++++++ .../KiloclawReferralsInvestigation.tsx | 295 ++++++++++++++ .../src/app/admin/kiloclaw-referrals/page.tsx | 5 + apps/web/src/routers/admin-router.ts | 2 + .../admin/kiloclaw-referrals-router.test.ts | 204 ++++++++++ .../admin/kiloclaw-referrals-router.ts | 371 ++++++++++++++++++ 7 files changed, 1005 insertions(+) create mode 100644 apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts create mode 100644 apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx create mode 100644 apps/web/src/app/admin/kiloclaw-referrals/page.tsx create mode 100644 apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts create mode 100644 apps/web/src/routers/admin/kiloclaw-referrals-router.ts diff --git a/apps/web/src/app/admin/components/AppSidebar.tsx b/apps/web/src/app/admin/components/AppSidebar.tsx index 049310e158..8a6faaf748 100644 --- a/apps/web/src/app/admin/components/AppSidebar.tsx +++ b/apps/web/src/app/admin/components/AppSidebar.tsx @@ -122,6 +122,11 @@ const productEngineeringItems: MenuItem[] = [ url: '/admin/kiloclaw', icon: () => , }, + { + title: () => 'KiloClaw referrals', + url: '/admin/kiloclaw-referrals', + icon: () => , + }, { title: () => 'Community PRs', url: '/admin/community-prs', diff --git a/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts new file mode 100644 index 0000000000..4f106d37a9 --- /dev/null +++ b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts @@ -0,0 +1,123 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from '@jest/globals'; + +import { KiloclawReferralsInvestigationResults } from './KiloclawReferralsInvestigation'; + +const result = { + referrer: { id: 'referrer-1', email: 'referrer@example.com', name: 'Referrer' }, + referrals: [ + { + referral: { + id: '11111111-1111-4111-8111-111111111111', + impactReferralId: 'RS-SUPPORT', + createdAt: '2026-04-01T00:00:00.000Z', + }, + referee: { id: 'referee-1', email: 'qualified@example.com', name: null }, + sourceTouch: null, + conversion: { + id: '22222222-2222-4222-8222-222222222222', + winningTouchType: 'referral', + sourcePaymentId: 'qualified-payment', + qualified: true, + disqualificationReason: null, + convertedAt: '2026-04-10T00:00:00.000Z', + }, + rewardDecisions: [ + { + id: '33333333-3333-4333-8333-333333333333', + beneficiaryUserId: 'referrer-1', + beneficiaryRole: 'referrer', + outcome: 'granted', + reason: null, + monthsGranted: 1, + createdAt: '2026-04-10T00:00:00.000Z', + }, + ], + rewards: [], + rewardApplications: [ + { + id: '44444444-4444-4444-8444-444444444444', + beneficiaryUserId: 'referrer-1', + subscriptionId: '55555555-5555-4555-8555-555555555555', + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + appliedAt: '2026-04-10T00:05:00.000Z', + }, + ], + impactReports: [ + { + id: '66666666-6666-4666-8666-666666666666', + state: 'delivered', + actionTrackerId: 71659, + orderId: 'qualified-payment', + deliveredAt: '2026-04-10T00:06:00.000Z', + nextRetryAt: null, + responseStatusCode: null, + }, + ], + }, + { + referral: { + id: '77777777-7777-4777-8777-777777777777', + impactReferralId: 'RS-SUPPORT', + createdAt: '2026-04-02T00:00:00.000Z', + }, + referee: { id: 'referee-2', email: 'disqualified@example.com', name: null }, + sourceTouch: null, + conversion: { + id: '88888888-8888-4888-8888-888888888888', + winningTouchType: 'referral', + sourcePaymentId: 'disqualified-payment', + qualified: false, + disqualificationReason: 'referral_self_referral', + convertedAt: '2026-04-10T00:00:00.000Z', + }, + rewardDecisions: [ + { + id: '99999999-9999-4999-8999-999999999999', + beneficiaryUserId: 'referrer-1', + beneficiaryRole: 'referrer', + outcome: 'disqualified', + reason: 'referral_self_referral', + monthsGranted: 0, + createdAt: '2026-04-10T00:00:00.000Z', + }, + ], + rewards: [], + rewardApplications: [], + impactReports: [ + { + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + state: 'failed', + actionTrackerId: 71659, + orderId: 'disqualified-payment', + deliveredAt: null, + nextRetryAt: null, + responseStatusCode: 400, + }, + ], + }, + ], +}; + +describe('KiloclawReferralsInvestigationResults', () => { + it('renders qualified and disqualified referee diagnostics', () => { + const html = renderToStaticMarkup( + React.createElement(KiloclawReferralsInvestigationResults, { result }) + ); + + expect(html).toContain('referrer@example.com'); + expect(html).toContain('qualified@example.com'); + expect(html).toContain('disqualified@example.com'); + expect(html).toContain('Qualified'); + expect(html).toContain('Disqualified'); + expect(html).toContain('referral_self_referral'); + expect(html).toContain('granted'); + expect(html).toContain('disqualified'); + expect(html).toContain('delivered'); + expect(html).toContain('failed'); + expect(html).toContain('May 1, 2026 to'); + expect(html).toContain('June 1, 2026'); + }); +}); diff --git a/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx new file mode 100644 index 0000000000..6abb2b5521 --- /dev/null +++ b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx @@ -0,0 +1,295 @@ +'use client'; + +import React, { useState } from 'react'; +import { Search } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useTRPC } from '@/lib/trpc/utils'; + +type InvestigationResult = { + referrer: { id: string; email: string | null; name: string | null }; + referrals: Array<{ + referral: { id: string; impactReferralId: string | null; createdAt: string }; + referee: { id: string; email: string | null; name: string | null }; + sourceTouch: { + id: string; + provider: string | null; + touchType: string | null; + landingPath: string | null; + rsCode: string | null; + imRef: string | null; + touchedAt: string | null; + expiresAt: string | null; + } | null; + conversion: { + id: string; + winningTouchType: string; + sourcePaymentId: string; + qualified: boolean; + disqualificationReason: string | null; + convertedAt: string; + } | null; + rewardDecisions: Array<{ + id: string; + beneficiaryUserId: string; + beneficiaryRole: string; + outcome: string; + reason: string | null; + monthsGranted: number; + createdAt: string; + }>; + rewards: Array<{ + id: string; + beneficiaryUserId: string; + beneficiaryRole: string; + status: string; + monthsGranted: number; + earnedAt: string; + appliedAt: string | null; + expiresAt: string | null; + reviewReason: string | null; + }>; + rewardApplications: Array<{ + id: string; + beneficiaryUserId: string; + subscriptionId: string | null; + previousRenewalBoundary: string; + newRenewalBoundary: string; + appliedAt: string; + }>; + impactReports: Array<{ + id: string; + state: string; + actionTrackerId: number; + orderId: string; + deliveredAt: string | null; + nextRetryAt: string | null; + responseStatusCode: number | null; + }>; + }>; +}; + +type ResultsProps = { + result: InvestigationResult; +}; + +function formatDate(value: string | null): string { + if (!value) return '—'; + return new Date(value).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); +} + +function outcomeLabel(qualified: boolean): string { + return qualified ? 'Qualified' : 'Disqualified'; +} + +export function KiloclawReferralsInvestigationResults({ result }: ResultsProps) { + return ( +
+ + + Referrer + + Support investigation details for this KiloClaw referrer. + + + + + + + + + + + + Referees + + Includes qualified and disqualified referrals, reward decisions, applications, and + Impact report state. + + + + {result.referrals.length === 0 ? ( +
+ No referees found for this referrer. +
+ ) : ( + result.referrals.map(row => ) + )} +
+
+
+ ); +} + +function ReferralDiagnosticsRow({ row }: { row: InvestigationResult['referrals'][number] }) { + const conversion = row.conversion; + return ( +
+
+
+
{row.referee.email ?? row.referee.id}
+
{row.referee.id}
+
+ {conversion ? ( + + {outcomeLabel(conversion.qualified)} + + ) : null} +
+ +
+
+

+ Conversion +

+ {conversion ? ( +
+ + + + +
+ ) : ( +
No conversion recorded.
+ )} +
+ +
+

+ Reward decisions +

+ {row.rewardDecisions.length === 0 ? ( +
No reward decisions.
+ ) : ( +
+ {row.rewardDecisions.map(decision => ( +
+ {decision.beneficiaryRole}: {decision.outcome}, {decision.monthsGranted} month + {decision.monthsGranted === 1 ? '' : 's'} + {decision.reason ? ` (${decision.reason})` : ''} +
+ ))} +
+ )} +
+
+ +
+
+

+ Reward applications +

+ {row.rewardApplications.length === 0 ? ( +
No reward applications.
+ ) : ( + row.rewardApplications.map(application => ( +
+ {formatDate(application.previousRenewalBoundary)} to{' '} + {formatDate(application.newRenewalBoundary)} +
+ )) + )} +
+ +
+

+ Impact reports +

+ {row.impactReports.length === 0 ? ( +
No Impact reports.
+ ) : ( + row.impactReports.map(report => ( +
+ {report.state}, tracker {report.actionTrackerId}, order {report.orderId} + {report.responseStatusCode ? `, HTTP ${report.responseStatusCode}` : ''} +
+ )) + )} +
+
+
+ ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function KiloclawReferralsInvestigation() { + const trpc = useTRPC(); + const [search, setSearch] = useState(''); + const [submittedSearch, setSubmittedSearch] = useState(null); + const query = useQuery( + trpc.admin.kiloclawReferrals.investigateReferrer.queryOptions( + { search: submittedSearch ?? '' }, + { enabled: submittedSearch !== null } + ) + ); + + return ( +
+ + + KiloClaw referral investigation + + Search by referrer user ID or email to inspect referee conversion and reward state. + + + +
{ + event.preventDefault(); + const trimmedSearch = search.trim(); + if (trimmedSearch) { + setSubmittedSearch(trimmedSearch); + } + }} + > +
+ + setSearch(event.target.value)} + placeholder="user_... or referrer@example.com" + /> +
+ +
+
+
+ + {query.isError ? ( + + + {query.error.message || 'Unable to load referral investigation.'} + + + ) : null} + {query.data ? : null} +
+ ); +} diff --git a/apps/web/src/app/admin/kiloclaw-referrals/page.tsx b/apps/web/src/app/admin/kiloclaw-referrals/page.tsx new file mode 100644 index 0000000000..24a09ff24b --- /dev/null +++ b/apps/web/src/app/admin/kiloclaw-referrals/page.tsx @@ -0,0 +1,5 @@ +import { KiloclawReferralsInvestigation } from '@/app/admin/components/KiloclawReferralsInvestigation'; + +export default function KiloclawReferralsPage() { + return ; +} diff --git a/apps/web/src/routers/admin-router.ts b/apps/web/src/routers/admin-router.ts index 41f4b18d45..d73515480f 100644 --- a/apps/web/src/routers/admin-router.ts +++ b/apps/web/src/routers/admin-router.ts @@ -43,6 +43,7 @@ import { adminGatewayConfigRouter } from '@/routers/admin/gateway-config-router' import { adminBlacklistDomainsRouter } from '@/routers/admin/blacklist-domains-router'; import { adminBulkBlockRouter } from '@/routers/admin/bulk-block-router'; import { adminKiloPassRouter } from '@/routers/admin/kilo-pass-router'; +import { adminKiloclawReferralsRouter } from '@/routers/admin/kiloclaw-referrals-router'; import { adminShellSecurityContentRouter } from '@/routers/admin/shell-security-content-router'; import { adminWebhookTriggersRouter } from '@/routers/admin-webhook-triggers-router'; import { adminAlertingRouter } from '@/routers/admin-alerting-router'; @@ -344,6 +345,7 @@ const CancelKiloClawSubscriptionSchema = z.object({ }); export const adminRouter = createTRPCRouter({ + kiloclawReferrals: adminKiloclawReferralsRouter, webhookTriggers: adminWebhookTriggersRouter, github: createTRPCRouter({ getKilocodeOpenPullRequestCounts: adminProcedure.query(async () => { diff --git a/apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts b/apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts new file mode 100644 index 0000000000..210db06bfc --- /dev/null +++ b/apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { eq } from 'drizzle-orm'; + +import { cleanupDbForTest, db } from '@/lib/drizzle'; +import { createCallerForUser } from '@/routers/test-utils'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { + impact_conversion_reports, + kiloclaw_attribution_touches, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referrals, + type User, +} from '@kilocode/db/schema'; + +let admin: User; +let nonAdmin: User; +let referrer: User; + +beforeEach(async () => { + await cleanupDbForTest(); + admin = await insertTestUser({ + google_user_email: `admin-referrals-${Math.random()}@admin.example.com`, + is_admin: true, + }); + nonAdmin = await insertTestUser({ + google_user_email: `not-admin-referrals-${Math.random()}@example.com`, + }); + referrer = await insertTestUser({ + google_user_email: `referrer-${Math.random()}@example.com`, + normalized_email: `referrer-${Math.random()}@example.com`, + }); +}); + +async function insertReferralInvestigationRow(params: { + refereeEmail: string; + sourcePaymentId: string; + qualified: boolean; + disqualificationReason: string | null; + reportState: 'delivered' | 'failed'; +}) { + const referee = await insertTestUser({ + google_user_email: params.refereeEmail, + normalized_email: params.refereeEmail, + }); + const [touch] = await db + .insert(kiloclaw_attribution_touches) + .values({ + dedupe_key: `touch-${params.sourcePaymentId}`, + user_id: referee.id, + touch_type: 'referral', + provider: 'impact_advocate', + opaque_tracking_value: 'opaque-support-only', + tracking_value_length: 19, + is_tracking_value_accepted: true, + rs_code: 'RS-SUPPORT', + touched_at: '2026-04-01T00:00:00.000Z', + expires_at: '2026-05-01T00:00:00.000Z', + }) + .returning({ id: kiloclaw_attribution_touches.id }); + await db.insert(kiloclaw_referrals).values({ + referee_user_id: referee.id, + referrer_user_id: referrer.id, + source_touch_id: touch.id, + impact_referral_id: 'RS-SUPPORT', + }); + const [conversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: referee.id, + referrer_user_id: referrer.id, + source_touch_id: touch.id, + winning_touch_type: 'referral', + source_payment_id: params.sourcePaymentId, + qualified: params.qualified, + disqualification_reason: params.disqualificationReason, + converted_at: '2026-04-10T00:00:00.000Z', + }) + .returning({ id: kiloclaw_referral_conversions.id }); + const [decision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values({ + conversion_id: conversion.id, + beneficiary_user_id: referrer.id, + beneficiary_role: 'referrer', + outcome: params.qualified ? 'granted' : 'disqualified', + reason: params.disqualificationReason, + months_granted: params.qualified ? 1 : 0, + }) + .returning({ id: kiloclaw_referral_reward_decisions.id }); + + if (params.qualified) { + const [reward] = await db + .insert(kiloclaw_referral_rewards) + .values({ + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: referrer.id, + beneficiary_role: 'referrer', + months_granted: 1, + status: 'applied', + earned_at: '2026-04-10T00:00:00.000Z', + applied_at: '2026-04-10T00:05:00.000Z', + }) + .returning({ id: kiloclaw_referral_rewards.id }); + await db.insert(kiloclaw_referral_reward_applications).values({ + reward_id: reward.id, + beneficiary_user_id: referrer.id, + subscription_id: crypto.randomUUID(), + previous_renewal_boundary: '2026-05-01T00:00:00.000Z', + new_renewal_boundary: '2026-06-01T00:00:00.000Z', + applied_at: '2026-04-10T00:05:00.000Z', + }); + } + + await db.insert(impact_conversion_reports).values({ + conversion_id: conversion.id, + dedupe_key: `impact-report-${params.sourcePaymentId}`, + action_tracker_id: 71659, + order_id: params.sourcePaymentId, + state: params.reportState, + request_payload: { orderId: params.sourcePaymentId }, + response_payload: { actionId: '1000.2000.3000' }, + }); + + return referee; +} + +describe('admin kiloclaw referrals investigation', () => { + it('rejects non-admin users', async () => { + const caller = await createCallerForUser(nonAdmin.id); + + await expect( + caller.admin.kiloclawReferrals.investigateReferrer({ search: referrer.id }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + + it('searches by referrer email and returns qualified and disqualified referee diagnostics', async () => { + const qualifiedReferee = await insertReferralInvestigationRow({ + refereeEmail: `qualified-referee-${Math.random()}@example.com`, + sourcePaymentId: 'qualified-payment', + qualified: true, + disqualificationReason: null, + reportState: 'delivered', + }); + const disqualifiedReferee = await insertReferralInvestigationRow({ + refereeEmail: `disqualified-referee-${Math.random()}@example.com`, + sourcePaymentId: 'disqualified-payment', + qualified: false, + disqualificationReason: 'referral_self_referral', + reportState: 'failed', + }); + + const caller = await createCallerForUser(admin.id); + const result = await caller.admin.kiloclawReferrals.investigateReferrer({ + search: referrer.google_user_email, + }); + + expect(result.referrer).toEqual( + expect.objectContaining({ id: referrer.id, email: referrer.google_user_email }) + ); + expect(result.referrals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + referee: expect.objectContaining({ + id: qualifiedReferee.id, + email: qualifiedReferee.google_user_email, + }), + conversion: expect.objectContaining({ qualified: true, disqualificationReason: null }), + rewardDecisions: [expect.objectContaining({ outcome: 'granted', monthsGranted: 1 })], + rewardApplications: [ + expect.objectContaining({ + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + }), + ], + impactReports: [expect.objectContaining({ state: 'delivered' })], + }), + expect.objectContaining({ + referee: expect.objectContaining({ + id: disqualifiedReferee.id, + email: disqualifiedReferee.google_user_email, + }), + conversion: expect.objectContaining({ + qualified: false, + disqualificationReason: 'referral_self_referral', + }), + rewardDecisions: [expect.objectContaining({ outcome: 'disqualified' })], + rewardApplications: [], + impactReports: [expect.objectContaining({ state: 'failed' })], + }), + ]) + ); + expect(result.referrals).toHaveLength(2); + + const reports = await db + .select() + .from(impact_conversion_reports) + .where(eq(impact_conversion_reports.state, 'failed')); + expect(reports).toHaveLength(1); + }); +}); diff --git a/apps/web/src/routers/admin/kiloclaw-referrals-router.ts b/apps/web/src/routers/admin/kiloclaw-referrals-router.ts new file mode 100644 index 0000000000..9ca622e4d4 --- /dev/null +++ b/apps/web/src/routers/admin/kiloclaw-referrals-router.ts @@ -0,0 +1,371 @@ +import * as z from 'zod'; +import { TRPCError } from '@trpc/server'; +import { desc, eq, inArray, or } from 'drizzle-orm'; + +import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import { db } from '@/lib/drizzle'; +import { + impact_conversion_reports, + kiloclaw_attribution_touches, + kiloclaw_referral_conversions, + kiloclaw_referral_reward_applications, + kiloclaw_referral_reward_decisions, + kiloclaw_referral_rewards, + kiloclaw_referrals, + kilocode_users, +} from '@kilocode/db/schema'; + +const ReferralInvestigationInputSchema = z.object({ + search: z.string().trim().min(1), +}); + +const NullableString = z.string().nullable(); + +const ReferralInvestigationOutputSchema = z.object({ + referrer: z.object({ + id: z.string(), + email: NullableString, + name: NullableString, + }), + referrals: z.array( + z.object({ + referral: z.object({ + id: z.string().uuid(), + impactReferralId: NullableString, + createdAt: z.string(), + }), + referee: z.object({ + id: z.string(), + email: NullableString, + name: NullableString, + }), + sourceTouch: z + .object({ + id: z.string().uuid(), + provider: NullableString, + touchType: NullableString, + landingPath: NullableString, + rsCode: NullableString, + imRef: NullableString, + touchedAt: NullableString, + expiresAt: NullableString, + }) + .nullable(), + conversion: z + .object({ + id: z.string().uuid(), + winningTouchType: z.string(), + sourcePaymentId: z.string(), + qualified: z.boolean(), + disqualificationReason: NullableString, + convertedAt: z.string(), + }) + .nullable(), + rewardDecisions: z.array( + z.object({ + id: z.string().uuid(), + beneficiaryUserId: z.string(), + beneficiaryRole: z.string(), + outcome: z.string(), + reason: NullableString, + monthsGranted: z.number(), + createdAt: z.string(), + }) + ), + rewards: z.array( + z.object({ + id: z.string().uuid(), + beneficiaryUserId: z.string(), + beneficiaryRole: z.string(), + status: z.string(), + monthsGranted: z.number(), + earnedAt: z.string(), + appliedAt: NullableString, + expiresAt: NullableString, + reviewReason: NullableString, + }) + ), + rewardApplications: z.array( + z.object({ + id: z.string().uuid(), + beneficiaryUserId: z.string(), + subscriptionId: z.string().uuid().nullable(), + previousRenewalBoundary: z.string(), + newRenewalBoundary: z.string(), + appliedAt: z.string(), + }) + ), + impactReports: z.array( + z.object({ + id: z.string().uuid(), + state: z.string(), + actionTrackerId: z.number(), + orderId: z.string(), + deliveredAt: NullableString, + nextRetryAt: NullableString, + responseStatusCode: z.number().nullable(), + }) + ), + }) + ), +}); + +type ReferralInvestigationOutput = z.infer; + +function normalizeTimestamp(value: string | null | undefined): string | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toISOString(); +} + +function listByConversionId( + rows: T[], + conversionId: string +): T[] { + return rows.filter(row => row.conversionId === conversionId); +} + +async function findReferrer(search: string) { + const normalizedSearch = search.trim().toLowerCase(); + const [referrer] = await db + .select({ + id: kilocode_users.id, + email: kilocode_users.google_user_email, + normalizedEmail: kilocode_users.normalized_email, + name: kilocode_users.google_user_name, + }) + .from(kilocode_users) + .where( + or( + eq(kilocode_users.id, search), + eq(kilocode_users.google_user_email, search), + eq(kilocode_users.normalized_email, normalizedSearch) + ) + ) + .limit(1); + + return referrer ?? null; +} + +async function investigateReferrer(search: string): Promise { + const referrer = await findReferrer(search); + if (!referrer) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Referrer not found.' }); + } + + const referralRows = await db + .select({ + referralId: kiloclaw_referrals.id, + impactReferralId: kiloclaw_referrals.impact_referral_id, + referralCreatedAt: kiloclaw_referrals.created_at, + refereeId: kilocode_users.id, + refereeEmail: kilocode_users.google_user_email, + refereeName: kilocode_users.google_user_name, + touchId: kiloclaw_attribution_touches.id, + touchProvider: kiloclaw_attribution_touches.provider, + touchType: kiloclaw_attribution_touches.touch_type, + landingPath: kiloclaw_attribution_touches.landing_path, + rsCode: kiloclaw_attribution_touches.rs_code, + imRef: kiloclaw_attribution_touches.im_ref, + touchedAt: kiloclaw_attribution_touches.touched_at, + expiresAt: kiloclaw_attribution_touches.expires_at, + }) + .from(kiloclaw_referrals) + .innerJoin(kilocode_users, eq(kilocode_users.id, kiloclaw_referrals.referee_user_id)) + .leftJoin( + kiloclaw_attribution_touches, + eq(kiloclaw_attribution_touches.id, kiloclaw_referrals.source_touch_id) + ) + .where(eq(kiloclaw_referrals.referrer_user_id, referrer.id)) + .orderBy(desc(kiloclaw_referrals.created_at)); + + const conversions = await db + .select({ + id: kiloclaw_referral_conversions.id, + refereeUserId: kiloclaw_referral_conversions.referee_user_id, + winningTouchType: kiloclaw_referral_conversions.winning_touch_type, + sourcePaymentId: kiloclaw_referral_conversions.source_payment_id, + qualified: kiloclaw_referral_conversions.qualified, + disqualificationReason: kiloclaw_referral_conversions.disqualification_reason, + convertedAt: kiloclaw_referral_conversions.converted_at, + }) + .from(kiloclaw_referral_conversions) + .where(eq(kiloclaw_referral_conversions.referrer_user_id, referrer.id)) + .orderBy(desc(kiloclaw_referral_conversions.converted_at)); + + const conversionIds = conversions.map(conversion => conversion.id); + const rewardDecisions = conversionIds.length + ? await db + .select({ + conversionId: kiloclaw_referral_reward_decisions.conversion_id, + id: kiloclaw_referral_reward_decisions.id, + beneficiaryUserId: kiloclaw_referral_reward_decisions.beneficiary_user_id, + beneficiaryRole: kiloclaw_referral_reward_decisions.beneficiary_role, + outcome: kiloclaw_referral_reward_decisions.outcome, + reason: kiloclaw_referral_reward_decisions.reason, + monthsGranted: kiloclaw_referral_reward_decisions.months_granted, + createdAt: kiloclaw_referral_reward_decisions.created_at, + }) + .from(kiloclaw_referral_reward_decisions) + .where(inArray(kiloclaw_referral_reward_decisions.conversion_id, conversionIds)) + .orderBy(desc(kiloclaw_referral_reward_decisions.created_at)) + : []; + const rewards = conversionIds.length + ? await db + .select({ + conversionId: kiloclaw_referral_rewards.conversion_id, + id: kiloclaw_referral_rewards.id, + beneficiaryUserId: kiloclaw_referral_rewards.beneficiary_user_id, + beneficiaryRole: kiloclaw_referral_rewards.beneficiary_role, + status: kiloclaw_referral_rewards.status, + monthsGranted: kiloclaw_referral_rewards.months_granted, + earnedAt: kiloclaw_referral_rewards.earned_at, + appliedAt: kiloclaw_referral_rewards.applied_at, + expiresAt: kiloclaw_referral_rewards.expires_at, + reviewReason: kiloclaw_referral_rewards.review_reason, + }) + .from(kiloclaw_referral_rewards) + .where(inArray(kiloclaw_referral_rewards.conversion_id, conversionIds)) + .orderBy(desc(kiloclaw_referral_rewards.created_at)) + : []; + const rewardApplications = conversionIds.length + ? await db + .select({ + conversionId: kiloclaw_referral_rewards.conversion_id, + id: kiloclaw_referral_reward_applications.id, + beneficiaryUserId: kiloclaw_referral_reward_applications.beneficiary_user_id, + subscriptionId: kiloclaw_referral_reward_applications.subscription_id, + previousRenewalBoundary: kiloclaw_referral_reward_applications.previous_renewal_boundary, + newRenewalBoundary: kiloclaw_referral_reward_applications.new_renewal_boundary, + appliedAt: kiloclaw_referral_reward_applications.applied_at, + }) + .from(kiloclaw_referral_reward_applications) + .innerJoin( + kiloclaw_referral_rewards, + eq(kiloclaw_referral_rewards.id, kiloclaw_referral_reward_applications.reward_id) + ) + .where(inArray(kiloclaw_referral_rewards.conversion_id, conversionIds)) + .orderBy(desc(kiloclaw_referral_reward_applications.applied_at)) + : []; + const impactReports = conversionIds.length + ? await db + .select({ + conversionId: impact_conversion_reports.conversion_id, + id: impact_conversion_reports.id, + state: impact_conversion_reports.state, + actionTrackerId: impact_conversion_reports.action_tracker_id, + orderId: impact_conversion_reports.order_id, + deliveredAt: impact_conversion_reports.delivered_at, + nextRetryAt: impact_conversion_reports.next_retry_at, + responseStatusCode: impact_conversion_reports.response_status_code, + }) + .from(impact_conversion_reports) + .where(inArray(impact_conversion_reports.conversion_id, conversionIds)) + .orderBy(desc(impact_conversion_reports.created_at)) + : []; + + return { + referrer: { + id: referrer.id, + email: referrer.email, + name: referrer.name, + }, + referrals: referralRows.map(referral => { + const conversion = conversions.find(row => row.refereeUserId === referral.refereeId) ?? null; + const conversionId = conversion?.id ?? null; + + return { + referral: { + id: referral.referralId, + impactReferralId: referral.impactReferralId, + createdAt: normalizeTimestamp(referral.referralCreatedAt) ?? referral.referralCreatedAt, + }, + referee: { + id: referral.refereeId, + email: referral.refereeEmail, + name: referral.refereeName, + }, + sourceTouch: referral.touchId + ? { + id: referral.touchId, + provider: referral.touchProvider, + touchType: referral.touchType, + landingPath: referral.landingPath, + rsCode: referral.rsCode, + imRef: referral.imRef, + touchedAt: normalizeTimestamp(referral.touchedAt), + expiresAt: normalizeTimestamp(referral.expiresAt), + } + : null, + conversion: conversion + ? { + id: conversion.id, + winningTouchType: conversion.winningTouchType, + sourcePaymentId: conversion.sourcePaymentId, + qualified: conversion.qualified, + disqualificationReason: conversion.disqualificationReason, + convertedAt: normalizeTimestamp(conversion.convertedAt) ?? conversion.convertedAt, + } + : null, + rewardDecisions: conversionId + ? listByConversionId(rewardDecisions, conversionId).map(decision => ({ + id: decision.id, + beneficiaryUserId: decision.beneficiaryUserId, + beneficiaryRole: decision.beneficiaryRole, + outcome: decision.outcome, + reason: decision.reason, + monthsGranted: decision.monthsGranted, + createdAt: normalizeTimestamp(decision.createdAt) ?? decision.createdAt, + })) + : [], + rewards: conversionId + ? listByConversionId(rewards, conversionId).map(reward => ({ + id: reward.id, + beneficiaryUserId: reward.beneficiaryUserId, + beneficiaryRole: reward.beneficiaryRole, + status: reward.status, + monthsGranted: reward.monthsGranted, + earnedAt: normalizeTimestamp(reward.earnedAt) ?? reward.earnedAt, + appliedAt: normalizeTimestamp(reward.appliedAt), + expiresAt: normalizeTimestamp(reward.expiresAt), + reviewReason: reward.reviewReason, + })) + : [], + rewardApplications: conversionId + ? listByConversionId(rewardApplications, conversionId).map(application => ({ + id: application.id, + beneficiaryUserId: application.beneficiaryUserId, + subscriptionId: application.subscriptionId, + previousRenewalBoundary: + normalizeTimestamp(application.previousRenewalBoundary) ?? + application.previousRenewalBoundary, + newRenewalBoundary: + normalizeTimestamp(application.newRenewalBoundary) ?? + application.newRenewalBoundary, + appliedAt: normalizeTimestamp(application.appliedAt) ?? application.appliedAt, + })) + : [], + impactReports: conversionId + ? listByConversionId(impactReports, conversionId).map(report => ({ + id: report.id, + state: report.state, + actionTrackerId: report.actionTrackerId, + orderId: report.orderId, + deliveredAt: normalizeTimestamp(report.deliveredAt), + nextRetryAt: normalizeTimestamp(report.nextRetryAt), + responseStatusCode: report.responseStatusCode, + })) + : [], + }; + }), + }; +} + +export const adminKiloclawReferralsRouter = createTRPCRouter({ + investigateReferrer: adminProcedure + .input(ReferralInvestigationInputSchema) + .output(ReferralInvestigationOutputSchema) + .query(async ({ input }) => { + return await investigateReferrer(input.search); + }), +}); From 99c23254adcc859d83493b374224697f9f0e6e6d Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 10:42:58 +0200 Subject: [PATCH 13/32] feat(claw): refer & earn page and rewards UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the user-facing referral surface and the embeddable widget that authenticates the current user against Impact Advocate's Verified Access contract. - /claw/refer page: SetPageTitle + ReferralRewardStatusCard + the Impact Advocate widget. Loads getReferralRewardSummary so the page shows pending vs applied rewards, referred-people totals, and a 'Start/Reactivate' CTA when there are pending rewards waiting on an active subscription. - ReferralRewardStatusCard: composed status card for the totals, referred-people list, and reward history; covered by unit tests for the empty, pending, and applied states. - ReferralRewardsSummary: shared component reused by the dashboard active-subscription card and the KiloClaw detail page. - variant="card" | "section" so the same component renders flat inside another card (avoiding the Kilo nested-card anti-pattern) and standalone as a sibling card on the detail page. - Badge variant="new" pill for 'X free months applied'. - ArrowRight between renewal-moved dates with tabular-nums. - aria-live="polite" so newly applied rewards announce after a refetch; Kilo-voice copy ('Free months push your renewal date out.', 'No rewards yet. Refer a friend to earn a free month.'); role labels addressed to the user ('Reward for referring', 'Welcome reward'). - ImpactAdvocateReferralCard: move under components/referrals/ and delete the old profile copy. Profile page no longer renders the card — referrals now live on /claw/refer. - PersonalAppSidebar: 'Refer & Earn' menu item under the KiloClaw group with a 'NEW' badge and 'Get 1 Month Free' subtitle. - SidebarMenuList: optional subtitle, badge, and className on MenuItem so the sidebar can render the new two-line entry without cloning the primitive. - billing-types: extend ClawBillingStatus with the rewards summary shape consumed by the new surfaces, with parity tests. --- .../billing/ReferralRewardStatusCard.test.ts | 187 ++++++++++ .../billing/ReferralRewardStatusCard.tsx | 330 ++++++++++++++++++ .../billing/ReferralRewardsSummary.test.ts | 83 +++++ .../billing/ReferralRewardsSummary.tsx | 110 ++++++ .../components/billing/billing-types.test.ts | 4 + .../claw/components/billing/billing-types.ts | 10 + apps/web/src/app/(app)/claw/refer/page.tsx | 48 +++ .../(app)/components/PersonalAppSidebar.tsx | 10 + .../app/(app)/components/SidebarMenuList.tsx | 41 ++- apps/web/src/app/(app)/profile/page.tsx | 3 - .../ImpactAdvocateReferralCard.tsx | 60 ++-- 11 files changed, 839 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.test.ts create mode 100644 apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.tsx create mode 100644 apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.test.ts create mode 100644 apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx create mode 100644 apps/web/src/app/(app)/claw/refer/page.tsx rename apps/web/src/components/{profile => referrals}/ImpactAdvocateReferralCard.tsx (59%) diff --git a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.test.ts b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.test.ts new file mode 100644 index 0000000000..1f63d53438 --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.test.ts @@ -0,0 +1,187 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from '@jest/globals'; + +import { ReferralRewardStatusCard } from './ReferralRewardStatusCard'; + +const emptySummary = { + totals: { + totalRewards: 0, + pendingRewards: 0, + totalAppliedMonths: 0, + }, + pendingRewardAction: { + showStartReactivateCta: false, + pendingRewardCount: 0, + }, + referredPeople: [], + rewards: [], +}; + +describe('ReferralRewardStatusCard', () => { + it('renders program guidance and an empty my rewards state', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardStatusCard, { summary: emptySummary }) + ); + + expect(html).toContain('Earn a free month of KiloClaw hosting'); + expect(html).toContain('Earned rewards'); + expect(html).toContain('Total rewards earned'); + expect(html).toContain('Rewards on hold'); + expect(html).toContain('Rewards applied'); + expect(html).toContain('Share your referral link'); + expect(html).toContain('href="#referral-share"'); + expect(html).not.toContain('credits'); + expect(html).not.toContain('awards'); + }); + + it('does not render the warning indicator dot when no rewards are on hold', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardStatusCard, { summary: emptySummary }) + ); + + expect(html).not.toContain('data-testid="summary-indicator-warning"'); + }); + + it('renders the share widget slot when provided', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardStatusCard, { + summary: emptySummary, + shareWidget: React.createElement('div', { 'data-testid': 'share-widget' }, 'widget body'), + }) + ); + + expect(html).toContain('id="referral-share"'); + expect(html).toContain('data-testid="share-widget"'); + expect(html).toContain('widget body'); + }); + + it('renders reward status labels, referred people, and a top-of-card reactivate banner', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardStatusCard, { + summary: { + totals: { + totalRewards: 6, + pendingRewards: 1, + totalAppliedMonths: 1, + }, + pendingRewardAction: { + showStartReactivateCta: true, + pendingRewardCount: 1, + }, + referredPeople: [ + { + maskedEmail: 'q***@example.com', + state: 'reward_granted', + rewardGranted: true, + }, + { + maskedEmail: 's***@example.com', + state: 'waiting_for_paid_conversion', + rewardGranted: false, + }, + ], + rewards: [ + { + role: 'referrer', + status: 'applied', + monthsGranted: 1, + earnedAt: '2026-04-10T00:00:00.000Z', + appliedAt: '2026-04-10T00:05:00.000Z', + expiresAt: null, + reviewReason: null, + application: { + appliedAt: '2026-04-10T00:05:00.000Z', + subscriptionId: '11111111-1111-4111-8111-111111111111', + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + }, + }, + { + role: 'referee', + status: 'pending', + monthsGranted: 1, + earnedAt: '2026-04-11T00:00:00.000Z', + appliedAt: null, + expiresAt: null, + reviewReason: null, + application: null, + }, + { + role: 'referrer', + status: 'expired', + monthsGranted: 1, + earnedAt: '2026-04-12T00:00:00.000Z', + appliedAt: null, + expiresAt: '2027-04-12T00:00:00.000Z', + reviewReason: null, + application: null, + }, + { + role: 'referrer', + status: 'canceled', + monthsGranted: 1, + earnedAt: '2026-04-13T00:00:00.000Z', + appliedAt: null, + expiresAt: null, + reviewReason: null, + application: null, + }, + { + role: 'referrer', + status: 'reversed', + monthsGranted: 1, + earnedAt: '2026-04-14T00:00:00.000Z', + appliedAt: null, + expiresAt: null, + reviewReason: null, + application: null, + }, + { + role: 'referrer', + status: 'review_required', + monthsGranted: 1, + earnedAt: '2026-04-15T00:00:00.000Z', + appliedAt: null, + expiresAt: null, + reviewReason: 'referral_payment_chargeback', + application: null, + }, + ], + }, + }) + ); + + expect(html).toContain('Applied'); + expect(html).toContain('Waiting for an eligible KiloClaw subscription'); + expect(html).toContain('Expired'); + expect(html).toContain('Canceled'); + expect(html).toContain('Reversed'); + expect(html).toContain('Needs review'); + expect(html).toContain('May 1, 2026'); + expect(html).toContain('June 1, 2026'); + expect(html).toContain('Your referees'); + expect(html).toContain('q***@example.com'); + expect(html).toContain('Reward granted'); + expect(html).toContain('s***@example.com'); + expect(html).toContain('Signed up, waiting for paid KiloClaw conversion'); + expect(html).toContain('1 reward on hold'); + expect(html).toContain('Start or reactivate KiloClaw'); + expect(html).toContain('data-testid="summary-indicator-warning"'); + }); + + it('pluralizes the reactivate banner copy when more than one reward is pending', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardStatusCard, { + summary: { + ...emptySummary, + totals: { totalRewards: 2, pendingRewards: 2, totalAppliedMonths: 0 }, + pendingRewardAction: { showStartReactivateCta: true, pendingRewardCount: 2 }, + }, + }) + ); + + expect(html).toContain('2 rewards on hold'); + expect(html).toContain('to apply them'); + }); +}); diff --git a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.tsx b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.tsx new file mode 100644 index 0000000000..a7bb2c9771 --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.tsx @@ -0,0 +1,330 @@ +import React from 'react'; +import Link from 'next/link'; +import { CalendarDays, Gift, Info, Sparkles } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { formatBillingDate } from './billing-types'; + +const SHARE_WIDGET_ANCHOR_ID = 'referral-share'; + +type ReferralRewardStatus = + | 'pending' + | 'earned' + | 'applied' + | 'expired' + | 'canceled' + | 'reversed' + | 'review_required'; + +type ReferralRewardSummary = { + totals: { + totalRewards: number; + pendingRewards: number; + totalAppliedMonths: number; + }; + pendingRewardAction: { + showStartReactivateCta: boolean; + pendingRewardCount: number; + }; + referredPeople: Array<{ + maskedEmail: string | null; + state: 'reward_granted' | 'waiting_for_paid_conversion'; + rewardGranted: boolean; + }>; + rewards: Array<{ + role: 'referrer' | 'referee'; + status: ReferralRewardStatus; + monthsGranted: number; + earnedAt: string; + appliedAt: string | null; + expiresAt: string | null; + reviewReason: string | null; + application: { + appliedAt: string; + subscriptionId: string | null; + previousRenewalBoundary: string; + newRenewalBoundary: string; + } | null; + }>; +}; + +type ReferralRewardStatusCardProps = { + summary: ReferralRewardSummary; + shareWidget?: React.ReactNode; +}; + +type StatusPresentation = { + label: string; + className: string; +}; + +function rewardStatusPresentation(status: ReferralRewardStatus): StatusPresentation { + switch (status) { + case 'applied': + return { + label: 'Applied', + className: 'bg-emerald-500/20 text-emerald-400 ring-emerald-500/20', + }; + case 'earned': + return { + label: 'Waiting for renewal extension', + className: 'bg-blue-500/20 text-blue-400 ring-blue-500/20', + }; + case 'pending': + return { + label: 'Waiting for an eligible KiloClaw subscription', + className: 'bg-yellow-500/20 text-yellow-400 ring-yellow-500/20', + }; + case 'expired': + return { + label: 'Expired', + className: 'bg-zinc-500/20 text-zinc-400 ring-zinc-500/20', + }; + case 'canceled': + return { + label: 'Canceled', + className: 'bg-zinc-500/20 text-zinc-400 ring-zinc-500/20', + }; + case 'reversed': + return { + label: 'Reversed', + className: 'bg-red-500/20 text-red-400 ring-red-500/20', + }; + case 'review_required': + return { + label: 'Needs review', + className: 'bg-orange-500/20 text-orange-400 ring-orange-500/20', + }; + } +} + +function roleLabel(role: 'referrer' | 'referee'): string { + return role === 'referrer' ? 'Referral you shared' : 'Referral you used'; +} + +function monthLabel(months: number): string { + return `${months} ${months === 1 ? 'free month' : 'free months'}`; +} + +export function ReferralRewardStatusCard({ summary, shareWidget }: ReferralRewardStatusCardProps) { + const showReactivateCta = summary.pendingRewardAction.showStartReactivateCta; + + return ( + + + + + + Share KiloClaw with someone else and when they sign up for a paid subscription, you both + get 1 free month of KiloClaw hosting. + + + + {shareWidget ?
{shareWidget}
: null} + + {showReactivateCta ? ( +
+
+ You have {summary.pendingRewardAction.pendingRewardCount}{' '} + {summary.pendingRewardAction.pendingRewardCount === 1 ? 'reward' : 'rewards'} on hold. + Start or reactivate KiloClaw to apply{' '} + {summary.pendingRewardAction.pendingRewardCount === 1 ? 'it' : 'them'}. +
+ +
+ ) : null} + +
+ + 0 ? 'warning' : undefined} + /> + +
+ +
+

+ Earned rewards +

+ + {summary.rewards.length === 0 ? ( +
+ No referral rewards yet.{' '} + + Share your referral link + {' '} + to earn a free month. +
+ ) : ( +
+ {summary.rewards.map((reward, index) => ( + + ))} +
+ )} +
+ +
+

+ Your referees +

+ + {summary.referredPeople.length === 0 ? ( +
+ No referred people yet. +
+ ) : ( +
+ {summary.referredPeople.map((person, index) => ( +
+
+
+ {person.maskedEmail ?? 'Unknown referee'} +
+
Masked referee identity
+
+ + {person.state === 'reward_granted' + ? 'Reward granted' + : 'Signed up, waiting for paid KiloClaw conversion'} + +
+ ))} +
+ )} +
+
+
+ ); +} + +type IndicatorTone = 'warning'; + +function SummaryTile({ + label, + value, + info, + indicator, +}: { + label: string; + value: string; + info?: string; + indicator?: IndicatorTone; +}) { + return ( +
+
+ {indicator === 'warning' ? ( +
+
+ {value} +
+
+ ); +} + +function RewardRow({ reward }: { reward: ReferralRewardSummary['rewards'][number] }) { + const status = rewardStatusPresentation(reward.status); + return ( +
+
+
{roleLabel(reward.role)}
+
+
+
+
+ + {status.label} + + + {monthLabel(reward.monthsGranted)} +
+
+ {reward.application ? ( + <> +
+
+
+ Renewal moved{' '} + + {formatBillingDate(reward.application.previousRenewalBoundary)} + {' '} + to{' '} + + {formatBillingDate(reward.application.newRenewalBoundary)} + +
+ + ) : reward.expiresAt ? ( +
+ Expires{' '} + {formatBillingDate(reward.expiresAt)} +
+ ) : ( +
Reward application details appear after the free month is applied.
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.test.ts b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.test.ts new file mode 100644 index 0000000000..ad1f323689 --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.test.ts @@ -0,0 +1,83 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from '@jest/globals'; + +import { ReferralRewardsSummary } from './ReferralRewardsSummary'; +import type { ClawBillingStatus } from './billing-types'; + +const emptyRewards: NonNullable['referralRewards'] = { + totalAppliedMonths: 0, + applications: [], +}; + +describe('ReferralRewardsSummary', () => { + it('renders the empty state with a primary refer-a-friend CTA', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardsSummary, { rewards: emptyRewards }) + ); + + expect(html).toContain('Referral rewards'); + expect(html).toContain('No rewards yet. Refer a friend to earn a free month.'); + expect(html).toContain('Refer a friend'); + expect(html).toContain('href="/claw/refer"'); + }); + + it('renders applied reward rows with renewal boundaries and Kilo-voice role label', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardsSummary, { + rewards: { + totalAppliedMonths: 1, + applications: [ + { + role: 'referrer', + appliedAt: '2026-04-10T00:05:00.000Z', + monthsGranted: 1, + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + }, + ], + }, + }) + ); + + expect(html).toContain('1 free month applied'); + expect(html).toContain('Reward for referring'); + expect(html).toContain('Applied April 10, 2026'); + expect(html).toContain('May 1, 2026'); + expect(html).toContain('June 1, 2026'); + }); + + it('uses the welcome-reward label for referee rewards', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardsSummary, { + rewards: { + totalAppliedMonths: 1, + applications: [ + { + role: 'referee', + appliedAt: '2026-04-10T00:05:00.000Z', + monthsGranted: 1, + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + }, + ], + }, + }) + ); + + expect(html).toContain('Welcome reward'); + }); + + it('drops its own border when rendered as a section variant', () => { + const html = renderToStaticMarkup( + React.createElement(ReferralRewardsSummary, { + rewards: emptyRewards, + variant: 'section', + }) + ); + + // The card variant has bg-background/40; the section variant must not. + expect(html).not.toContain('bg-background/40'); + expect(html).toContain('border-t'); + }); +}); diff --git a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx new file mode 100644 index 0000000000..d17fa21e78 --- /dev/null +++ b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import Link from 'next/link'; +import { ArrowRight, CalendarDays, Gift } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { ClawBillingStatus } from './billing-types'; +import { formatBillingDate } from './billing-types'; + +type ReferralRewards = NonNullable['referralRewards']; + +type ReferralRewardsSummaryProps = { + rewards: ReferralRewards; + /** + * `card` (default) renders inside its own bordered container. Use this when + * the summary stands alone as a sibling card on a detail page. + * `section` renders as a flat block separated by a top divider — use it when + * embedding inside another `` to avoid the nested-card anti-pattern. + */ + variant?: 'card' | 'section'; +}; + +function roleLabel(role: ReferralRewards['applications'][number]['role']): string { + // Address the user, not the system. "Referee" is internal jargon. + return role === 'referrer' ? 'Reward for referring' : 'Welcome reward'; +} + +function monthsLabel(months: number): string { + return `${months} ${months === 1 ? 'free month' : 'free months'}`; +} + +export function ReferralRewardsSummary({ rewards, variant = 'card' }: ReferralRewardsSummaryProps) { + const isCard = variant === 'card'; + + return ( +
+
+
+
+
+
+

+ Referral rewards +

+

+ Free months push your renewal date out. +

+
+
+ {rewards.totalAppliedMonths > 0 ? ( + + {monthsLabel(rewards.totalAppliedMonths)} applied + + ) : null} +
+ + {rewards.applications.length === 0 ? ( +
+

No rewards yet. Refer a friend to earn a free month.

+ +
+ ) : ( +
    + {rewards.applications.map(application => ( +
  • +
    +
    {roleLabel(application.role)}
    +
    + {monthsLabel(application.monthsGranted)} +
    +
    +
    + + + + Renewal: + + {formatBillingDate(application.previousRenewalBoundary)} + + +
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/claw/components/billing/billing-types.test.ts b/apps/web/src/app/(app)/claw/components/billing/billing-types.test.ts index 78b08638ec..4601d76a08 100644 --- a/apps/web/src/app/(app)/claw/components/billing/billing-types.test.ts +++ b/apps/web/src/app/(app)/claw/components/billing/billing-types.test.ts @@ -50,6 +50,10 @@ function createBillingStatus(overrides?: BillingStatusOverrides): ClawBillingSta renewalCostMicrodollars: null, showConversionPrompt: false, pendingConversion: false, + referralRewards: { + totalAppliedMonths: 0, + applications: [], + }, ...subscriptionOverrides, }, earlybird: null, diff --git a/apps/web/src/app/(app)/claw/components/billing/billing-types.ts b/apps/web/src/app/(app)/claw/components/billing/billing-types.ts index 4d6e7a35ed..a7f864801e 100644 --- a/apps/web/src/app/(app)/claw/components/billing/billing-types.ts +++ b/apps/web/src/app/(app)/claw/components/billing/billing-types.ts @@ -101,6 +101,16 @@ export type ClawBillingStatus = { showConversionPrompt: boolean; /** True when Stripe subscription is being cancelled to convert to credit-funded billing. */ pendingConversion: boolean; + referralRewards: { + totalAppliedMonths: number; + applications: Array<{ + role: 'referrer' | 'referee'; + appliedAt: string; + monthsGranted: number; + previousRenewalBoundary: string; + newRenewalBoundary: string; + }>; + }; } | null; earlybird: { diff --git a/apps/web/src/app/(app)/claw/refer/page.tsx b/apps/web/src/app/(app)/claw/refer/page.tsx new file mode 100644 index 0000000000..2866525f2b --- /dev/null +++ b/apps/web/src/app/(app)/claw/refer/page.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { Gift } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; + +import { SetPageTitle } from '@/components/SetPageTitle'; +import { Card, CardContent } from '@/components/ui/card'; +import { useTRPC } from '@/lib/trpc/utils'; +import { ImpactAdvocateReferralWidget } from '@/components/referrals/ImpactAdvocateReferralCard'; +import { ReferralRewardStatusCard } from '../components/billing/ReferralRewardStatusCard'; + +const emptyRewardSummary = { + totals: { + totalRewards: 0, + pendingRewards: 0, + totalAppliedMonths: 0, + }, + pendingRewardAction: { + showStartReactivateCta: false, + pendingRewardCount: 0, + }, + referredPeople: [], + rewards: [], +}; + +export default function PersonalClawReferPage() { + const trpc = useTRPC(); + const rewardSummary = useQuery(trpc.kiloclaw.getReferralRewardSummary.queryOptions()); + + return ( +
+ } + /> + {rewardSummary.isLoading ? ( + + Loading rewards… + + ) : ( + } + /> + )} +
+ ); +} diff --git a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx index 2ccd33276d..8d266db392 100644 --- a/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx +++ b/apps/web/src/app/(app)/components/PersonalAppSidebar.tsx @@ -30,6 +30,7 @@ import { CreditCard, MessageSquare, Sparkles, + Gift, ChevronLeft, ChevronRight, } from 'lucide-react'; @@ -82,6 +83,8 @@ export default function PersonalAppSidebar(props: React.ComponentProps = [ { @@ -104,6 +107,13 @@ export default function PersonalAppSidebar(props: React.ComponentProps void; isActive?: boolean; suffixIcon?: React.ElementType; + subtitle?: string; + badge?: string; className?: string; }; @@ -60,20 +64,35 @@ export default function SidebarMenuList({ const content = ( <> - {item.title} + {item.subtitle ? ( + + {item.title} + + {item.subtitle} + + + ) : ( + {item.title} + )} {item.suffixIcon && } ); + const buttonClassName = cn( + 'flex items-center gap-3 transition-colors', + item.subtitle && 'h-12 py-2', + item.badge && 'pr-14', + item.className + ); return ( {item.url ? ( - - + + {content} @@ -82,11 +101,17 @@ export default function SidebarMenuList({ type="button" onClick={item.onClick} isActive={isActive} - className={`flex cursor-pointer items-center gap-3 transition-colors ${item.className || ''}`} + size={item.subtitle ? 'lg' : 'default'} + className={cn('cursor-pointer', buttonClassName)} > {content} )} + {item.badge && ( + + {item.badge} + + )} ); })} diff --git a/apps/web/src/app/(app)/profile/page.tsx b/apps/web/src/app/(app)/profile/page.tsx index 7c0c133f48..74169cc793 100644 --- a/apps/web/src/app/(app)/profile/page.tsx +++ b/apps/web/src/app/(app)/profile/page.tsx @@ -22,7 +22,6 @@ import { getUserOrganizationsWithSeats } from '@/lib/organizations/organizations import { PageLayout } from '@/components/PageLayout'; import { ProfileOrganizationsSection } from '@/components/profile/ProfileOrganizationsSection'; import { ProfileKiloPassSection } from '@/components/profile/ProfileKiloPassSection'; -import { ImpactAdvocateReferralCard } from '@/components/profile/ImpactAdvocateReferralCard'; import { CreateKilocodeOrgButton } from '@/components/dev/CreateKilocodeOrgButton'; import { isFeatureFlagEnabled } from '@/lib/posthog-feature-flags'; import { UserProfileCard } from '@/components/profile/UserProfileCard'; @@ -83,8 +82,6 @@ export default async function ProfilePage({ searchParams }: AppPageProps) { {isKiloPassUiEnabled && } - -
diff --git a/apps/web/src/components/profile/ImpactAdvocateReferralCard.tsx b/apps/web/src/components/referrals/ImpactAdvocateReferralCard.tsx similarity index 59% rename from apps/web/src/components/profile/ImpactAdvocateReferralCard.tsx rename to apps/web/src/components/referrals/ImpactAdvocateReferralCard.tsx index 103d9ac3bb..407ee68595 100644 --- a/apps/web/src/components/profile/ImpactAdvocateReferralCard.tsx +++ b/apps/web/src/components/referrals/ImpactAdvocateReferralCard.tsx @@ -1,16 +1,35 @@ 'use client'; import { createElement, useEffect, useState } from 'react'; -import { Gift } from 'lucide-react'; - -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; type WidgetState = | { status: 'loading' } | { status: 'ready'; token: string; widgetId: string } | { status: 'unavailable'; message: string }; -export function ImpactAdvocateReferralCard() { +function renderWidgetContent(state: WidgetState) { + switch (state.status) { + case 'loading': + return
Loading referral sharing…
; + case 'unavailable': + return
{state.message}
; + case 'ready': + return ( +
+ {createElement( + 'impact-embed', + { + widget: state.widgetId, + className: 'block min-h-52 w-full', + }, +
Loading referral widget…
+ )} +
+ ); + } +} + +export function ImpactAdvocateReferralWidget() { const [state, setState] = useState({ status: 'loading' }); useEffect(() => { @@ -77,36 +96,5 @@ export function ImpactAdvocateReferralCard() { }; }, []); - return ( - - - - - Referral Program - - - Invite a friend to KiloClaw. When they become an eligible paid personal subscriber, you - both get a free month. - - - - {state.status === 'loading' ? ( -
Loading referral sharing…
- ) : state.status === 'unavailable' ? ( -
{state.message}
- ) : ( -
- {createElement( - 'impact-embed', - { - widget: state.widgetId, - className: 'block min-h-52 w-full', - }, -
Loading referral widget…
- )} -
- )} -
-
- ); + return
{renderWidgetContent(state)}
; } From 34d25c37b738bc15d0148daf086d455ac97c5a7c Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 10:43:30 +0200 Subject: [PATCH 14/32] refactor(claw): align KiloClaw subscription view with kilo-design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standardise the /claw subscription tab and /subscriptions/kiloclaw/:id detail page on shared Kilo primitives so they read as one product. Driven by a kilo-design review against reference/kilo-brand.md. Shared primitives: - DetailRow: label-over-value row with optional numeric (tabular-nums). - useKiloClawBillingQueries: single source of truth for invalidating every query keyed off a KiloClaw instance after a state-changing mutation, removing the duplicate invalidation block that existed in both surfaces. SubscriptionCard (/claw): - Replace bespoke tinted-fill rounded-xl shells (emerald/amber/blue/ red/indigo) with one neutral KiloClawCardShell built on Card + CardTitle + SubscriptionStatusBadge. Status is communicated by the badge plus context-specific Alert variants, not background colour — restoring brand-accent discipline per kilo-brand.md. - Drop the hand-rolled PaymentSourceBadge (and the indigo palette); payment source now lives in a DetailRow via formatPaymentSummary(). - DetailRow grid for Plan / Next billing / Payment source with numeric on dates and prices; new explicit 'Auto-renew' row for commit plans replaces the parenthetical aside. - One primary CTA per non-resting state (Reactivate, Keep Stripe billing, Update payment / Add credits); active resting state stays neutral with destructive-styled outline 'Cancel subscription' pushed to the right. - All state-changing actions go through an AlertDialog confirmation, matching the detail page so the dashboard and detail flows agree. - aria-busy on async buttons, aria-hidden on decorative icons, Loader2 announced via aria-busy not visual spinner alone. - Conversion prompt and 'switching scheduled' notes use Alert variants instead of nested tinted boxes. KiloClawDetail (/subscriptions/kiloclaw/:id): - Drop the inline private DetailRow in favour of the shared one; numeric on Price, Next renewal, Commit ends, Trial ends, Suspended at, Destruction deadline. - Replace the hand-rolled bg-blue-500/10 status note with Alert variant="notice". - refreshData replaced by the shared useInvalidateKiloClawBilling hook. - Reactivate promoted to primary; sentence-case labels; aria-busy on AlertDialogAction during pending state. ReferralRewardsSummary now embeds as variant="section" inside the dashboard card (no nested-card anti-pattern) and as variant="card" on the standalone detail page. Crab-icon sweep across the surrounding dashboard so emoji 🦀 is replaced by KiloCrabIcon at size-5 in BillingWrapper's EarlybirdActiveCard and both EarlybirdCard variants. The remaining 🦀 literal in apps/web/src/app/(app)/claw/hooks/useOnboardingSaves.test.ts is botEmoji data (user-configured chat-agent emoji) and is unrelated to the Kilo logo system. --- .../components/billing/BillingWrapper.tsx | 3 +- .../claw/components/billing/EarlybirdCard.tsx | 5 +- .../components/billing/SubscriptionCard.tsx | 613 +++++++++--------- .../components/subscriptions/DetailRow.tsx | 24 + .../subscriptions/kiloclaw/KiloClawDetail.tsx | 82 ++- .../kiloclaw/useKiloClawBillingQueries.ts | 39 ++ 6 files changed, 426 insertions(+), 340 deletions(-) create mode 100644 apps/web/src/components/subscriptions/DetailRow.tsx create mode 100644 apps/web/src/components/subscriptions/kiloclaw/useKiloClawBillingQueries.ts diff --git a/apps/web/src/app/(app)/claw/components/billing/BillingWrapper.tsx b/apps/web/src/app/(app)/claw/components/billing/BillingWrapper.tsx index 349d7d5c72..a12c0926cf 100644 --- a/apps/web/src/app/(app)/claw/components/billing/BillingWrapper.tsx +++ b/apps/web/src/app/(app)/claw/components/billing/BillingWrapper.tsx @@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useTRPC } from '@/lib/trpc/utils'; import { Button } from '@/components/ui/button'; +import KiloCrabIcon from '@/components/KiloCrabIcon'; import { BillingBanner } from './BillingBanner'; import { AccessLockedDialog } from './AccessLockedDialog'; import { PlanSelectionDialog } from './PlanSelectionDialog'; @@ -20,7 +21,7 @@ function EarlybirdActiveCard({ }) { return (
- 🦀 +
Thanks for being an early KiloClaw subscriber. diff --git a/apps/web/src/app/(app)/claw/components/billing/EarlybirdCard.tsx b/apps/web/src/app/(app)/claw/components/billing/EarlybirdCard.tsx index 36eb154c59..cc8afe54fc 100644 --- a/apps/web/src/app/(app)/claw/components/billing/EarlybirdCard.tsx +++ b/apps/web/src/app/(app)/claw/components/billing/EarlybirdCard.tsx @@ -2,6 +2,7 @@ import { Gift } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import KiloCrabIcon from '@/components/KiloCrabIcon'; import { formatBillingDate, type ClawBillingStatus } from './billing-types'; type EarlybirdCardProps = { @@ -17,7 +18,7 @@ export function EarlybirdCard({ earlybird, onSubscribeClick }: EarlybirdCardProp
- 🦀 + KiloClaw Hosting
@@ -59,7 +60,7 @@ export function EarlybirdCard({ earlybird, onSubscribeClick }: EarlybirdCardProp
- 🦀 + KiloClaw Hosting
diff --git a/apps/web/src/app/(app)/claw/components/billing/SubscriptionCard.tsx b/apps/web/src/app/(app)/claw/components/billing/SubscriptionCard.tsx index 343183067a..f742610e07 100644 --- a/apps/web/src/app/(app)/claw/components/billing/SubscriptionCard.tsx +++ b/apps/web/src/app/(app)/claw/components/billing/SubscriptionCard.tsx @@ -1,11 +1,31 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import Link from 'next/link'; -import { ExternalLink, CreditCard, Coins, Loader2 } from 'lucide-react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ExternalLink, Loader2 } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; + import { useTRPC } from '@/lib/trpc/utils'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import KiloCrabIcon from '@/components/KiloCrabIcon'; +import { DetailRow } from '@/components/subscriptions/DetailRow'; +import { formatPaymentSummary } from '@/components/subscriptions/helpers'; +import { SubscriptionStatusBadge } from '@/components/subscriptions/SubscriptionStatusBadge'; +import { useInvalidateKiloClawBilling } from '@/components/subscriptions/kiloclaw/useKiloClawBillingQueries'; +import { cn } from '@/lib/utils'; + import { COMMIT_PERIOD_MONTHS, formatBillingDate, @@ -14,34 +34,28 @@ import { planLabel, type ClawBillingStatus, } from './billing-types'; +import { ReferralRewardsSummary } from './ReferralRewardsSummary'; type SubscriptionCardProps = { billing: ClawBillingStatus; onCancelClick: () => void; }; -function PaymentSourceBadge({ - subscription, -}: { - subscription: NonNullable; -}) { - if (subscription.hasStripeFunding) { - return ( - - - Stripe - - ); - } - if (subscription.paymentSource === 'credits') { - return ( - - - Credits - - ); - } - return null; +type ShellStatus = 'active' | 'pending_settlement' | 'past_due' | 'unpaid' | 'pending_cancellation'; + +function KiloClawCardShell({ status, children }: { status: ShellStatus; children: ReactNode }) { + return ( + + + + + KiloClaw subscription + + + + {children} + + ); } function PendingSettlementSubscriptionCard({ billing }: { billing: ClawBillingStatus }) { @@ -49,30 +63,28 @@ function PendingSettlementSubscriptionCard({ billing }: { billing: ClawBillingSt if (!sub) return null; return ( -
-
-
- 🦀 - KiloClaw Subscription -
- -
- -
- -
-
- Status: Processing payment -
-

- Hosting activates after invoice settlement. This usually takes just a moment. -

-
-
-
+ + + + ); } +type ActiveConfirmationAction = 'switchPlan' | 'cancelPlanSwitch' | 'switchToCredits'; + +type ActiveConfirmation = { + title: string; + description: string; + confirmLabel: string; + pendingLabel: string; + run: () => Promise; +}; + function ActiveSubscriptionCard({ billing, onCancelClick, @@ -81,8 +93,9 @@ function ActiveSubscriptionCard({ onCancelClick: () => void; }) { const trpc = useTRPC(); - const queryClient = useQueryClient(); const instanceId = billing.instance?.id ?? null; + const invalidate = useInvalidateKiloClawBilling(instanceId); + const switchPlanMutation = useMutation(trpc.kiloclaw.switchPlanAtInstance.mutationOptions()); const portalMutation = useMutation(trpc.kiloclaw.getCustomerPortalUrl.mutationOptions()); const cancelSwitchMutation = useMutation( @@ -91,6 +104,12 @@ function ActiveSubscriptionCard({ const acceptConversionMutation = useMutation( trpc.kiloclaw.acceptConversionAtInstance.mutationOptions() ); + + const [confirmationAction, setConfirmationAction] = useState( + null + ); + const [pendingAction, setPendingAction] = useState(null); + const CONVERSION_DISMISSED_KEY = 'kiloclaw-conversion-dismissed'; const [conversionDismissed, setConversionDismissed] = useState(() => { if (typeof window === 'undefined') return false; @@ -98,43 +117,27 @@ function ActiveSubscriptionCard({ }); const sub = billing.subscription; + + useEffect(() => { + if (sub && !sub.showConversionPrompt && conversionDismissed) { + localStorage.removeItem(CONVERSION_DISMISSED_KEY); + setConversionDismissed(false); + } + }, [sub, conversionDismissed]); + if (!sub) return null; const isCommit = sub.plan === 'commit'; - const currentPlanLabel = planLabel(sub.plan); + const otherPlan = isCommit ? 'standard' : 'commit'; const otherPlanLabel = isCommit ? `Standard ($${PLAN_DISPLAY.standard.monthlyDollars}/mo)` : `Commit ($${PLAN_DISPLAY.commit.monthlyDollars}/mo · ${COMMIT_PERIOD_MONTHS}-mo term)`; const hasUserRequestedSwitch = sub.scheduledBy === 'user'; - - async function invalidateBillingQueries() { - if (!instanceId) return; - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getActivePersonalBillingStatus.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getPersonalBillingSummary.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.listPersonalSubscriptions.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getSubscriptionDetail.queryKey({ instanceId }), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getBillingHistory.queryKey({ instanceId }), - }), - ]); - } - - async function handleSwitchPlan() { - if (!instanceId) return; - const toPlan = isCommit ? 'standard' : 'commit'; - await switchPlanMutation.mutateAsync({ instanceId, toPlan }); - await invalidateBillingQueries(); - } + const isCreditFunded = !sub.hasStripeFunding && sub.paymentSource === 'credits'; + const renewalDate = + isCreditFunded && sub.creditRenewalAt ? sub.creditRenewalAt : sub.currentPeriodEnd; + const showConversion = sub.showConversionPrompt && !conversionDismissed; async function handleManageBilling() { if (!instanceId) return; @@ -145,154 +148,198 @@ function ActiveSubscriptionCard({ window.location.href = result.url; } - async function handleCancelSwitch() { - if (!instanceId) return; - await cancelSwitchMutation.mutateAsync({ instanceId }); - await invalidateBillingQueries(); - } - - async function handleAcceptConversion() { - if (!instanceId) return; - await acceptConversionMutation.mutateAsync({ instanceId }); - await invalidateBillingQueries(); + const confirmations: Record = { + switchPlan: { + title: `Switch to ${isCommit ? 'Standard' : 'Commit'}?`, + description: `Schedules your KiloClaw subscription to switch plans at the next renewal. Your current plan stays active until then.`, + confirmLabel: `Switch to ${otherPlan}`, + pendingLabel: `Switching to ${otherPlan}`, + run: async () => { + if (!instanceId) return; + await switchPlanMutation.mutateAsync({ instanceId, toPlan: otherPlan }); + }, + }, + cancelPlanSwitch: { + title: 'Cancel scheduled plan switch?', + description: + 'Keeps your KiloClaw subscription on its current plan and removes the pending change.', + confirmLabel: 'Cancel plan switch', + pendingLabel: 'Canceling plan switch', + run: async () => { + if (!instanceId) return; + await cancelSwitchMutation.mutateAsync({ instanceId }); + }, + }, + switchToCredits: { + title: 'Switch hosting billing to credits?', + description: + 'Stripe billing stays active through the current period, then this subscription renews against your credit balance.', + confirmLabel: 'Switch to Credits', + pendingLabel: 'Switching to credits', + run: async () => { + if (!instanceId) return; + await acceptConversionMutation.mutateAsync({ instanceId }); + }, + }, + }; + + const activeConfirmation = confirmationAction ? confirmations[confirmationAction] : null; + const isPending = pendingAction !== null; + + function confirmCurrentAction() { + if (!confirmationAction || !activeConfirmation) return; + setPendingAction(confirmationAction); + void (async () => { + try { + await activeConfirmation.run(); + await invalidate(); + setConfirmationAction(null); + } finally { + setPendingAction(null); + } + })(); } - // Clear the persisted dismiss when the prompt is no longer relevant - // (e.g. user converted, subscription changed) so it doesn't stay hidden forever. - useEffect(() => { - if (!sub.showConversionPrompt && conversionDismissed) { - localStorage.removeItem(CONVERSION_DISMISSED_KEY); - setConversionDismissed(false); - } - }, [sub.showConversionPrompt, conversionDismissed]); - - const showConversion = sub.showConversionPrompt && !conversionDismissed; - - // Credit-funded renewal info - const isCreditFunded = !sub.hasStripeFunding && sub.paymentSource === 'credits'; - const renewalDate = - isCreditFunded && sub.creditRenewalAt ? sub.creditRenewalAt : sub.currentPeriodEnd; - return ( -
-
-
- 🦀 - KiloClaw Subscription -
- + +
+ + + + {isCommit ? ( + + ) : null} + {isCreditFunded && sub.renewalCostMicrodollars != null ? ( + + ) : null}
-
-
- Plan: {currentPlanLabel} -
-
- Status: Active -
- {isCommit && sub.commitEndsAt ? ( - <> -
- Commit period ends:{' '} - {formatBillingDate(sub.commitEndsAt)} -
-
(Auto-renews for another {COMMIT_PERIOD_MONTHS} months)
- - ) : ( -
- Next billing:{' '} - {formatBillingDate(renewalDate)} -
- )} - {isCreditFunded && sub.renewalCostMicrodollars != null && ( -
- Renewal cost:{' '} - - {formatMicrodollars(sub.renewalCostMicrodollars)} from credit balance - -
- )} - {hasUserRequestedSwitch && ( -
+ {hasUserRequestedSwitch ? ( + + Switching to {isCommit ? 'Standard' : 'Commit'} on{' '} - {formatBillingDate(sub.currentPeriodEnd)} -
- )} -
+ {formatBillingDate(sub.currentPeriodEnd)}. + + + ) : null} + + {showConversion ? ( + + +

+ You have an active Kilo Pass. Switch hosting to credit-funded billing to stop the + separate Stripe charge — your current period continues as-is. +

+
+ + +
+
+
+ ) : null} - {showConversion && ( -
-

- You have an active Kilo Pass. Switch hosting to credit-funded billing to stop the - separate Stripe charge — your current period continues as-is. -

-
- - -
-
- )} + -
+
{hasUserRequestedSwitch ? ( ) : ( )} - - {sub.hasStripeFunding && ( - - )} + ) : null} +
-
+ + { + if (!open && !isPending) setConfirmationAction(null); + }} + > + + + {activeConfirmation?.title} + {activeConfirmation?.description} + + + Cancel + + {isPending ? activeConfirmation?.pendingLabel : activeConfirmation?.confirmLabel} + + + + +
); } @@ -309,47 +356,39 @@ function ConvertingSubscriptionCard({ if (!sub) return null; return ( -
-
-
- 🦀 - KiloClaw Subscription -
- - - Switching to Credits - + +
+ + +
-
-
- Plan: {planLabel(sub.plan)} -
-
- Status:{' '} - - Switches to credit billing on {formatBillingDate(sub.currentPeriodEnd)} - -
-

+ + Your Stripe charge ends at the current period. After that, hosting renews from your credit balance. -

-
+ + -
-
-
+ ); } @@ -366,40 +405,42 @@ function CancelingSubscriptionCard({ if (!sub) return null; return ( -
-
-
- 🦀 - KiloClaw Subscription -
- + +
+ + +
-
-
- Plan: {planLabel(sub.plan)} -
-
- Status:{' '} - - Cancels on {formatBillingDate(sub.currentPeriodEnd)} - -
-
+ + + Your subscription cancels on{' '} + {formatBillingDate(sub.currentPeriodEnd)}. + Reactivate to keep it renewing. + + -
-
-
+ ); } @@ -414,80 +455,64 @@ function PastDueSubscriptionCard({ if (!sub) return null; const isCreditFunded = !sub.hasStripeFunding && sub.paymentSource === 'credits'; + const status: ShellStatus = sub.status === 'unpaid' ? 'unpaid' : 'past_due'; return ( -
-
-
- 🦀 - KiloClaw Subscription -
- + +
+ +
-
-
- Status: Payment Failed -
-

+ + Payment failed + {isCreditFunded ? 'Your credit balance is insufficient for the next renewal. Add credits to avoid service interruption.' : 'Your last payment failed. Update your payment method to avoid service interruption.'} -

-
+ + -
+ + +
{isCreditFunded ? ( ) : ( )}
-
+
); } export function SubscriptionCard({ billing, onCancelClick }: SubscriptionCardProps) { const trpc = useTRPC(); - const queryClient = useQueryClient(); const instanceId = billing.instance?.id ?? null; + const invalidate = useInvalidateKiloClawBilling(instanceId); + const reactivateMutation = useMutation( trpc.kiloclaw.reactivateSubscriptionAtInstance.mutationOptions() ); const portalMutation = useMutation(trpc.kiloclaw.getCustomerPortalUrl.mutationOptions()); - async function invalidateBillingQueries() { - if (!instanceId) return; - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getActivePersonalBillingStatus.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getPersonalBillingSummary.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.listPersonalSubscriptions.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getSubscriptionDetail.queryKey({ instanceId }), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getBillingHistory.queryKey({ instanceId }), - }), - ]); - } - function handleReactivate() { if (!instanceId || reactivateMutation.isPending) return; reactivateMutation.mutate( { instanceId }, { onSuccess: () => { - void invalidateBillingQueries(); + void invalidate(); }, } ); diff --git a/apps/web/src/components/subscriptions/DetailRow.tsx b/apps/web/src/components/subscriptions/DetailRow.tsx new file mode 100644 index 0000000000..ff348f67bf --- /dev/null +++ b/apps/web/src/components/subscriptions/DetailRow.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; + +import { cn } from '@/lib/utils'; + +type DetailRowProps = { + label: ReactNode; + value: ReactNode; + className?: string; + /** Apply tabular-nums to the value (use for currency, dates, counts). */ + numeric?: boolean; +}; + +/** + * Standard label-over-value row used across subscription detail surfaces. + * Pairs a muted label with a foreground value, optionally tabular for numbers. + */ +export function DetailRow({ label, value, className, numeric = false }: DetailRowProps) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx b/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx index 6c14c33e98..51908d7e66 100644 --- a/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx +++ b/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx @@ -1,16 +1,20 @@ 'use client'; import { useCallback, useState, type ReactNode } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { ArrowRight } from 'lucide-react'; import { toast } from 'sonner'; import KiloCrabIcon from '@/components/KiloCrabIcon'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { useRawTRPCClient, useTRPC } from '@/lib/trpc/utils'; import { DetailPageHeader } from '@/components/subscriptions/DetailPageHeader'; +import { DetailRow } from '@/components/subscriptions/DetailRow'; import { StripePortalLink } from '@/components/subscriptions/StripePortalLink'; import { BillingHistoryTable } from '@/components/subscriptions/BillingHistoryTable'; +import { ReferralRewardsSummary } from '@/app/(app)/claw/components/billing/ReferralRewardsSummary'; +import { useInvalidateKiloClawBilling } from '@/components/subscriptions/kiloclaw/useKiloClawBillingQueries'; import { AlertDialog, AlertDialogAction, @@ -53,7 +57,7 @@ type ConfirmationDetails = { export function KiloClawDetail({ instanceId }: { instanceId: string }) { const trpc = useTRPC(); const trpcClient = useRawTRPCClient(); - const queryClient = useQueryClient(); + const refreshData = useInvalidateKiloClawBilling(instanceId); const [confirmationAction, setConfirmationAction] = useState(null); const [pendingConfirmationAction, setPendingConfirmationAction] = @@ -72,20 +76,6 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) { resetKey: instanceId, }); - async function refreshData() { - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.listPersonalSubscriptions.queryKey(), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getSubscriptionDetail.queryKey({ instanceId }), - }), - queryClient.invalidateQueries({ - queryKey: trpc.kiloclaw.getBillingHistory.queryKey({ instanceId }), - }), - ]); - } - async function runAction(action: () => Promise, successMessage: string) { await action(); toast.success(successMessage); @@ -235,9 +225,9 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) { }); } - const primaryDetailRows: Array<{ label: string; value: string }> = [ + const primaryDetailRows: Array<{ label: string; value: string; numeric?: boolean }> = [ { label: 'Plan', value: capitalize(subscription.plan) }, - { label: 'Price', value: formatKiloclawPrice(subscription.plan) }, + { label: 'Price', value: formatKiloclawPrice(subscription.plan), numeric: true }, { label: 'Payment source', value: formatPaymentSummary({ @@ -248,25 +238,29 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) { { label: 'Next renewal', value: nextRenewalLabel === 'At your next renewal' ? '—' : nextRenewalLabel, + numeric: true, }, ]; - const secondaryDetailRows: Array<{ label: string; value: string }> = [ + type SecondaryRow = { label: string; value: string; numeric?: boolean }; + const secondaryRowSource: Array = [ subscription.commitEndsAt - ? { label: 'Commit ends', value: formatDateLabel(subscription.commitEndsAt) } + ? { label: 'Commit ends', value: formatDateLabel(subscription.commitEndsAt), numeric: true } : null, subscription.status === 'trialing' && subscription.trialEndsAt - ? { label: 'Trial ends', value: formatDateLabel(subscription.trialEndsAt) } + ? { label: 'Trial ends', value: formatDateLabel(subscription.trialEndsAt), numeric: true } : null, subscription.suspendedAt - ? { label: 'Suspended at', value: formatDateLabel(subscription.suspendedAt) } + ? { label: 'Suspended at', value: formatDateLabel(subscription.suspendedAt), numeric: true } : null, subscription.destructionDeadline ? { label: 'Destruction deadline', value: formatDateLabel(subscription.destructionDeadline), + numeric: true, } : null, - ].filter((row): row is { label: string; value: string } => row !== null); + ]; + const secondaryDetailRows = secondaryRowSource.filter((row): row is SecondaryRow => row !== null); return (
@@ -293,20 +287,30 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) {
{primaryDetailRows.map(row => ( - + ))}
{statusNote ? ( -
- {statusNote} -
+ + {statusNote} + ) : null} {secondaryDetailRows.length > 0 ? (
{secondaryDetailRows.map(row => ( - + ))}
) : null} @@ -319,26 +323,24 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) { {subscription.plan !== 'trial' ? ( hasUserRequestedSwitch ? ( ) : ( ) ) : null} {subscription.cancelAtPeriodEnd ? ( - + ) : ( )} @@ -362,6 +364,8 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) {
)} + + Billing history @@ -399,6 +403,7 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) { variant={confirmationDetails?.confirmVariant ?? 'default'} onClick={confirmSubscriptionAction} disabled={pendingConfirmationAction !== null} + aria-busy={pendingConfirmationAction !== null} > {pendingConfirmationAction !== null ? confirmationDetails?.pendingLabel @@ -419,12 +424,3 @@ function ConfirmationDetailRow({ label, value }: { label: string; value: string
); } - -function DetailRow({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} diff --git a/apps/web/src/components/subscriptions/kiloclaw/useKiloClawBillingQueries.ts b/apps/web/src/components/subscriptions/kiloclaw/useKiloClawBillingQueries.ts new file mode 100644 index 0000000000..e933d83330 --- /dev/null +++ b/apps/web/src/components/subscriptions/kiloclaw/useKiloClawBillingQueries.ts @@ -0,0 +1,39 @@ +'use client'; + +import { useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { useTRPC } from '@/lib/trpc/utils'; + +/** + * Returns a stable callback that invalidates every query keyed off a KiloClaw + * instance's billing/subscription state. Both the `/claw` dashboard card and + * the `/subscriptions/kiloclaw/:id` detail page need the exact same set of + * invalidations after a state-changing mutation; centralising them here keeps + * the two surfaces from drifting. + */ +export function useInvalidateKiloClawBilling(instanceId: string | null) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + return useCallback(async () => { + if (!instanceId) return; + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.kiloclaw.getActivePersonalBillingStatus.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.kiloclaw.getPersonalBillingSummary.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.kiloclaw.listPersonalSubscriptions.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: trpc.kiloclaw.getSubscriptionDetail.queryKey({ instanceId }), + }), + queryClient.invalidateQueries({ + queryKey: trpc.kiloclaw.getBillingHistory.queryKey({ instanceId }), + }), + ]); + }, [queryClient, trpc, instanceId]); +} From ab23708f0da3c76ada55a82fef59708ce955fced Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 10:43:46 +0200 Subject: [PATCH 15/32] chore(dev): tunnel and seed tooling for referrals testing - start-tunnel.ts: split TUNNEL_HOSTNAME into per-service hostnames (TUNNEL_APP_HOSTNAME, TUNNEL_KILOCLAW_HOSTNAME, TUNNEL_KILOCHAT_HOSTNAME) so the dev tunnel can route the web app, KiloClaw, and Kilo Chat to distinct hostnames; preserve the legacy single-hostname fallback. Also pull from .env.local in addition to .dev.vars and use a quoted-identifier helper when rewriting env files, so values containing special characters survive the round trip. - dev/seed/app/add-credits.ts: standalone seed script to top up a user's credit balance for end-to-end referral reward testing. - dev/seed/kiloclaw/fake-instance.ts: provision a fake KiloClaw instance with a chosen plan/payment source so referral lifecycle paths (signup, renewal, reward application) can be exercised locally without standing up real hosting. - dev/seed/lib/kiloclaw-referrals.ts: minor adjustments to align with the lifecycle changes in apps/web/src/lib/kiloclaw-referrals.ts. - services/kiloclaw DEVELOPMENT.md / README.md / .dev-start.conf.example: document the new tunnel hostnames and seed scripts. --- dev/local/scripts/start-tunnel.ts | 166 +++++++++++++++--- dev/seed/lib/kiloclaw-referrals.ts | 5 +- services/kiloclaw/DEVELOPMENT.md | 38 +++- services/kiloclaw/README.md | 6 + .../kiloclaw/scripts/.dev-start.conf.example | 17 +- 5 files changed, 199 insertions(+), 33 deletions(-) diff --git a/dev/local/scripts/start-tunnel.ts b/dev/local/scripts/start-tunnel.ts index 933009f74d..b417c40c21 100644 --- a/dev/local/scripts/start-tunnel.ts +++ b/dev/local/scripts/start-tunnel.ts @@ -5,10 +5,16 @@ import * as path from 'node:path'; const repoRoot = path.resolve(import.meta.dirname, '../../..'); const devVarsPath = path.join(repoRoot, 'services/kiloclaw/.dev.vars'); +const envLocalPath = path.join(repoRoot, '.env.local'); type TunnelConfig = { tunnelName: string; + tunnelConfig: string; tunnelHostname: string; + appHostname: string; + kiloclawHostname: string; + kiloChatHostname: string; + updateAppEnv: boolean; }; const DOCKER_HOST_INTERNAL = 'host.docker.internal'; @@ -39,29 +45,100 @@ function loadTunnelConfig(): TunnelConfig { return { tunnelName: merged['TUNNEL_NAME'] ?? '', + tunnelConfig: expandHome(merged['TUNNEL_CONFIG'] ?? ''), tunnelHostname: merged['TUNNEL_HOSTNAME'] ?? '', + appHostname: merged['TUNNEL_APP_HOSTNAME'] ?? merged['TUNNEL_HOSTNAME'] ?? '', + kiloclawHostname: merged['TUNNEL_KILOCLAW_HOSTNAME'] ?? merged['TUNNEL_HOSTNAME'] ?? '', + kiloChatHostname: merged['TUNNEL_KILOCHAT_HOSTNAME'] ?? merged['TUNNEL_HOSTNAME'] ?? '', + updateAppEnv: merged['TUNNEL_UPDATE_APP_ENV'] !== 'false', }; } +function expandHome(value: string): string { + if (value === '~') return os.homedir(); + if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2)); + return value; +} + +function originFromHostname(value: string): string | null { + const trimmed = value.trim().replace(/\/+$/, ''); + if (!trimmed) return null; + + try { + if (/^https?:\/\//.test(trimmed)) { + return new URL(trimmed).origin; + } + return new URL(`https://${trimmed}`).origin; + } catch { + throw new Error(`Invalid tunnel hostname: ${value}`); + } +} + function updateEnvValue(filePath: string, key: string, value: string): void { let content = ''; if (fs.existsSync(filePath)) { content = fs.readFileSync(filePath, 'utf-8'); } - const activePattern = new RegExp(`^${key}=.*`, 'm'); - const commentedPattern = new RegExp(`^# ${key}=.*`, 'm'); + const lines = content.split('\n'); + const nextLines: string[] = []; + let replaced = false; + let replacedComment = false; + + for (const [index, line] of lines.entries()) { + if (line === '' && index === lines.length - 1) continue; + const trimmed = line.trimStart(); + if (!replaced && line.startsWith(`${key}=`)) { + nextLines.push(`${key}=${value}`); + replaced = true; + continue; + } + if (!replaced && !replacedComment && trimmed.startsWith(`# ${key}=`)) { + nextLines.push(`${key}=${value}`); + replaced = true; + replacedComment = true; + continue; + } + if (line.startsWith(`${key}=`)) { + continue; + } + nextLines.push(line); + } + + if (!replaced) { + if (nextLines.length > 0 && nextLines[nextLines.length - 1] !== '') { + nextLines.push(''); + } + nextLines.push(`${key}=${value}`); + } + + fs.writeFileSync(filePath, `${nextLines.join('\n').replace(/\n+$/, '')}\n`); +} - if (activePattern.test(content)) { - content = content.replace(activePattern, `${key}=${value}`); - } else if (commentedPattern.test(content)) { - content = content.replace(commentedPattern, `${key}=${value}`); - } else { - content = content.endsWith('\n') || content === '' ? content : content + '\n'; - content += `${key}=${value}\n`; +function readEnvValueFromFile(filePath: string, key: string): string | null { + if (!fs.existsSync(filePath)) return null; + for (const line of fs.readFileSync(filePath, 'utf-8').split('\n')) { + if (line.startsWith(`${key}=`)) { + return line.slice(key.length + 1); + } } + return null; +} - fs.writeFileSync(filePath, content); +function appendEnvListValues(filePath: string, key: string, values: string[]): void { + const existing = readEnvValueFromFile(filePath, key); + const entries = new Set( + (existing ?? '') + .split(',') + .map(value => value.trim()) + .filter(Boolean) + ); + for (const value of values) { + if (value) entries.add(value); + } + if (entries.size > 0) { + updateEnvValue(filePath, key, [...entries].join(',')); + } } function loadKiloClawProvider(): string { @@ -155,36 +232,71 @@ if (provider === DOCKER_LOCAL_PROVIDER) { console.log(`Set KILOCHAT_BASE_URL=${kiloChatUrl}`); setInterval(() => undefined, 60_000); -} else { - if (spawnSync('cloudflared', ['version'], { stdio: 'ignore' }).error) { - console.error( - 'cloudflared not found on PATH. Install it:\n https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n brew install cloudflared' - ); - process.exit(1); - } +} else if (spawnSync('cloudflared', ['version'], { stdio: 'ignore' }).error) { + console.error( + 'cloudflared not found on PATH. Install it:\n https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n brew install cloudflared' + ); + process.exit(1); } -if (provider !== DOCKER_LOCAL_PROVIDER && config.tunnelName) { +if (provider !== DOCKER_LOCAL_PROVIDER && (config.tunnelName || config.tunnelConfig)) { const label = 'kiloclaw-tunnel'; - const child = spawn('cloudflared', ['tunnel', 'run', config.tunnelName], { + const args = config.tunnelConfig + ? ['tunnel', '--config', config.tunnelConfig, 'run'] + : ['tunnel', 'run', config.tunnelName]; + const child = spawn('cloudflared', args, { stdio: ['ignore', 'pipe', 'pipe'], }); trackChild(label, child); - console.log(`Named tunnel: ${config.tunnelName} -> ${config.tunnelHostname}`); + const appOrigin = originFromHostname(config.appHostname); + const kiloclawOrigin = originFromHostname(config.kiloclawHostname); + const kiloChatOrigin = originFromHostname(config.kiloChatHostname); - if (config.tunnelHostname) { - const apiUrl = `https://${config.tunnelHostname}/api/gateway/`; - const checkinUrl = `https://${config.tunnelHostname}/api/controller/checkin`; - const kiloChatUrl = `https://${config.tunnelHostname}`; + console.log( + `Named tunnel: ${config.tunnelConfig || config.tunnelName}` + + `${appOrigin ? `\n app -> ${appOrigin}` : ''}` + + `${kiloclawOrigin ? `\n kiloclaw -> ${kiloclawOrigin}` : ''}` + + `${kiloChatOrigin ? `\n kilochat -> ${kiloChatOrigin}` : ''}` + ); + + if (appOrigin) { + const apiUrl = `${appOrigin}/api/gateway/`; + updateEnvValue(devVarsPath, 'BACKEND_API_URL', appOrigin); updateEnvValue(devVarsPath, 'KILOCODE_API_BASE_URL', apiUrl); - updateEnvValue(devVarsPath, 'KILOCLAW_CHECKIN_URL', checkinUrl); - updateEnvValue(devVarsPath, 'KILOCHAT_BASE_URL', kiloChatUrl); + console.log(`Set BACKEND_API_URL=${appOrigin}`); console.log(`Set KILOCODE_API_BASE_URL=${apiUrl}`); + + if (config.updateAppEnv) { + updateEnvValue(envLocalPath, 'APP_URL_OVERRIDE', appOrigin); + updateEnvValue(envLocalPath, 'NEXTAUTH_URL', appOrigin); + console.log(`Set APP_URL_OVERRIDE=${appOrigin}`); + console.log(`Set NEXTAUTH_URL=${appOrigin}`); + } + } + + if (kiloclawOrigin) { + const checkinUrl = `${kiloclawOrigin}/api/controller/checkin`; + updateEnvValue(devVarsPath, 'KILOCLAW_CHECKIN_URL', checkinUrl); console.log(`Set KILOCLAW_CHECKIN_URL=${checkinUrl}`); - console.log(`Set KILOCHAT_BASE_URL=${kiloChatUrl}`); + + if (config.updateAppEnv) { + updateEnvValue(envLocalPath, 'KILOCLAW_API_URL', kiloclawOrigin); + console.log(`Set KILOCLAW_API_URL=${kiloclawOrigin}`); + } + } + + if (kiloChatOrigin) { + updateEnvValue(devVarsPath, 'KILOCHAT_BASE_URL', kiloChatOrigin); + console.log(`Set KILOCHAT_BASE_URL=${kiloChatOrigin}`); } + appendEnvListValues( + devVarsPath, + 'OPENCLAW_ALLOWED_ORIGINS', + [appOrigin, kiloclawOrigin, kiloChatOrigin].filter((origin): origin is string => !!origin) + ); + child.stdout.on('data', data => prefixAndWrite(label, data)); child.stderr.on('data', data => prefixAndWrite(label, data)); child.on('close', code => exitAndStopOthers(label, code)); diff --git a/dev/seed/lib/kiloclaw-referrals.ts b/dev/seed/lib/kiloclaw-referrals.ts index 89cc0903a6..3c35a3f49d 100644 --- a/dev/seed/lib/kiloclaw-referrals.ts +++ b/dev/seed/lib/kiloclaw-referrals.ts @@ -1,4 +1,5 @@ import { insertKiloClawSubscriptionChangeLog } from '@kilocode/db'; +import type { KiloClawPaymentSource, KiloClawSubscriptionStatus } from '@kilocode/db/schema-types'; import { credit_transactions, impact_advocate_participants, @@ -36,8 +37,8 @@ type PersonalSubscriptionFixture = { name?: string | null; organizationId?: string | null; plan: 'trial' | 'standard' | 'commit'; - status: 'trialing' | 'active' | 'past_due' | 'suspended' | 'canceled'; - paymentSource?: 'credits' | 'hybrid' | 'stripe'; + status: KiloClawSubscriptionStatus; + paymentSource?: KiloClawPaymentSource; currentPeriodStart?: string | null; currentPeriodEnd?: string | null; creditRenewalAt?: string | null; diff --git a/services/kiloclaw/DEVELOPMENT.md b/services/kiloclaw/DEVELOPMENT.md index 7d4e9d52c7..73c3f03b15 100644 --- a/services/kiloclaw/DEVELOPMENT.md +++ b/services/kiloclaw/DEVELOPMENT.md @@ -143,9 +143,41 @@ don't need to manage this manually. - **Free quick tunnel** (default): hostname changes on every restart. The script handles this automatically. -- **Named tunnel**: preconfigure in the Cloudflare dashboard for a persistent - hostname (e.g., `yourname.devclaw.dev`). Use `--tunnel-name ` or set - `TUNNEL_NAME` and `TUNNEL_HOSTNAME` in your config file. +- **Named tunnel**: preconfigure Cloudflare Tunnel/DNS for persistent + hostnames, then set `TUNNEL_NAME` or `TUNNEL_CONFIG` in your dev-start config + file. + +For a full local stack over HTTPS, prefer separate named-tunnel hostnames: + +```conf +# ~/.config/kiloclaw/dev-start.conf or services/kiloclaw/scripts/.dev-start.conf +TUNNEL_CONFIG=~/.cloudflared/accounts/kilo-local-dev.yml +TUNNEL_APP_HOSTNAME=app-dev.yourdomain.com +TUNNEL_KILOCLAW_HOSTNAME=claw-dev.yourdomain.com +TUNNEL_KILOCHAT_HOSTNAME=chat-dev.yourdomain.com +``` + +with cloudflared ingress similar to: + +```yaml +ingress: + - hostname: app-dev.yourdomain.com + service: http://localhost:3000 + - hostname: claw-dev.yourdomain.com + service: http://localhost:8795 + - hostname: chat-dev.yourdomain.com + service: http://localhost:8808 + - service: http_status:404 +``` + +When named tunnel hostnames are configured, `dev:start` writes: + +- `services/kiloclaw/.dev.vars`: `BACKEND_API_URL`, `KILOCODE_API_BASE_URL`, + `KILOCLAW_CHECKIN_URL`, `KILOCHAT_BASE_URL`, and appends the tunnel origins to + `OPENCLAW_ALLOWED_ORIGINS`. +- `.env.local`: `APP_URL_OVERRIDE`, `NEXTAUTH_URL`, and `KILOCLAW_API_URL`. + +Set `TUNNEL_UPDATE_APP_ENV=false` to leave `.env.local` untouched. ### If the tunnel isn't working diff --git a/services/kiloclaw/README.md b/services/kiloclaw/README.md index c7732e4c04..a8b2ced653 100644 --- a/services/kiloclaw/README.md +++ b/services/kiloclaw/README.md @@ -116,6 +116,12 @@ pnpm start # wrangler dev New provisions without an explicit provider use `KILOCLAW_DEFAULT_PROVIDER`. You can also pass `provider: "fly"` to the platform provision endpoint if you need Fly for a specific test. +To run the full local stack over stable Cloudflare Tunnel hostnames, set +`TUNNEL_CONFIG`, `TUNNEL_APP_HOSTNAME`, `TUNNEL_KILOCLAW_HOSTNAME`, and +optionally `TUNNEL_KILOCHAT_HOSTNAME` in `services/kiloclaw/scripts/.dev-start.conf` +or `~/.config/kiloclaw/dev-start.conf`, then run `pnpm dev:start kiloclaw`. See +`DEVELOPMENT.md` for the ingress example and generated env vars. + Rebuild the image after controller or Dockerfile changes, then restart or redeploy the instance so the container is recreated with the new image/env/config. A plain `start` leaves an already-running docker-local container intact. ## Fly Provider diff --git a/services/kiloclaw/scripts/.dev-start.conf.example b/services/kiloclaw/scripts/.dev-start.conf.example index ae8e75e549..c6e1331441 100644 --- a/services/kiloclaw/scripts/.dev-start.conf.example +++ b/services/kiloclaw/scripts/.dev-start.conf.example @@ -5,9 +5,24 @@ # ~/.config/kiloclaw/dev-start.conf (shared across all worktrees) # kiloclaw/scripts/.dev-start.conf (per-worktree override, gitignored) -# Named Cloudflare tunnel (leave empty for temporary quick tunnel) +# Named Cloudflare tunnel (leave empty for temporary quick tunnel). +# Use TUNNEL_CONFIG when credentials live outside ~/.cloudflared or when a +# config file defines multiple ingress hostnames. # TUNNEL_NAME= # tunnel +# TUNNEL_CONFIG= # ~/.cloudflared/accounts/kilo-local-dev.yml +# +# Single-hostname legacy mode. Requires cloudflared ingress path routing if +# the same hostname serves the app, KiloClaw worker, and Kilo Chat. # TUNNEL_HOSTNAME= # tunnel.yourdomain.com +# +# Multi-hostname mode for running the full local stack over a named tunnel. +# TUNNEL_APP_HOSTNAME= # app-dev.yourdomain.com -> localhost:3000 +# TUNNEL_KILOCLAW_HOSTNAME= # claw-dev.yourdomain.com -> localhost:8795 +# TUNNEL_KILOCHAT_HOSTNAME= # chat-dev.yourdomain.com -> localhost:8808 +# +# When true (default), named tunnel mode also writes .env.local values: +# APP_URL_OVERRIDE, NEXTAUTH_URL, and KILOCLAW_API_URL. +# TUNNEL_UPDATE_APP_ENV=true # RSA private key for agent env var encryption (get from 1Password, engineering vault) # AGENT_ENV_VARS_PRIVATE_KEY= From 1bd8b1f50b045b1919941478dacfce0e7bb8b699 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 10:55:18 +0200 Subject: [PATCH 16/32] feat(impact): unified debug logger and outbound response logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tighten the manual-test loop for the Impact integration. Previously two independent gates (IMPACT_ADVOCATE_DEBUG_LOGGING and IMPACT_REFERRAL_DEBUG) controlled overlapping log paths, and several outbound calls dropped the response on the floor — making it hard to tell from logs whether a payload actually landed at Impact. - impact-debug.ts: single isImpactDebugLoggingEnabled() gate that honors NODE_ENV=development, IMPACT_REFERRAL_DEBUG=true, and the legacy IMPACT_ADVOCATE_DEBUG_LOGGING flag. Add truncateForLog(body, 500) so Impact response bodies (which Impact uses to convey rejection reasons) can be safely logged without flooding output. - impact-advocate.ts: route logImpactAdvocateDebug through the unified logger and log the Register Participant response (url, ok, statusCode, truncated responseBody) plus a network-error branch. Authorization header continues to be redacted as 'not_logged'; cookies stay redacted via getDebuggableRegisterParticipantPayload. - impact.ts: log the full conversion request URL up front (was path-only), log the response body and error message on failure for sendImpactConversionPayload, and add request + raw-result logging around resolveImpactSubmissionUri (previously had zero logging). - impact-advocate.test.ts: spy on console.log instead of console.warn to match the unified logger's output channel; existing redaction assertions are unchanged. --- apps/web/src/lib/impact-advocate.test.ts | 4 ++-- apps/web/src/lib/impact-advocate.ts | 23 ++++++++++++++++------- apps/web/src/lib/impact-debug.ts | 24 +++++++++++++++++++++--- apps/web/src/lib/impact.ts | 21 +++++++++++++++++++-- 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/apps/web/src/lib/impact-advocate.test.ts b/apps/web/src/lib/impact-advocate.test.ts index 0605837dba..4e4a6fbf80 100644 --- a/apps/web/src/lib/impact-advocate.test.ts +++ b/apps/web/src/lib/impact-advocate.test.ts @@ -68,7 +68,7 @@ describe('impact advocate', () => { process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; process.env.IMPACT_ADVOCATE_DEBUG_LOGGING = 'true'; - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); const { buildImpactAdvocateRegisterParticipantPayload, @@ -84,7 +84,7 @@ describe('impact advocate', () => { new Date('2026-04-23T12:00:00.000Z') ); - const loggedData = JSON.stringify(warnSpy.mock.calls); + const loggedData = JSON.stringify(logSpy.mock.calls); expect(loggedData).toContain('[impact-advocate] built register participant payload'); expect(loggedData).toContain('[impact-advocate] issued verified access token'); expect(loggedData).toContain('referee@example.com'); diff --git a/apps/web/src/lib/impact-advocate.ts b/apps/web/src/lib/impact-advocate.ts index 081b4a8fcf..d47c0ac6c3 100644 --- a/apps/web/src/lib/impact-advocate.ts +++ b/apps/web/src/lib/impact-advocate.ts @@ -11,6 +11,7 @@ import { IMPACT_ADVOCATE_TENANT_ALIAS, IMPACT_ADVOCATE_WIDGET_ID, } from '@/lib/config.server'; +import { logImpactReferralDebug, truncateForLog } from '@/lib/impact-debug'; export const IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID = '51699'; export const IMPACT_ADVOCATE_DEFAULT_WIDGET_ID = 'p/51699/w/referrerWidget'; @@ -67,14 +68,12 @@ function getDebuggableRegisterParticipantPayload( }; } -function isImpactAdvocateDebugLoggingEnabled(): boolean { - const value = process.env.IMPACT_ADVOCATE_DEBUG_LOGGING?.trim().toLowerCase(); - return value === 'true' || value === '1' || value === 'yes'; -} - function logImpactAdvocateDebug(message: string, details: Record): void { - if (!isImpactAdvocateDebugLoggingEnabled()) return; - console.warn(`${message} ${JSON.stringify(details)}`); + // Delegates to the unified Impact debug logger so a single env + // (IMPACT_REFERRAL_DEBUG=true, or NODE_ENV=development) lights up every + // outbound Impact call site. IMPACT_ADVOCATE_DEBUG_LOGGING is still + // honored as a legacy alias inside the unified gate. + logImpactReferralDebug(message, details); } function getImpactAdvocateWidgetPath(widgetId: string, programId: string): string { @@ -195,6 +194,13 @@ export async function sendImpactAdvocateRegisterParticipantPayload( }); const responseBody = await response.text(); + logImpactAdvocateDebug('[impact-advocate] register participant response', { + url, + ok: response.ok, + statusCode: response.status, + responseBody: truncateForLog(responseBody), + }); + if (response.ok) { return { ok: true, @@ -210,6 +216,9 @@ export async function sendImpactAdvocateRegisterParticipantPayload( responseBody, }; } catch (error) { + logImpactAdvocateDebug('[impact-advocate] register participant network error', { + error: error instanceof Error ? error.message : String(error), + }); return { ok: false, failureKind: 'network', diff --git a/apps/web/src/lib/impact-debug.ts b/apps/web/src/lib/impact-debug.ts index 3b8582718c..a634e6bc9c 100644 --- a/apps/web/src/lib/impact-debug.ts +++ b/apps/web/src/lib/impact-debug.ts @@ -1,10 +1,28 @@ +/** + * Returns true when the unified Impact debug logger should emit. Honors: + * - NODE_ENV === 'development' (always-on locally) + * - IMPACT_REFERRAL_DEBUG=true (server-side opt-in for staging/prod tests) + * - IMPACT_ADVOCATE_DEBUG_LOGGING=true|1|yes (legacy flag still honored) + */ +export function isImpactDebugLoggingEnabled(): boolean { + if (process.env.NODE_ENV === 'development') return true; + if (process.env.IMPACT_REFERRAL_DEBUG === 'true') return true; + const advocate = process.env.IMPACT_ADVOCATE_DEBUG_LOGGING?.trim().toLowerCase(); + return advocate === 'true' || advocate === '1' || advocate === 'yes'; +} + export function logImpactReferralDebug(message: string, fields?: Record): void { - if (process.env.NODE_ENV !== 'development' && process.env.IMPACT_REFERRAL_DEBUG !== 'true') { - return; - } + if (!isImpactDebugLoggingEnabled()) return; console.log('[impact-referral-debug]', message, { at: new Date().toISOString(), ...(fields ?? {}), }); } + +/** Truncate a response body for safe logging. Impact responses can be large. */ +export function truncateForLog(body: string | null | undefined, max = 500): string | null { + if (body == null) return null; + if (body.length <= max) return body; + return `${body.slice(0, max)}… [truncated ${body.length - max} chars]`; +} diff --git a/apps/web/src/lib/impact.ts b/apps/web/src/lib/impact.ts index f779f0b37f..1b3e7fcaea 100644 --- a/apps/web/src/lib/impact.ts +++ b/apps/web/src/lib/impact.ts @@ -2,7 +2,7 @@ import 'server-only'; import { createHash } from 'crypto'; import { IMPACT_ACCOUNT_SID, IMPACT_AUTH_TOKEN, IMPACT_CAMPAIGN_ID } from '@/lib/config.server'; -import { logImpactReferralDebug } from '@/lib/impact-debug'; +import { logImpactReferralDebug, truncateForLog } from '@/lib/impact-debug'; const IMPACT_REVERSAL_DISPOSITION_CODE = 'REJECTED'; @@ -547,9 +547,11 @@ function getNormalizedStatus(value: unknown): string | null { export async function sendImpactConversionPayload( payload: ImpactConversionPayload ): Promise { + const conversionPath = `/Advertisers/${IMPACT_ACCOUNT_SID}/Conversions`; logImpactReferralDebug('Sending Impact conversion payload', { actionTrackerId: payload.ActionTrackerId, orderId: payload.OrderId, + url: buildImpactUrl(conversionPath), clickIdPresent: Boolean(payload.ClickId?.trim()), customerIdPresent: Boolean(payload.CustomerId?.trim()), customerEmailHashPresent: Boolean(payload.CustomerEmail?.trim()), @@ -561,7 +563,7 @@ export async function sendImpactConversionPayload( const result = await sendImpactRequest({ method: 'POST', - path: `/Advertisers/${IMPACT_ACCOUNT_SID}/Conversions`, + path: conversionPath, body: JSON.stringify(payload), contentType: 'application/json', }); @@ -573,6 +575,8 @@ export async function sendImpactConversionPayload( delivery: result.ok ? (result.skipped ?? result.delivery ?? null) : null, failureKind: result.ok ? null : result.failureKind, statusCode: result.ok ? null : (result.statusCode ?? null), + responseBody: result.ok ? null : truncateForLog(result.responseBody ?? null), + error: result.ok ? null : (result.error ?? null), }); if ( @@ -602,10 +606,23 @@ export async function sendImpactConversionPayload( export async function resolveImpactSubmissionUri( submissionUri: string ): Promise { + logImpactReferralDebug('Resolving Impact submission URI', { + submissionUri, + url: buildImpactUrl(submissionUri), + impactConfigured: isImpactConfigured(), + }); const result = await sendImpactRequest({ method: 'GET', path: submissionUri, }); + logImpactReferralDebug('Impact submission URI resolution raw result', { + submissionUri, + ok: result.ok, + delivery: result.ok ? (result.skipped ?? result.delivery ?? null) : null, + failureKind: result.ok ? null : result.failureKind, + statusCode: result.ok ? null : (result.statusCode ?? null), + responseBody: truncateForLog(result.ok ? result.responseBody : result.responseBody), + }); if (!result.ok) { return result.failureKind === 'network' From 6b83cf1f204194bae4b134ea7d8c39875ee83923 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 13:13:56 +0200 Subject: [PATCH 17/32] fix(impact-advocate): point Register Participant at SaaSquatch Upsert User MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live registration was returning 404 from Impact's Kong gateway with 'no Route matched with those values'. The integration spec in fact documents the SaaSquatch Upsert User endpoint, not a generic Impact participants endpoint: PUT https://app.referralsaasquatch.com/api/v1/{tenantAlias}\ /open/account/{accountId}/user/{userId} Per the program spec, accountId and userId are both the user's plain email — these are URL-encoded because the path segment contains '@'. Changes: - IMPACT_ADVOCATE_API_BASE_URL env var (default https://app.referralsaasquatch.com) so a sandbox/proxy host can be injected without a code change. - getImpactAdvocateRegisterParticipantUrl now takes the payload so it can pull accountId/userId into the path; trims trailing slashes on the base URL and url-encodes the segments. - HTTP method flipped from POST to PUT, matching SaaSquatch's upsert-user verb. - normalizeAdvocateLocale converts BCP 47 'en-US' (what we get from Accept-Language) to SaaSquatch's required 'en_US' format. Applied at payload-build time so the persisted attempt body matches the wire format on retry. - Tests updated to assert the new URL/method/locale; all 14 advocate + referral tests pass. --- apps/web/src/lib/config.server.ts | 2 ++ apps/web/src/lib/impact-advocate.test.ts | 3 +- apps/web/src/lib/impact-advocate.ts | 42 ++++++++++++++++++++---- apps/web/src/lib/impact-referral.test.ts | 7 ++-- 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/apps/web/src/lib/config.server.ts b/apps/web/src/lib/config.server.ts index 83c58a9742..202352cdf2 100644 --- a/apps/web/src/lib/config.server.ts +++ b/apps/web/src/lib/config.server.ts @@ -48,6 +48,8 @@ export const IMPACT_ADVOCATE_PROGRAM_ID = getEnvVariable('IMPACT_ADVOCATE_PROGRA export const IMPACT_ADVOCATE_ACCOUNT_SID = getEnvVariable('IMPACT_ADVOCATE_ACCOUNT_SID') || ''; export const IMPACT_ADVOCATE_AUTH_TOKEN = getEnvVariable('IMPACT_ADVOCATE_AUTH_TOKEN') || ''; export const IMPACT_ADVOCATE_WIDGET_ID = getEnvVariable('IMPACT_ADVOCATE_WIDGET_ID') || ''; +export const IMPACT_ADVOCATE_API_BASE_URL = + getEnvVariable('IMPACT_ADVOCATE_API_BASE_URL') || 'https://app.referralsaasquatch.com'; export const IMPACT_ADVOCATE_DEBUG_LOGGING = getEnvVariable('IMPACT_ADVOCATE_DEBUG_LOGGING') === 'true'; diff --git a/apps/web/src/lib/impact-advocate.test.ts b/apps/web/src/lib/impact-advocate.test.ts index 4e4a6fbf80..746cfcfbf7 100644 --- a/apps/web/src/lib/impact-advocate.test.ts +++ b/apps/web/src/lib/impact-advocate.test.ts @@ -45,7 +45,8 @@ describe('impact advocate', () => { programId: '51699', email: 'referee@example.com', cookies: 'opaque-cookie-value', - locale: 'en-US', + // SaaSquatch wants en_US, not en-US. + locale: 'en_US', countryCode: 'US', }); }); diff --git a/apps/web/src/lib/impact-advocate.ts b/apps/web/src/lib/impact-advocate.ts index d47c0ac6c3..4cb9a5c942 100644 --- a/apps/web/src/lib/impact-advocate.ts +++ b/apps/web/src/lib/impact-advocate.ts @@ -6,6 +6,7 @@ import type { User } from '@kilocode/db/schema'; import { IMPACT_ACCOUNT_SID, IMPACT_ADVOCATE_ACCOUNT_SID, + IMPACT_ADVOCATE_API_BASE_URL, IMPACT_ADVOCATE_AUTH_TOKEN, IMPACT_ADVOCATE_PROGRAM_ID, IMPACT_ADVOCATE_TENANT_ALIAS, @@ -13,6 +14,17 @@ import { } from '@/lib/config.server'; import { logImpactReferralDebug, truncateForLog } from '@/lib/impact-debug'; +/** + * SaaSquatch / Impact Advocate expects locale tags formatted as `en_US`, + * not the BCP 47 `en-US` we get from Accept-Language. Normalize once here + * so the value is consistent both on the wire and in the persisted payload. + */ +function normalizeAdvocateLocale(locale: string | null | undefined): string | null { + const trimmed = locale?.trim(); + if (!trimmed) return null; + return trimmed.replace(/-/g, '_'); +} + export const IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID = '51699'; export const IMPACT_ADVOCATE_DEFAULT_WIDGET_ID = 'p/51699/w/referrerWidget'; const IMPACT_ADVOCATE_WIDGET_NAME = 'referrerWidget'; @@ -129,13 +141,14 @@ export function buildImpactAdvocateRegisterParticipantPayload(params: { countryCode?: string | null; }): ImpactAdvocateRegisterParticipantPayload { const config = getImpactAdvocateConfig(); + const normalizedLocale = normalizeAdvocateLocale(params.locale); const payload: ImpactAdvocateRegisterParticipantPayload = { id: params.user.google_user_email, accountId: params.user.google_user_email, programId: config?.programId ?? IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID, email: params.user.google_user_email, cookies: params.referralCookieValue, - ...(params.locale ? { locale: params.locale } : {}), + ...(normalizedLocale ? { locale: normalizedLocale } : {}), ...(params.countryCode ? { countryCode: params.countryCode } : {}), }; @@ -152,10 +165,27 @@ function getImpactAdvocateAuthorizationHeader( return `Basic ${Buffer.from(`${config.accountSid}:${config.authToken}`).toString('base64')}`; } +function trimTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ''); +} + +/** + * SaaSquatch (Impact Advocate) Upsert User REST endpoint. + * + * PUT {base}/api/v1/{tenantAlias}/open/account/{accountId}/user/{userId} + * + * accountId and userId are both the user's plain email per the program's + * integration spec; we URL-encode them because the path segment contains '@'. + */ function getImpactAdvocateRegisterParticipantUrl( - config: NonNullable> + config: NonNullable>, + payload: ImpactAdvocateRegisterParticipantPayload ): string { - return `https://api.impact.com/Advocate/${config.tenantAlias}/Programs/${config.programId}/Participants`; + const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); + const tenant = encodeURIComponent(config.tenantAlias); + const accountId = encodeURIComponent(payload.accountId); + const userId = encodeURIComponent(payload.id); + return `${base}/api/v1/${tenant}/open/account/${accountId}/user/${userId}`; } export async function sendImpactAdvocateRegisterParticipantPayload( @@ -171,10 +201,10 @@ export async function sendImpactAdvocateRegisterParticipantPayload( } try { - const url = getImpactAdvocateRegisterParticipantUrl(config); + const url = getImpactAdvocateRegisterParticipantUrl(config, payload); logImpactAdvocateDebug('[impact-advocate] sending register participant request', { url, - method: 'POST', + method: 'PUT', headers: { Authorization: 'not_logged', Accept: 'application/json', @@ -184,7 +214,7 @@ export async function sendImpactAdvocateRegisterParticipantPayload( }); const response = await fetch(url, { - method: 'POST', + method: 'PUT', headers: { Authorization: getImpactAdvocateAuthorizationHeader(config), Accept: 'application/json', diff --git a/apps/web/src/lib/impact-referral.test.ts b/apps/web/src/lib/impact-referral.test.ts index 05072c3e60..b750707de9 100644 --- a/apps/web/src/lib/impact-referral.test.ts +++ b/apps/web/src/lib/impact-referral.test.ts @@ -89,10 +89,11 @@ describe('impact referral participant registration dispatch', () => { expect(attempt.next_retry_at).toBeNull(); expect(attempt.response_status_code).toBe(200); + const encodedEmail = encodeURIComponent(user.google_user_email); expect(fetchMock).toHaveBeenCalledWith( - 'https://api.impact.com/Advocate/tenant-alias/Programs/51699/Participants', + `https://app.referralsaasquatch.com/api/v1/tenant-alias/open/account/${encodedEmail}/user/${encodedEmail}`, expect.objectContaining({ - method: 'POST', + method: 'PUT', headers: expect.objectContaining({ Authorization: 'Basic ' + @@ -112,7 +113,7 @@ describe('impact referral participant registration dispatch', () => { programId: '51699', email: user.google_user_email, cookies: 'sq-cookie', - locale: 'en-US', + locale: 'en_US', countryCode: 'US', }); }); From 9b2818c0f0cb159e4ea1953aece1f225b6077ecc Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 13:20:06 +0200 Subject: [PATCH 18/32] fix(impact-advocate): drop programId and sanitize payload at send time Retry hit a second SaaSquatch rejection: 400 INVALID_JSON_REQUEST 'Unrecognized field programId' Per the Upsert User integration spec, SaaSquatch validates the body against a strict allow-list (id, accountId, email, cookies + firstName, lastName, locale, countryCode, segments, customFields). Any extra field is rejected with INVALID_JSON_REQUEST. The same retry also re-sent locale='en-US' from the original persisted attempt, because dispatch reads request_payload off disk; build-time locale normalisation never reaches retried rows. Both problems share a fix: sanitize the payload at the moment we hit the wire instead of at build time only. - ImpactAdvocateRegisterParticipantPayload type drops programId and adds firstName, lastName, segments, customFields per the spec. - buildImpactAdvocateRegisterParticipantPayload no longer sets programId. - New sanitizeRegisterParticipantPayloadForWire allow-list filter runs inside sendImpactAdvocateRegisterParticipantPayload right before fetch. It strips unknown fields (e.g. legacy programId) and re-runs locale normalisation (en-US -> en_US) so previously persisted attempts retry cleanly without a data migration. - isImpactAdvocateRegisterParticipantPayload no longer requires programId; it asserts only the SaaSquatch-required fields and tolerates extras (which the sanitiser will drop). - New regression test covers the exact retry shape: legacy programId, BCP 47 locale, and a junk extra field; sanitiser must produce SaaSquatch-acceptable JSON. Also flips the persisted attempt + participant rows from 'failed' back to 'queued'/'pending' (done out-of-band via psql at the user's request) so the next cron tick exercises the fixed path. --- apps/web/src/lib/impact-advocate.test.ts | 27 +++++++++- apps/web/src/lib/impact-advocate.ts | 63 ++++++++++++++++++++++-- apps/web/src/lib/impact-referral.test.ts | 1 - apps/web/src/lib/impact-referral.ts | 4 +- 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/apps/web/src/lib/impact-advocate.test.ts b/apps/web/src/lib/impact-advocate.test.ts index 746cfcfbf7..dc9ee7a63a 100644 --- a/apps/web/src/lib/impact-advocate.test.ts +++ b/apps/web/src/lib/impact-advocate.test.ts @@ -42,7 +42,6 @@ describe('impact advocate', () => { ).toEqual({ id: 'referee@example.com', accountId: 'referee@example.com', - programId: '51699', email: 'referee@example.com', cookies: 'opaque-cookie-value', // SaaSquatch wants en_US, not en-US. @@ -131,4 +130,30 @@ describe('impact advocate', () => { exp: Math.floor(new Date('2026-04-23T12:00:00.000Z').getTime() / 1000) + 60 * 60, }); }); + + it('strips legacy programId and normalises locale at send time', async () => { + const { sanitizeRegisterParticipantPayloadForWire } = await import('@/lib/impact-advocate'); + + // Legacy persisted shape: extra programId, BCP 47 locale, plus an unknown + // garbage field. Sanitiser must produce SaaSquatch-acceptable JSON. + const sanitized = sanitizeRegisterParticipantPayloadForWire({ + id: 'referee@example.com', + accountId: 'referee@example.com', + email: 'referee@example.com', + cookies: 'sq-cookie', + locale: 'en-US', + countryCode: 'US', + programId: '51699', + garbage: 'should be dropped', + }); + + expect(sanitized).toEqual({ + id: 'referee@example.com', + accountId: 'referee@example.com', + email: 'referee@example.com', + cookies: 'sq-cookie', + locale: 'en_US', + countryCode: 'US', + }); + }); }); diff --git a/apps/web/src/lib/impact-advocate.ts b/apps/web/src/lib/impact-advocate.ts index 4cb9a5c942..d0e8e60a8d 100644 --- a/apps/web/src/lib/impact-advocate.ts +++ b/apps/web/src/lib/impact-advocate.ts @@ -37,16 +37,66 @@ export type ImpactAdvocateIdentityPayload = { referable: boolean; }; +/** + * SaaSquatch / Impact Advocate Upsert User accepts a strict allow-list of + * fields. Per the program integration spec, these are the only keys SaaSquatch + * will accept; any extra field is rejected with `INVALID_JSON_REQUEST`. + * + * Required: id, accountId, email, cookies. + * Optional: firstName, lastName, locale, countryCode, segments, customFields. + * + * Note: `programId` is intentionally NOT part of this type. Earlier code + * persisted it into request_payload rows; sanitizeRegisterParticipantPayloadForWire + * strips it (and any other unknown field) before the request goes out, so old + * rows can still be retried without a data migration. + */ export type ImpactAdvocateRegisterParticipantPayload = { id: string; accountId: string; - programId: string; email: string; cookies: string; + firstName?: string; + lastName?: string; locale?: string; countryCode?: string; + segments?: string[]; + customFields?: Record; }; +const REGISTER_PARTICIPANT_ALLOWED_FIELDS = new Set([ + 'id', + 'accountId', + 'email', + 'cookies', + 'firstName', + 'lastName', + 'locale', + 'countryCode', + 'segments', + 'customFields', +]); + +/** + * Allow-list filter applied at the moment we hit the wire. Drops anything + * SaaSquatch would reject and re-normalises locale (`en-US` -> `en_US`) so + * persisted rows from before the locale fix retry cleanly. + */ +export function sanitizeRegisterParticipantPayloadForWire( + payload: Record +): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(payload)) { + if (!REGISTER_PARTICIPANT_ALLOWED_FIELDS.has(key)) continue; + if (key === 'locale' && typeof value === 'string') { + const normalized = normalizeAdvocateLocale(value); + if (normalized) sanitized[key] = normalized; + continue; + } + sanitized[key] = value; + } + return sanitized; +} + type ImpactAdvocateVerifiedAccessTokenPayload = { user: ImpactAdvocateIdentityPayload; exp: number; @@ -140,12 +190,10 @@ export function buildImpactAdvocateRegisterParticipantPayload(params: { locale?: string | null; countryCode?: string | null; }): ImpactAdvocateRegisterParticipantPayload { - const config = getImpactAdvocateConfig(); const normalizedLocale = normalizeAdvocateLocale(params.locale); const payload: ImpactAdvocateRegisterParticipantPayload = { id: params.user.google_user_email, accountId: params.user.google_user_email, - programId: config?.programId ?? IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID, email: params.user.google_user_email, cookies: params.referralCookieValue, ...(normalizedLocale ? { locale: normalizedLocale } : {}), @@ -202,6 +250,9 @@ export async function sendImpactAdvocateRegisterParticipantPayload( try { const url = getImpactAdvocateRegisterParticipantUrl(config, payload); + const sanitizedPayload = sanitizeRegisterParticipantPayloadForWire( + payload as unknown as Record + ); logImpactAdvocateDebug('[impact-advocate] sending register participant request', { url, method: 'PUT', @@ -210,7 +261,9 @@ export async function sendImpactAdvocateRegisterParticipantPayload( Accept: 'application/json', 'Content-Type': 'application/json', }, - payload: getDebuggableRegisterParticipantPayload(payload), + payload: getDebuggableRegisterParticipantPayload( + sanitizedPayload as ImpactAdvocateRegisterParticipantPayload + ), }); const response = await fetch(url, { @@ -220,7 +273,7 @@ export async function sendImpactAdvocateRegisterParticipantPayload( Accept: 'application/json', 'Content-Type': 'application/json', }, - body: JSON.stringify(payload), + body: JSON.stringify(sanitizedPayload), }); const responseBody = await response.text(); diff --git a/apps/web/src/lib/impact-referral.test.ts b/apps/web/src/lib/impact-referral.test.ts index b750707de9..dd357473bc 100644 --- a/apps/web/src/lib/impact-referral.test.ts +++ b/apps/web/src/lib/impact-referral.test.ts @@ -110,7 +110,6 @@ describe('impact referral participant registration dispatch', () => { expect(JSON.parse(String(requestBody))).toEqual({ id: user.google_user_email, accountId: user.google_user_email, - programId: '51699', email: user.google_user_email, cookies: 'sq-cookie', locale: 'en_US', diff --git a/apps/web/src/lib/impact-referral.ts b/apps/web/src/lib/impact-referral.ts index 66396ca9e2..2bc7152661 100644 --- a/apps/web/src/lib/impact-referral.ts +++ b/apps/web/src/lib/impact-referral.ts @@ -70,10 +70,12 @@ function isImpactAdvocateRegisterParticipantPayload( return false; } + // Only assert the SaaSquatch-required fields. Extra keys (e.g. legacy + // `programId` rows persisted before the endpoint fix) are tolerated here + // and stripped at send time by sanitizeRegisterParticipantPayloadForWire. return ( typeof value.id === 'string' && typeof value.accountId === 'string' && - typeof value.programId === 'string' && typeof value.email === 'string' && typeof value.cookies === 'string' && (value.locale === undefined || typeof value.locale === 'string') && From 862e5eb918fd7702d17fbaa5c15e11ca76cc6163 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 13:36:00 +0200 Subject: [PATCH 19/32] fix(impact-advocate): persist SaaSquatch referral code so advocates resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Kilo-side advocate-resolution lookup at kiloclaw-referrals.ts:275 joins kiloclaw_attribution_touches.rs_code (the rsCode parsed from the referee's referral URL) against impact_advocate_participants.opaque_referral_identifier. In production that join could never match because the dispatcher never wrote the SaaSquatch-issued referral code anywhere on Kilo's side: it threw away the response body after using it for state transitions. The only rows where the lookup ever succeeded were synthetic seed rows that populated both columns with matching UUIDs. End-to-end manifestation: referrerUserId came back null on every real paid conversion, silently undercounting attribution on the Kilo side even when SaaSquatch had it correct. Fix: - impact-advocate.ts adds extractAdvocateReferralCodeFromUpsertResponse (pure parser, returns the program-scoped code or null on any malformed / missing input) and getImpactAdvocateProgramId() so callers don't duplicate the env-default fallback. - dispatchImpactAdvocateRegistrationAttemptById extracts the code on a successful upsert and includes it in the participant UPDATE inside the same transaction. A pre-check guards the UQ_impact_advocate_participants_opaque_referral_identifier unique constraint: if another participant already holds the candidate code (vanishingly unlikely under SaaSquatch's per-tenant uniqueness guarantee, but still constraint-protected), the new row keeps its prior identifier and the rest of the success state is still recorded — so we don't loop forever in retry on a real collision. - New rule 50 in .specs/kiloclaw-referrals.md captures the requirement; rules 50-118 in the body shifted to 51-119. Failure-mode rules and changelog untouched. - Test coverage: unit tests for the parser (program-scoped extraction, malformed input, missing program, non-string codes) plus an integration test that asserts the participant row receives the parsed code on success, and a second integration test that asserts the conflict-skip path leaves both rows valid. Existing rows on disk were backfilled out-of-band by reading response_payload->>'responseBody' and copying referralCodes['51699'] into opaque_referral_identifier (skipping rows that would violate the unique constraint). Sample SQL block lives in the prior message exchange; not a migration because this is dev-data correction, not a schema change. --- .specs/kiloclaw-referrals.md | 149 ++++++++++++----------- apps/web/src/lib/impact-advocate.test.ts | 46 +++++++ apps/web/src/lib/impact-advocate.ts | 34 ++++++ apps/web/src/lib/impact-referral.test.ts | 112 ++++++++++++++++- apps/web/src/lib/impact-referral.ts | 53 +++++++- 5 files changed, 318 insertions(+), 76 deletions(-) diff --git a/.specs/kiloclaw-referrals.md b/.specs/kiloclaw-referrals.md index 253ef0fc27..de8239e65e 100644 --- a/.specs/kiloclaw-referrals.md +++ b/.specs/kiloclaw-referrals.md @@ -261,214 +261,223 @@ interventions, and non-KiloClaw purchases are out of scope. 49. Register Participant requests MUST include plain-text email only as the Advocate contact email. +50. On a successful Register Participant response, the system MUST persist the program-scoped + referral code returned in `referralCodes[]` against the participant record so + inbound referral touches can resolve the originating Advocate user. Persistence MUST be + idempotent: re-running registration for the same participant MUST NOT corrupt or duplicate the + code. If another participant already holds the same code (vanishingly unlikely under + SaaSquatch's per-tenant uniqueness guarantee, but constraint-protected on the Kilo side), the + new participant's code MUST NOT be persisted; the rest of the registration success state MUST + still be recorded. + ### Referee Eligibility -50. A referee MUST be a brand-new Kilo account to qualify for referral rewards. +51. A referee MUST be a brand-new Kilo account to qualify for referral rewards. -51. Existing users MUST NOT qualify as referees, even if they later click a referral link. +52. Existing users MUST NOT qualify as referees, even if they later click a referral link. -52. Adding an auth provider to an existing Kilo user MUST NOT qualify as a brand-new Kilo account. +53. Adding an auth provider to an existing Kilo user MUST NOT qualify as a brand-new Kilo account. -53. Previously deleted users MUST NOT qualify as referees. Previously deleted user disqualification MUST use a +54. Previously deleted users MUST NOT qualify as referees. Previously deleted user disqualification MUST use a legal-approved normalized-email hash tombstone. -54. A referee MUST convert on a personal KiloClaw subscription. Team plans, organization-scoped KiloClaw subscriptions, +55. A referee MUST convert on a personal KiloClaw subscription. Team plans, organization-scoped KiloClaw subscriptions, and non-KiloClaw subscriptions MUST NOT qualify. -55. A referee MUST make a first confirmed paid KiloClaw subscription payment before either side earns a reward. +56. A referee MUST make a first confirmed paid KiloClaw subscription payment before either side earns a reward. -56. The first confirmed paid KiloClaw subscription payment MUST fund a monetized KiloClaw payment period. +57. The first confirmed paid KiloClaw subscription payment MUST fund a monetized KiloClaw payment period. -57. Trial start, trial end, account signup, widget registration, zero-dollar invoices, fully comped periods, admin +58. Trial start, trial end, account signup, widget registration, zero-dollar invoices, fully comped periods, admin adjustments, or referral touch capture MUST NOT qualify as a paid referral conversion. -58. A referee's renewals after the first paid KiloClaw conversion MUST NOT generate additional referral rewards. +59. A referee's renewals after the first paid KiloClaw conversion MUST NOT generate additional referral rewards. -59. A user MUST NOT refer themselves. The system MUST disqualify a referral when the referrer and referee resolve to the +60. A user MUST NOT refer themselves. The system MUST disqualify a referral when the referrer and referee resolve to the same Kilo user. -60. Fraudulent, test, admin-created, or manually adjusted subscriptions MUST NOT qualify for referral rewards unless an +61. Fraudulent, test, admin-created, or manually adjusted subscriptions MUST NOT qualify for referral rewards unless an authorized operator explicitly marks the conversion as eligible under a documented support process. ### Referrer Eligibility -61. A referrer MUST be a Kilo user registered or registerable as an Impact Advocate participant. +62. A referrer MUST be a Kilo user registered or registerable as an Impact Advocate participant. -62. A referrer's current KiloClaw subscription state MUST NOT prevent reward earning. +63. A referrer's current KiloClaw subscription state MUST NOT prevent reward earning. -63. If a referrer has no active eligible personal KiloClaw subscription when the reward is earned, the system MUST keep the +64. If a referrer has no active eligible personal KiloClaw subscription when the reward is earned, the system MUST keep the reward pending so it can be applied when the referrer starts or reactivates an eligible personal KiloClaw subscription. -64. A pending inactive-referrer reward MUST expire and be canceled 12 months after it is earned if the referrer has not +65. A pending inactive-referrer reward MUST expire and be canceled 12 months after it is earned if the referrer has not started or reactivated an eligible paid personal KiloClaw subscription. -65. A pending referrer reward MUST NOT apply to a KiloClaw trial. It MUST apply to the next unpaid renewal boundary after +66. A pending referrer reward MUST NOT apply to a KiloClaw trial. It MUST apply to the next unpaid renewal boundary after the referrer starts or reactivates a paid personal KiloClaw subscription. -66. A referrer MUST NOT receive more than 12 total free-month rewards from the referral program. +67. A referrer MUST NOT receive more than 12 total free-month rewards from the referral program. -67. The referrer cap MUST be enforced before granting a referrer reward. +68. The referrer cap MUST be enforced before granting a referrer reward. -68. The 12-month referrer cap MUST be enforced atomically across concurrent reward grants. Concurrent processing MUST NOT +69. The 12-month referrer cap MUST be enforced atomically across concurrent reward grants. Concurrent processing MUST NOT produce more than 12 granted referrer reward months. -69. When a qualified referral occurs after the referrer has reached the 12-month cap, the system MUST record that the +70. When a qualified referral occurs after the referrer has reached the 12-month cap, the system MUST record that the referrer reward was cap-limited and MUST NOT grant another referrer free month. -70. Referee rewards MUST NOT count against the referrer's 12-month cap. +71. Referee rewards MUST NOT count against the referrer's 12-month cap. ### Reward Granting -71. A qualified referral conversion MUST grant one free-month reward to the referee. +72. A qualified referral conversion MUST grant one free-month reward to the referee. -72. A qualified referral conversion MUST grant one free-month reward to the referrer. The reward MUST be marked +73. A qualified referral conversion MUST grant one free-month reward to the referrer. The reward MUST be marked cap-limited instead of granted when the referrer cap has been reached or another referrer eligibility rule prevents it. -73. Referral reward granting MUST be idempotent. Processing the same qualifying conversion multiple times MUST NOT create +74. Referral reward granting MUST be idempotent. Processing the same qualifying conversion multiple times MUST NOT create duplicate rewards for the same beneficiary role. -74. For a qualified referral, reward grant processing MUST be atomic across both beneficiary reward decisions. Both +75. For a qualified referral, reward grant processing MUST be atomic across both beneficiary reward decisions. Both beneficiary outcomes MUST be recorded together, including granted, cap-limited, and disqualified outcomes. -75. Reward records MUST identify the source referral, source conversion, beneficiary user, beneficiary role, number of +76. Reward records MUST identify the source referral, source conversion, beneficiary user, beneficiary role, number of months granted, status, and relevant timestamps. -76. Reward records MUST support the reward states defined in this spec. +77. Reward records MUST support the reward states defined in this spec. -77. A reward MUST NOT be considered fulfilled until KiloClaw billing state and any required Stripe state have been +78. A reward MUST NOT be considered fulfilled until KiloClaw billing state and any required Stripe state have been successfully updated so the corresponding KiloClaw renewal is delayed. -78. Impact Advocate reward state MAY be used for reconciliation, support, or reporting. It MUST NOT be the source of +79. Impact Advocate reward state MAY be used for reconciliation, support, or reporting. It MUST NOT be the source of truth for local free-month fulfillment. ### Reward Fulfillment and Billing -79. Free-month rewards MUST be fulfilled by delaying a KiloClaw renewal by one calendar month per reward. +80. Free-month rewards MUST be fulfilled by delaying a KiloClaw renewal by one calendar month per reward. -80. An earned reward applies to the beneficiary's next unpaid renewal boundary after the reward is earned. It MUST NOT +81. An earned reward applies to the beneficiary's next unpaid renewal boundary after the reward is earned. It MUST NOT modify already-finalized invoices or already-funded periods. -81. Free-month rewards MUST NOT be fulfilled as general account credits. +82. Free-month rewards MUST NOT be fulfilled as general account credits. -82. Free-month rewards MUST apply to KiloClaw billing only. They MUST NOT apply to inference usage, Kilo Pass, team plans, +83. Free-month rewards MUST apply to KiloClaw billing only. They MUST NOT apply to inference usage, Kilo Pass, team plans, or non-KiloClaw purchases. -83. Multiple free-month rewards MAY stack. Each applied reward MUST delay renewal by exactly one calendar month. +84. Multiple free-month rewards MAY stack. Each applied reward MUST delay renewal by exactly one calendar month. -84. For month-to-month KiloClaw subscriptions, one reward MUST delay the next monthly renewal by one calendar month. +85. For month-to-month KiloClaw subscriptions, one reward MUST delay the next monthly renewal by one calendar month. -85. For six-month commitment KiloClaw subscriptions, one reward MUST delay the next six-month renewal by one calendar +86. For six-month commitment KiloClaw subscriptions, one reward MUST delay the next six-month renewal by one calendar month. The reward MUST NOT convert the subscription to month-to-month and MUST NOT reduce the next invoice by one sixth. -86. For pure-credit KiloClaw subscriptions, reward application MUST update local renewal state so the credit renewal sweep +87. For pure-credit KiloClaw subscriptions, reward application MUST update local renewal state so the credit renewal sweep does not deduct KiloClaw hosting credits until the extended renewal time. -87. For Stripe-funded or hybrid KiloClaw subscriptions, reward application MUST keep local billing state and Stripe +88. For Stripe-funded or hybrid KiloClaw subscriptions, reward application MUST keep local billing state and Stripe billing state consistent. The system MUST NOT create a local-only renewal delay for a Stripe-funded subscription while allowing Stripe to charge on the original schedule. -88. Reward application MUST be idempotent. Retrying reward application MUST NOT extend the same subscription more than +89. Reward application MUST be idempotent. Retrying reward application MUST NOT extend the same subscription more than once for the same reward. -89. Reward application MUST record an audit trail containing the reward, beneficiary, affected subscription, previous +90. Reward application MUST record an audit trail containing the reward, beneficiary, affected subscription, previous renewal or period boundary, new renewal or period boundary, and any external billing operation identifiers. -90. Reward application MUST NOT break existing KiloClaw billing invariants for trials, pure-credit renewal, hybrid invoice +91. Reward application MUST NOT break existing KiloClaw billing invariants for trials, pure-credit renewal, hybrid invoice settlement, commit plans, plan switching, cancellation, reactivation, past-due recovery, suspension, or destruction. -91. Reward application MUST respect cancellation state. If a subscription is canceled or canceling before reward +92. Reward application MUST respect cancellation state. If a subscription is canceled or canceling before reward application, the reward MUST remain pending until the beneficiary has an active eligible personal KiloClaw subscription. ### Impact Conversion Reporting -92. Impact Advocate referral conversion MUST be driven by the existing Impact Performance conversion events. +93. Impact Advocate referral conversion MUST be driven by the existing Impact Performance conversion events. -93. `Sale (71659)` MUST be the paid KiloClaw conversion event used for referral conversion and renewal reporting. +94. `Sale (71659)` MUST be the paid KiloClaw conversion event used for referral conversion and renewal reporting. -94. The system MUST NOT dispatch client-side `trackConversion` for referrals while server-side Performance conversion is +95. The system MUST NOT dispatch client-side `trackConversion` for referrals while server-side Performance conversion is the configured reporting mechanism. -95. When a referral wins attribution and the first paid conversion qualifies, the system MUST ensure Impact receives the +96. When a referral wins attribution and the first paid conversion qualifies, the system MUST ensure Impact receives the required Performance conversion data for Advocate conversion reporting. -96. Conversion reporting MUST use deterministic order identifiers where possible so retries do not create duplicate Impact +97. Conversion reporting MUST use deterministic order identifiers where possible so retries do not create duplicate Impact actions. -97. Conversion reporting failures MUST NOT block billing settlement, reward ledger creation, or user access. Failures MUST +98. Conversion reporting failures MUST NOT block billing settlement, reward ledger creation, or user access. Failures MUST leave the conversion report in a retryable state until it succeeds, is superseded by a corrected payload, or is marked permanently failed by an operator-visible terminal state. ### Impact Reconciliation -98. The system MUST NOT rely on Impact Advocate webhooks for referral eligibility, reward granting, billing fulfillment, +99. The system MUST NOT rely on Impact Advocate webhooks for referral eligibility, reward granting, billing fulfillment, or reconciliation. -99. The system MAY use Impact dashboard exports or API reads for manual reconciliation and support investigations. +100. The system MAY use Impact dashboard exports or API reads for manual reconciliation and support investigations. -100. Impact reconciliation data MAY update local Impact-facing status fields, but it MUST NOT bypass local eligibility, - cap, attribution, or billing fulfillment rules. +101. Impact reconciliation data MAY update local Impact-facing status fields, but it MUST NOT bypass local eligibility, + cap, attribution, or billing fulfillment rules. ### Refunds, Reversals, and Fraud -101. Rewards from a qualifying Stripe payment MUST be canceled if Stripe reports a chargeback for that payment. +102. Rewards from a qualifying Stripe payment MUST be canceled if Stripe reports a chargeback for that payment. -102. Pending or earned-but-unapplied rewards MUST be canceled when the qualifying Stripe payment is charged back. +103. Pending or earned-but-unapplied rewards MUST be canceled when the qualifying Stripe payment is charged back. -103. Already-applied rewards from a charged-back Stripe payment MUST be marked for support review and MUST NOT be +104. Already-applied rewards from a charged-back Stripe payment MUST be marked for support review and MUST NOT be automatically canceled or clawed back. -104. Rewards from refunded or fraud-marked payments MUST be canceled before application. Already-applied rewards from +105. Rewards from refunded or fraud-marked payments MUST be canceled before application. Already-applied rewards from refunded or fraud-marked payments MUST be marked for support review and MUST NOT be automatically canceled or clawed back. -105. If a qualifying Impact action must be reversed, the system SHOULD use Impact's reverse-action mechanism instead of +106. If a qualifying Impact action must be reversed, the system SHOULD use Impact's reverse-action mechanism instead of creating an unrelated negative conversion. -106. Reversal and reward-cancellation handling MUST be idempotent. +107. Reversal and reward-cancellation handling MUST be idempotent. ### GDPR and PII -107. Referral tables that store user IDs, emails, referral relationships, IP addresses, referral cookies, Impact IDs, or +108. Referral tables that store user IDs, emails, referral relationships, IP addresses, referral cookies, Impact IDs, or reconciliation payloads MUST be included in GDPR soft-delete or anonymization flows. -108. GDPR deletion MUST delete or anonymize referral participant records, referral touch records, referral relationship +109. GDPR deletion MUST delete or anonymize referral participant records, referral touch records, referral relationship records, reconciliation payloads containing PII, and reward records to the extent required by policy. -109. Plain email stored for Impact Advocate compatibility MUST be deleted or anonymized during GDPR deletion. +110. Plain email stored for Impact Advocate compatibility MUST be deleted or anonymized during GDPR deletion. -110. Previously deleted user disqualification MUST use a legal-approved non-PII tombstone or irreversible hash. The system +111. Previously deleted user disqualification MUST use a legal-approved non-PII tombstone or irreversible hash. The system MUST NOT retain PII solely for this purpose. -111. Referral tracking values MUST NOT be logged in a way that exposes secrets, auth headers, cookies, or unnecessary PII. +112. Referral tracking values MUST NOT be logged in a way that exposes secrets, auth headers, cookies, or unnecessary PII. ### Reliability and Isolation -112. Referral touch capture, participant registration, conversion reporting, reconciliation processing, and reward +113. Referral touch capture, participant registration, conversion reporting, reconciliation processing, and reward fulfillment failures MUST NOT break unrelated product functionality. -113. Reward ledger operations MUST be transactional where needed to prevent duplicate grants, partial grants, or missing +114. Reward ledger operations MUST be transactional where needed to prevent duplicate grants, partial grants, or missing audit records. -114. Reward fulfillment failures MUST leave rewards in a retryable state unless the failure is a permanent eligibility or +115. Reward fulfillment failures MUST leave rewards in a retryable state unless the failure is a permanent eligibility or configuration failure. -115. The system MUST expose enough operational state to distinguish pending Impact registration, pending Impact conversion +116. The system MUST expose enough operational state to distinguish pending Impact registration, pending Impact conversion reporting, pending local reward application, applied rewards, reversed rewards, canceled rewards, review-required rewards, and disqualified referrals. -116. Admin-only subscription interventions, internal test conversions, and support adjustments MUST NOT emit referral +117. Admin-only subscription interventions, internal test conversions, and support adjustments MUST NOT emit referral rewards or Impact referral conversions unless explicitly marked as eligible by an authorized operator. ### Existing Internal Referral System -117. The existing internal referral-code system MUST NOT grant additional KiloClaw referral rewards for conversions already +118. The existing internal referral-code system MUST NOT grant additional KiloClaw referral rewards for conversions already governed by this spec. -118. Before launch, the existing internal referral system MUST be scoped away from KiloClaw, disabled for KiloClaw, or +119. Before launch, the existing internal referral system MUST be scoped away from KiloClaw, disabled for KiloClaw, or migrated into this program's rules to prevent double rewards. ## Error Handling diff --git a/apps/web/src/lib/impact-advocate.test.ts b/apps/web/src/lib/impact-advocate.test.ts index dc9ee7a63a..2b602ff65c 100644 --- a/apps/web/src/lib/impact-advocate.test.ts +++ b/apps/web/src/lib/impact-advocate.test.ts @@ -156,4 +156,50 @@ describe('impact advocate', () => { countryCode: 'US', }); }); + + describe('extractAdvocateReferralCodeFromUpsertResponse', () => { + it('returns the program-scoped code from a SaaSquatch upsert response', async () => { + const { extractAdvocateReferralCodeFromUpsertResponse } = + await import('@/lib/impact-advocate'); + + const body = JSON.stringify({ + id: 'hash', + email: 'referee@example.com', + referralCodes: { '51699': 'REFEREE15914', '99999': 'OTHER42' }, + referable: true, + }); + + expect(extractAdvocateReferralCodeFromUpsertResponse(body, '51699')).toBe('REFEREE15914'); + expect(extractAdvocateReferralCodeFromUpsertResponse(body, '99999')).toBe('OTHER42'); + }); + + it('returns null for missing program, malformed JSON, empty bodies, or non-string codes', async () => { + const { extractAdvocateReferralCodeFromUpsertResponse } = + await import('@/lib/impact-advocate'); + + expect(extractAdvocateReferralCodeFromUpsertResponse(null, '51699')).toBeNull(); + expect(extractAdvocateReferralCodeFromUpsertResponse('', '51699')).toBeNull(); + expect(extractAdvocateReferralCodeFromUpsertResponse('not json', '51699')).toBeNull(); + expect(extractAdvocateReferralCodeFromUpsertResponse('null', '51699')).toBeNull(); + expect(extractAdvocateReferralCodeFromUpsertResponse('{}', '51699')).toBeNull(); + expect( + extractAdvocateReferralCodeFromUpsertResponse( + JSON.stringify({ referralCodes: { '51699': ' ' } }), + '51699' + ) + ).toBeNull(); + expect( + extractAdvocateReferralCodeFromUpsertResponse( + JSON.stringify({ referralCodes: { '51699': 12345 } }), + '51699' + ) + ).toBeNull(); + expect( + extractAdvocateReferralCodeFromUpsertResponse( + JSON.stringify({ referralCodes: { '99999': 'OTHER42' } }), + '51699' + ) + ).toBeNull(); + }); + }); }); diff --git a/apps/web/src/lib/impact-advocate.ts b/apps/web/src/lib/impact-advocate.ts index d0e8e60a8d..a55652b86f 100644 --- a/apps/web/src/lib/impact-advocate.ts +++ b/apps/web/src/lib/impact-advocate.ts @@ -173,6 +173,40 @@ export function getImpactAdvocateWidgetId(): string { return getImpactAdvocateConfig()?.widgetId ?? IMPACT_ADVOCATE_DEFAULT_WIDGET_ID; } +export function getImpactAdvocateProgramId(): string { + return getImpactAdvocateConfig()?.programId ?? IMPACT_ADVOCATE_DEFAULT_PROGRAM_ID; +} + +/** + * Pull the program-scoped referral code out of a SaaSquatch Upsert User + * response body. The response shape is: + * + * { ..., "referralCodes": { "": "" }, ... } + * + * Returns null when the body is missing, malformed, or does not contain a + * code for the requested programId. Never throws — callers treat null as + * "no code, leave participants.opaque_referral_identifier alone". + */ +export function extractAdvocateReferralCodeFromUpsertResponse( + responseBody: string | null | undefined, + programId: string +): string | null { + if (!responseBody) return null; + let parsed: unknown; + try { + parsed = JSON.parse(responseBody); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null) return null; + const referralCodes = (parsed as Record).referralCodes; + if (typeof referralCodes !== 'object' || referralCodes === null) return null; + const code = (referralCodes as Record)[programId]; + if (typeof code !== 'string') return null; + const trimmed = code.trim(); + return trimmed ? trimmed : null; +} + export function buildImpactAdvocateIdentityPayload( user: Pick ): ImpactAdvocateIdentityPayload { diff --git a/apps/web/src/lib/impact-referral.test.ts b/apps/web/src/lib/impact-referral.test.ts index dd357473bc..4fa0a818e9 100644 --- a/apps/web/src/lib/impact-referral.test.ts +++ b/apps/web/src/lib/impact-referral.test.ts @@ -31,11 +31,22 @@ describe('impact referral participant registration dispatch', () => { }); it('delivers queued participant registrations and marks the participant registered', async () => { - const fetchMock = jest - .fn() - .mockResolvedValue( - new Response(JSON.stringify({ participantId: 'impact-participant-1' }), { status: 200 }) - ); + // Realistic SaaSquatch upsert response shape — the dispatcher must parse + // referralCodes[programId] and persist it as the participant's + // opaque_referral_identifier so future referee touches can resolve back + // to this user as their advocate. + const fetchMock = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 'sq-hash-id', + accountId: 'sq-hash-id', + email: 'participant@example.com', + referralCodes: { '51699': 'PARTICIPANT9001' }, + referable: true, + }), + { status: 200 } + ) + ); global.fetch = fetchMock; const user = await insertTestUser({ @@ -82,6 +93,9 @@ describe('impact referral participant registration dispatch', () => { expect(participant.registration_state).toBe('registered'); expect(participant.registered_at).toBeTruthy(); expect(participant.last_error_code).toBeNull(); + // The advocate's program-scoped SaaSquatch code is now persisted so the + // attribution lookup in kiloclaw-referrals.ts can resolve referrerUserId. + expect(participant.opaque_referral_identifier).toBe('PARTICIPANT9001'); const [attempt] = await db.select().from(impact_advocate_registration_attempts); expect(attempt.delivery_state).toBe('succeeded'); @@ -321,4 +335,92 @@ describe('impact referral participant registration dispatch', () => { ); expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it( + 'leaves opaque_referral_identifier untouched when another participant ' + + 'already holds the SaaSquatch code', + async () => { + // Existing participant on a *different* user already holds the code. + // The unique constraint on opaque_referral_identifier means we must not + // try to write the same code on a second participant — doing so would + // roll back the success transaction and the cron would loop forever. + const incumbent = await insertTestUser({ + google_user_email: 'incumbent@example.com', + normalized_email: 'incumbent@example.com', + }); + await db.insert(impact_advocate_participants).values({ + user_id: incumbent.id, + advocate_id: incumbent.google_user_email, + advocate_account_id: incumbent.google_user_email, + opaque_referral_identifier: 'COLLIDING_CODE', + registration_state: 'registered', + }); + + const fetchMock = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 'sq-hash-id-other', + email: 'other@example.com', + referralCodes: { '51699': 'COLLIDING_CODE' }, + referable: true, + }), + { status: 200 } + ) + ); + global.fetch = fetchMock; + + const newUser = await insertTestUser({ + google_user_email: 'other@example.com', + normalized_email: 'other@example.com', + }); + + const { + dispatchQueuedImpactAdvocateRegistrationAttempts, + queueImpactAdvocateParticipantRegistration, + } = await import('@/lib/impact-referral'); + + await queueImpactAdvocateParticipantRegistration({ + user: newUser, + referralTouch: { + opaqueTrackingValue: 'sq-cookie-other', + trackingValueLength: 15, + isTrackingValueAccepted: true, + rsCode: 'ref-other', + rsShareMedium: 'email', + rsEngagementMedium: 'link', + landingPath: '/get-started?_saasquatch=sq-cookie-other', + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmTerm: null, + utmContent: null, + touchedAt: new Date('2026-04-23T00:00:00.000Z'), + expiresAt: new Date('2026-05-23T00:00:00.000Z'), + }, + locale: 'en-US', + countryCode: 'US', + }); + + const summary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(summary).toEqual({ + claimed: 1, + delivered: 1, + retried: 0, + failed: 0, + }); + + // The new participant is registered but does NOT receive the colliding + // code; the incumbent keeps it. + const newParticipant = await db.query.impact_advocate_participants.findFirst({ + where: eq(impact_advocate_participants.user_id, newUser.id), + }); + expect(newParticipant?.registration_state).toBe('registered'); + expect(newParticipant?.opaque_referral_identifier).toBeNull(); + + const incumbentParticipant = await db.query.impact_advocate_participants.findFirst({ + where: eq(impact_advocate_participants.user_id, incumbent.id), + }); + expect(incumbentParticipant?.opaque_referral_identifier).toBe('COLLIDING_CODE'); + } + ); }); diff --git a/apps/web/src/lib/impact-referral.ts b/apps/web/src/lib/impact-referral.ts index 2bc7152661..05fd03885a 100644 --- a/apps/web/src/lib/impact-referral.ts +++ b/apps/web/src/lib/impact-referral.ts @@ -4,6 +4,8 @@ import { createHash } from 'crypto'; import { db, type DrizzleTransaction } from '@/lib/drizzle'; import { buildImpactAdvocateRegisterParticipantPayload, + extractAdvocateReferralCodeFromUpsertResponse, + getImpactAdvocateProgramId, isImpactAdvocateConfigured, sendImpactAdvocateRegisterParticipantPayload, type ImpactAdvocateRegisterParticipantPayload, @@ -26,7 +28,7 @@ import { KiloClawAttributionTouchProvider, KiloClawAttributionTouchType, } from '@kilocode/db/schema-types'; -import { and, asc, eq, lte, or, sql } from 'drizzle-orm'; +import { and, asc, eq, lte, ne, or, sql } from 'drizzle-orm'; type DatabaseClient = typeof db | DrizzleTransaction; @@ -465,6 +467,54 @@ async function dispatchImpactAdvocateRegistrationAttemptById( }); if (result.ok) { + // Pull the SaaSquatch-generated referral code out of the response so the + // participant becomes discoverable as an Advocate. Without this, every + // future referee touch carrying this user's rsCode would resolve + // referrerUserId=null and the rewards lifecycle would silently undercount + // attribution on the Kilo side. The unique constraint on + // opaque_referral_identifier means we have to pre-check for a collision + // (vanishingly unlikely — SaaSquatch issues unique codes per tenant — but + // a violation here would otherwise roll back the whole success transaction + // and put us in a retry loop). + const programId = getImpactAdvocateProgramId(); + const advocateCode = extractAdvocateReferralCodeFromUpsertResponse( + result.responseBody, + programId + ); + + let advocateCodeToPersist: string | null = null; + if (advocateCode) { + const conflicting = await db.query.impact_advocate_participants.findFirst({ + where: and( + eq(impact_advocate_participants.opaque_referral_identifier, advocateCode), + ne(impact_advocate_participants.id, participant.id) + ), + columns: { id: true, user_id: true }, + }); + if (conflicting) { + logImpactReferralDebug( + 'Skipped persisting Impact Advocate referral code due to existing holder', + { + participantId: participant.id, + conflictingParticipantId: conflicting.id, + conflictingUserId: conflicting.user_id, + programId, + } + ); + } else { + advocateCodeToPersist = advocateCode; + } + } + + logImpactReferralDebug('Parsed Impact Advocate referral code from upsert response', { + attemptId: attempt.id, + participantId: participant.id, + userId: participant.user_id, + programId, + advocateCodePresent: Boolean(advocateCode), + advocateCodePersisted: Boolean(advocateCodeToPersist), + }); + await db.transaction(async tx => { await tx .update(impact_advocate_registration_attempts) @@ -488,6 +538,7 @@ async function dispatchImpactAdvocateRegistrationAttemptById( last_registration_attempt_at: completedAt, last_error_code: null, last_error_message: null, + ...(advocateCodeToPersist ? { opaque_referral_identifier: advocateCodeToPersist } : {}), }) .where(eq(impact_advocate_participants.id, participant.id)); }); From 775a195bcb665e02400f6f6b9a7a0d727649fbee Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 13:49:55 +0200 Subject: [PATCH 20/32] feat(impact-advocate): server-side advocate registration via Verified Access token route Close the architectural gap that left advocate-only Kilo users (anyone who never came through the referee /_saasquatch cookie path) undiscoverable to the conversion lifecycle. Before: - /api/impact-advocate/token issued the Verified Access JWT and generated a Kilo-side random UUID, writing it as both referral_codes.code (internal Kilo system) AND impact_advocate_participants.opaque_referral_identifier (Impact integration table). The two tables were conflated. - The SaaSquatch widget separately upserted the user via the JWT and issued a real referral code (e.g. REFERRER5616), but Kilo never learned what code SaaSquatch had assigned. - Inbound referee touches with rs_code=REFERRER5616 could never match participants.opaque_referral_identifier=, so the conversion lifecycle resolved referrerUserId=null and silently undercounted attribution every time a real advocate's link was used. - Worse, the UUID overwrite ran on every /claw/refer page load, clobbering any SaaSquatch code the dispatcher had managed to persist. After: - queueImpactAdvocateSelfRegistration (new) queues an Upsert User attempt for the advocate with empty cookies (no inbound attribution) and dedupe key ('impact-advocate-self-registration', userId), so repeat /claw/refer visits don't stack attempts. Skips queueing once the participant is already registered with a code. - /api/impact-advocate/token now (a) keeps the internal referral_codes UUID (separate, internal Kilo system, untouched), (b) drops the opaque_referral_identifier write so the dispatcher's SaaSquatch code is never clobbered, and (c) calls queueImpactAdvocateSelfRegistration with locale/country derived from request headers. - The dispatcher (already fixed in e4c5b142a) parses referralCodes[programId] from the SaaSquatch upsert response and writes it to participants.opaque_referral_identifier. The same code path now serves both referee and advocate-only registrations. - Spec rule 11 (new) makes server-side advocate registration on Verified Access token issuance a hard requirement; cross-references rule 51 (the persistence rule). Rules 11-119 shifted to 12-120 to make room. Tests: queue + dispatch happy path, idempotency across repeat calls, skip-when-already-registered. All 4779 tests pass (was 4776; +3 new). The next /claw/refer load by an advocate-only user will queue an Upsert User attempt; the cron will dispatch within ~60s and persist their SaaSquatch code so the conversion lifecycle can resolve them as the referrer for any future referee that converts via their share link. --- .specs/kiloclaw-referrals.md | 226 +++++++++--------- .../app/api/impact-advocate/token/route.ts | 45 ++-- apps/web/src/lib/impact-referral.test.ts | 116 +++++++++ apps/web/src/lib/impact-referral.ts | 120 ++++++++++ 4 files changed, 380 insertions(+), 127 deletions(-) diff --git a/.specs/kiloclaw-referrals.md b/.specs/kiloclaw-referrals.md index de8239e65e..519685ed7d 100644 --- a/.specs/kiloclaw-referrals.md +++ b/.specs/kiloclaw-referrals.md @@ -141,86 +141,92 @@ interventions, and non-KiloClaw purchases are out of scope. 10. The system MUST NOT allow users to alter the identity payload used to establish Advocate identity. +11. The system MUST register every Kilo user who is issued an Impact Advocate Verified Access token as a participant in + the Advocate program server-side, even when the user has no inbound referral attribution. This MUST happen no later + than the first issuance of the token for that user. Registration MUST be idempotent across repeat issuances and MUST + persist the SaaSquatch-issued referral code per rule 51, so the user becomes resolvable as the referrer when their + referees later convert. + ### Client-Side Tracking and Identity -11. The system MUST load the Impact UTT script on pages used by the referral program when the UTT identifier is +12. The system MUST load the Impact UTT script on pages used by the referral program when the UTT identifier is configured, and MUST NOT load it when the UTT identifier is not configured. -12. The system MUST invoke Impact `identify` on pages used by the referral program. +13. The system MUST invoke Impact `identify` on pages used by the referral program. -13. Anonymous `identify` calls MUST pass empty string values for unknown `customerId` and `customerEmail`. The system +14. Anonymous `identify` calls MUST pass empty string values for unknown `customerId` and `customerEmail`. The system MUST NOT pass `undefined`, `null`, placeholders, or fake identifiers for unknown users. -14. Logged-in `identify` calls MUST pass a stable customer identifier and SHA-1 hashed email. +15. Logged-in `identify` calls MUST pass a stable customer identifier and SHA-1 hashed email. -15. `identify` calls MUST include a stable `customProfileId` derived from the Kilo user ID for logged-in users and a +16. `identify` calls MUST include a stable `customProfileId` derived from the Kilo user ID for logged-in users and a stable first-party anonymous ID for anonymous users. -16. The system MUST treat `_saasquatch`, `rsCode`, `rsShareMedium`, `rsEngagementMedium`, `im_ref`, and related tracking +17. The system MUST treat `_saasquatch`, `rsCode`, `rsShareMedium`, `rsEngagementMedium`, `im_ref`, and related tracking values as opaque. The system MUST NOT parse, validate the internal format of, or assign meaning to these values. -17. Opaque tracking values MUST have a documented maximum accepted length, MUST be stored as UTF-8 strings, and MUST be +18. Opaque tracking values MUST have a documented maximum accepted length, MUST be stored as UTF-8 strings, and MUST be ignored for attribution when they exceed that maximum. Logs MUST redact or truncate opaque tracking values. ### Referral Touch Capture -18. When a visitor opens an Impact Advocate referral link, the system MUST recognize that referral before signup and +19. When a visitor opens an Impact Advocate referral link, the system MUST recognize that referral before signup and preserve it through account creation so the referral can be associated with the newly created user. -19. A referral touch is valid for attribution only when it contains a non-empty `_saasquatch` value. If `_saasquatch` is +20. A referral touch is valid for attribution only when it contains a non-empty `_saasquatch` value. If `_saasquatch` is absent, the system MAY preserve related metadata for diagnostics but MUST NOT treat it as a valid referral touch. -20. A referral touch SHOULD include related opaque metadata when available, including `rsCode`, `rsShareMedium`, +21. A referral touch SHOULD include related opaque metadata when available, including `rsCode`, `rsShareMedium`, `rsEngagementMedium`, UTM parameters, and sanitized landing path. -21. Referral touch capture MUST preserve attribution across the authentication flow, including OAuth redirects and +22. Referral touch capture MUST preserve attribution across the authentication flow, including OAuth redirects and callback URLs. -22. Referral touches MUST expire 30 days after the touch time. A touch is valid only when +23. Referral touches MUST expire 30 days after the touch time. A touch is valid only when `conversion_time < touched_at + 30 * 24 hours`, using server UTC timestamps. A touch at or after that instant is expired. -23. The system MUST associate pre-signup referral touches with the created user during signup or first authenticated +24. The system MUST associate pre-signup referral touches with the created user during signup or first authenticated request after signup. -24. Capturing or associating a referral touch MUST NOT grant a reward. +25. Capturing or associating a referral touch MUST NOT grant a reward. -25. If a user arrives with multiple referral touches, the system MUST preserve enough chronological information to +26. If a user arrives with multiple referral touches, the system MUST preserve enough chronological information to resolve referral-priority attribution at conversion time. ### Affiliate and Referral Attribution Priority -26. KiloClaw referral rewards and KiloClaw affiliate attribution MUST share a 30-day conversion-time attribution window. +27. KiloClaw referral rewards and KiloClaw affiliate attribution MUST share a 30-day conversion-time attribution window. -27. At first paid KiloClaw conversion time, the system MUST evaluate valid affiliate and referral touches together. +28. At first paid KiloClaw conversion time, the system MUST evaluate valid affiliate and referral touches together. -28. For KiloClaw conversions governed by this referral spec, this spec's referral-priority attribution overrides the +29. For KiloClaw conversions governed by this referral spec, this spec's referral-priority attribution overrides the permanent first-touch affiliate attribution rules in `.specs/impact-affiliate-tracking.md`. -29. A valid referral touch MUST win over a valid affiliate touch unless the affiliate touch has already been +30. A valid referral touch MUST win over a valid affiliate touch unless the affiliate touch has already been sale-attributed before the referral touch occurred. Initial attribution for a not-yet-attributed SALE MUST prefer the valid referral touch. -30. A sale-attributed affiliate touch MUST keep affiliate attribution for the initial SALE and subsequent KiloClaw renewals +31. A sale-attributed affiliate touch MUST keep affiliate attribution for the initial SALE and subsequent KiloClaw renewals only when that initial SALE occurred before the referral touch. Referral touches MUST NOT retroactively override those affiliate-attributed SALE events. -31. If multiple valid referral touches exist and no sale-attributed affiliate touch is present, the oldest valid referral +32. If multiple valid referral touches exist and no sale-attributed affiliate touch is present, the oldest valid referral touch MUST win. -32. If no valid referral touch exists, the oldest valid affiliate touch MUST win. +33. If no valid referral touch exists, the oldest valid affiliate touch MUST win. -33. If all touches are expired or invalid, neither affiliate attribution nor referral rewards win for that conversion. +34. If all touches are expired or invalid, neither affiliate attribution nor referral rewards win for that conversion. -34. If an affiliate touch wins, the system MUST NOT grant referral rewards for that conversion. +35. If an affiliate touch wins, the system MUST NOT grant referral rewards for that conversion. -35. If a referral touch wins, the system MUST NOT attribute that first paid KiloClaw conversion to an affiliate for reward +36. If a referral touch wins, the system MUST NOT attribute that first paid KiloClaw conversion to an affiliate for reward or payout purposes. -36. The system MUST record when an affiliate touch has been attributed to a SALE conversion so affiliate attribution can be +37. The system MUST record when an affiliate touch has been attributed to a SALE conversion so affiliate attribution can be preserved for that initial sale and subsequent KiloClaw renewals. -37. The system MUST implement at least the following attribution outcomes. +38. The system MUST implement at least the following attribution outcomes. | Scenario | Expected winner | | ---------------------------------------------------------------------------- | --------------- | @@ -231,37 +237,37 @@ interventions, and non-KiloClaw purchases are out of scope. | Only referral valid | Referral | | All touches expired or invalid | None | -38. Attribution resolution for referral rewards MUST happen at conversion time, not only at signup time. +39. Attribution resolution for referral rewards MUST happen at conversion time, not only at signup time. -39. Impact-side attribution MUST NOT override local eligibility, reward caps, or billing fulfillment decisions. +40. Impact-side attribution MUST NOT override local eligibility, reward caps, or billing fulfillment decisions. ### Referred Participant Registration -40. When a new user signs up with `_saasquatch` attribution, the system MUST attempt to register or upsert the user as a +41. When a new user signs up with `_saasquatch` attribution, the system MUST attempt to register or upsert the user as a referred participant in Impact Advocate. -41. Register Participant requests MUST be made server-side. +42. Register Participant requests MUST be made server-side. -42. Register Participant requests MUST pass the captured `_saasquatch` value as opaque cookie attribution. +43. Register Participant requests MUST pass the captured `_saasquatch` value as opaque cookie attribution. -43. Register Participant requests SHOULD include locale and country code when available. +44. Register Participant requests SHOULD include locale and country code when available. -44. If `_saasquatch` is present during signup, referral touch association and participant registration enqueueing MUST +45. If `_saasquatch` is present during signup, referral touch association and participant registration enqueueing MUST occur before signup is considered complete, but external Impact delivery MUST NOT block user access. -45. Register Participant failures MUST be recorded for retry or reconciliation. +46. Register Participant failures MUST be recorded for retry or reconciliation. -46. Transient participant registration failures MUST leave the registration in a retryable state until it succeeds, is +47. Transient participant registration failures MUST leave the registration in a retryable state until it succeeds, is superseded by a corrected payload, or is marked permanently failed by an operator-visible terminal state. -47. Register Participant requests that fail with client errors MUST be logged and MUST NOT be retried until the request +48. Register Participant requests that fail with client errors MUST be logged and MUST NOT be retried until the request payload or configuration is corrected. -48. Register Participant requests MUST use the user's plain email for Advocate `id` and `accountId`. +49. Register Participant requests MUST use the user's plain email for Advocate `id` and `accountId`. -49. Register Participant requests MUST include plain-text email only as the Advocate contact email. +50. Register Participant requests MUST include plain-text email only as the Advocate contact email. -50. On a successful Register Participant response, the system MUST persist the program-scoped +51. On a successful Register Participant response, the system MUST persist the program-scoped referral code returned in `referralCodes[]` against the participant record so inbound referral touches can resolve the originating Advocate user. Persistence MUST be idempotent: re-running registration for the same participant MUST NOT corrupt or duplicate the @@ -272,212 +278,212 @@ interventions, and non-KiloClaw purchases are out of scope. ### Referee Eligibility -51. A referee MUST be a brand-new Kilo account to qualify for referral rewards. +52. A referee MUST be a brand-new Kilo account to qualify for referral rewards. -52. Existing users MUST NOT qualify as referees, even if they later click a referral link. +53. Existing users MUST NOT qualify as referees, even if they later click a referral link. -53. Adding an auth provider to an existing Kilo user MUST NOT qualify as a brand-new Kilo account. +54. Adding an auth provider to an existing Kilo user MUST NOT qualify as a brand-new Kilo account. -54. Previously deleted users MUST NOT qualify as referees. Previously deleted user disqualification MUST use a +55. Previously deleted users MUST NOT qualify as referees. Previously deleted user disqualification MUST use a legal-approved normalized-email hash tombstone. -55. A referee MUST convert on a personal KiloClaw subscription. Team plans, organization-scoped KiloClaw subscriptions, +56. A referee MUST convert on a personal KiloClaw subscription. Team plans, organization-scoped KiloClaw subscriptions, and non-KiloClaw subscriptions MUST NOT qualify. -56. A referee MUST make a first confirmed paid KiloClaw subscription payment before either side earns a reward. +57. A referee MUST make a first confirmed paid KiloClaw subscription payment before either side earns a reward. -57. The first confirmed paid KiloClaw subscription payment MUST fund a monetized KiloClaw payment period. +58. The first confirmed paid KiloClaw subscription payment MUST fund a monetized KiloClaw payment period. -58. Trial start, trial end, account signup, widget registration, zero-dollar invoices, fully comped periods, admin +59. Trial start, trial end, account signup, widget registration, zero-dollar invoices, fully comped periods, admin adjustments, or referral touch capture MUST NOT qualify as a paid referral conversion. -59. A referee's renewals after the first paid KiloClaw conversion MUST NOT generate additional referral rewards. +60. A referee's renewals after the first paid KiloClaw conversion MUST NOT generate additional referral rewards. -60. A user MUST NOT refer themselves. The system MUST disqualify a referral when the referrer and referee resolve to the +61. A user MUST NOT refer themselves. The system MUST disqualify a referral when the referrer and referee resolve to the same Kilo user. -61. Fraudulent, test, admin-created, or manually adjusted subscriptions MUST NOT qualify for referral rewards unless an +62. Fraudulent, test, admin-created, or manually adjusted subscriptions MUST NOT qualify for referral rewards unless an authorized operator explicitly marks the conversion as eligible under a documented support process. ### Referrer Eligibility -62. A referrer MUST be a Kilo user registered or registerable as an Impact Advocate participant. +63. A referrer MUST be a Kilo user registered or registerable as an Impact Advocate participant. -63. A referrer's current KiloClaw subscription state MUST NOT prevent reward earning. +64. A referrer's current KiloClaw subscription state MUST NOT prevent reward earning. -64. If a referrer has no active eligible personal KiloClaw subscription when the reward is earned, the system MUST keep the +65. If a referrer has no active eligible personal KiloClaw subscription when the reward is earned, the system MUST keep the reward pending so it can be applied when the referrer starts or reactivates an eligible personal KiloClaw subscription. -65. A pending inactive-referrer reward MUST expire and be canceled 12 months after it is earned if the referrer has not +66. A pending inactive-referrer reward MUST expire and be canceled 12 months after it is earned if the referrer has not started or reactivated an eligible paid personal KiloClaw subscription. -66. A pending referrer reward MUST NOT apply to a KiloClaw trial. It MUST apply to the next unpaid renewal boundary after +67. A pending referrer reward MUST NOT apply to a KiloClaw trial. It MUST apply to the next unpaid renewal boundary after the referrer starts or reactivates a paid personal KiloClaw subscription. -67. A referrer MUST NOT receive more than 12 total free-month rewards from the referral program. +68. A referrer MUST NOT receive more than 12 total free-month rewards from the referral program. -68. The referrer cap MUST be enforced before granting a referrer reward. +69. The referrer cap MUST be enforced before granting a referrer reward. -69. The 12-month referrer cap MUST be enforced atomically across concurrent reward grants. Concurrent processing MUST NOT +70. The 12-month referrer cap MUST be enforced atomically across concurrent reward grants. Concurrent processing MUST NOT produce more than 12 granted referrer reward months. -70. When a qualified referral occurs after the referrer has reached the 12-month cap, the system MUST record that the +71. When a qualified referral occurs after the referrer has reached the 12-month cap, the system MUST record that the referrer reward was cap-limited and MUST NOT grant another referrer free month. -71. Referee rewards MUST NOT count against the referrer's 12-month cap. +72. Referee rewards MUST NOT count against the referrer's 12-month cap. ### Reward Granting -72. A qualified referral conversion MUST grant one free-month reward to the referee. +73. A qualified referral conversion MUST grant one free-month reward to the referee. -73. A qualified referral conversion MUST grant one free-month reward to the referrer. The reward MUST be marked +74. A qualified referral conversion MUST grant one free-month reward to the referrer. The reward MUST be marked cap-limited instead of granted when the referrer cap has been reached or another referrer eligibility rule prevents it. -74. Referral reward granting MUST be idempotent. Processing the same qualifying conversion multiple times MUST NOT create +75. Referral reward granting MUST be idempotent. Processing the same qualifying conversion multiple times MUST NOT create duplicate rewards for the same beneficiary role. -75. For a qualified referral, reward grant processing MUST be atomic across both beneficiary reward decisions. Both +76. For a qualified referral, reward grant processing MUST be atomic across both beneficiary reward decisions. Both beneficiary outcomes MUST be recorded together, including granted, cap-limited, and disqualified outcomes. -76. Reward records MUST identify the source referral, source conversion, beneficiary user, beneficiary role, number of +77. Reward records MUST identify the source referral, source conversion, beneficiary user, beneficiary role, number of months granted, status, and relevant timestamps. -77. Reward records MUST support the reward states defined in this spec. +78. Reward records MUST support the reward states defined in this spec. -78. A reward MUST NOT be considered fulfilled until KiloClaw billing state and any required Stripe state have been +79. A reward MUST NOT be considered fulfilled until KiloClaw billing state and any required Stripe state have been successfully updated so the corresponding KiloClaw renewal is delayed. -79. Impact Advocate reward state MAY be used for reconciliation, support, or reporting. It MUST NOT be the source of +80. Impact Advocate reward state MAY be used for reconciliation, support, or reporting. It MUST NOT be the source of truth for local free-month fulfillment. ### Reward Fulfillment and Billing -80. Free-month rewards MUST be fulfilled by delaying a KiloClaw renewal by one calendar month per reward. +81. Free-month rewards MUST be fulfilled by delaying a KiloClaw renewal by one calendar month per reward. -81. An earned reward applies to the beneficiary's next unpaid renewal boundary after the reward is earned. It MUST NOT +82. An earned reward applies to the beneficiary's next unpaid renewal boundary after the reward is earned. It MUST NOT modify already-finalized invoices or already-funded periods. -82. Free-month rewards MUST NOT be fulfilled as general account credits. +83. Free-month rewards MUST NOT be fulfilled as general account credits. -83. Free-month rewards MUST apply to KiloClaw billing only. They MUST NOT apply to inference usage, Kilo Pass, team plans, +84. Free-month rewards MUST apply to KiloClaw billing only. They MUST NOT apply to inference usage, Kilo Pass, team plans, or non-KiloClaw purchases. -84. Multiple free-month rewards MAY stack. Each applied reward MUST delay renewal by exactly one calendar month. +85. Multiple free-month rewards MAY stack. Each applied reward MUST delay renewal by exactly one calendar month. -85. For month-to-month KiloClaw subscriptions, one reward MUST delay the next monthly renewal by one calendar month. +86. For month-to-month KiloClaw subscriptions, one reward MUST delay the next monthly renewal by one calendar month. -86. For six-month commitment KiloClaw subscriptions, one reward MUST delay the next six-month renewal by one calendar +87. For six-month commitment KiloClaw subscriptions, one reward MUST delay the next six-month renewal by one calendar month. The reward MUST NOT convert the subscription to month-to-month and MUST NOT reduce the next invoice by one sixth. -87. For pure-credit KiloClaw subscriptions, reward application MUST update local renewal state so the credit renewal sweep +88. For pure-credit KiloClaw subscriptions, reward application MUST update local renewal state so the credit renewal sweep does not deduct KiloClaw hosting credits until the extended renewal time. -88. For Stripe-funded or hybrid KiloClaw subscriptions, reward application MUST keep local billing state and Stripe +89. For Stripe-funded or hybrid KiloClaw subscriptions, reward application MUST keep local billing state and Stripe billing state consistent. The system MUST NOT create a local-only renewal delay for a Stripe-funded subscription while allowing Stripe to charge on the original schedule. -89. Reward application MUST be idempotent. Retrying reward application MUST NOT extend the same subscription more than +90. Reward application MUST be idempotent. Retrying reward application MUST NOT extend the same subscription more than once for the same reward. -90. Reward application MUST record an audit trail containing the reward, beneficiary, affected subscription, previous +91. Reward application MUST record an audit trail containing the reward, beneficiary, affected subscription, previous renewal or period boundary, new renewal or period boundary, and any external billing operation identifiers. -91. Reward application MUST NOT break existing KiloClaw billing invariants for trials, pure-credit renewal, hybrid invoice +92. Reward application MUST NOT break existing KiloClaw billing invariants for trials, pure-credit renewal, hybrid invoice settlement, commit plans, plan switching, cancellation, reactivation, past-due recovery, suspension, or destruction. -92. Reward application MUST respect cancellation state. If a subscription is canceled or canceling before reward +93. Reward application MUST respect cancellation state. If a subscription is canceled or canceling before reward application, the reward MUST remain pending until the beneficiary has an active eligible personal KiloClaw subscription. ### Impact Conversion Reporting -93. Impact Advocate referral conversion MUST be driven by the existing Impact Performance conversion events. +94. Impact Advocate referral conversion MUST be driven by the existing Impact Performance conversion events. -94. `Sale (71659)` MUST be the paid KiloClaw conversion event used for referral conversion and renewal reporting. +95. `Sale (71659)` MUST be the paid KiloClaw conversion event used for referral conversion and renewal reporting. -95. The system MUST NOT dispatch client-side `trackConversion` for referrals while server-side Performance conversion is +96. The system MUST NOT dispatch client-side `trackConversion` for referrals while server-side Performance conversion is the configured reporting mechanism. -96. When a referral wins attribution and the first paid conversion qualifies, the system MUST ensure Impact receives the +97. When a referral wins attribution and the first paid conversion qualifies, the system MUST ensure Impact receives the required Performance conversion data for Advocate conversion reporting. -97. Conversion reporting MUST use deterministic order identifiers where possible so retries do not create duplicate Impact +98. Conversion reporting MUST use deterministic order identifiers where possible so retries do not create duplicate Impact actions. -98. Conversion reporting failures MUST NOT block billing settlement, reward ledger creation, or user access. Failures MUST +99. Conversion reporting failures MUST NOT block billing settlement, reward ledger creation, or user access. Failures MUST leave the conversion report in a retryable state until it succeeds, is superseded by a corrected payload, or is marked permanently failed by an operator-visible terminal state. ### Impact Reconciliation -99. The system MUST NOT rely on Impact Advocate webhooks for referral eligibility, reward granting, billing fulfillment, - or reconciliation. +100. The system MUST NOT rely on Impact Advocate webhooks for referral eligibility, reward granting, billing fulfillment, + or reconciliation. -100. The system MAY use Impact dashboard exports or API reads for manual reconciliation and support investigations. +101. The system MAY use Impact dashboard exports or API reads for manual reconciliation and support investigations. -101. Impact reconciliation data MAY update local Impact-facing status fields, but it MUST NOT bypass local eligibility, +102. Impact reconciliation data MAY update local Impact-facing status fields, but it MUST NOT bypass local eligibility, cap, attribution, or billing fulfillment rules. ### Refunds, Reversals, and Fraud -102. Rewards from a qualifying Stripe payment MUST be canceled if Stripe reports a chargeback for that payment. +103. Rewards from a qualifying Stripe payment MUST be canceled if Stripe reports a chargeback for that payment. -103. Pending or earned-but-unapplied rewards MUST be canceled when the qualifying Stripe payment is charged back. +104. Pending or earned-but-unapplied rewards MUST be canceled when the qualifying Stripe payment is charged back. -104. Already-applied rewards from a charged-back Stripe payment MUST be marked for support review and MUST NOT be +105. Already-applied rewards from a charged-back Stripe payment MUST be marked for support review and MUST NOT be automatically canceled or clawed back. -105. Rewards from refunded or fraud-marked payments MUST be canceled before application. Already-applied rewards from +106. Rewards from refunded or fraud-marked payments MUST be canceled before application. Already-applied rewards from refunded or fraud-marked payments MUST be marked for support review and MUST NOT be automatically canceled or clawed back. -106. If a qualifying Impact action must be reversed, the system SHOULD use Impact's reverse-action mechanism instead of +107. If a qualifying Impact action must be reversed, the system SHOULD use Impact's reverse-action mechanism instead of creating an unrelated negative conversion. -107. Reversal and reward-cancellation handling MUST be idempotent. +108. Reversal and reward-cancellation handling MUST be idempotent. ### GDPR and PII -108. Referral tables that store user IDs, emails, referral relationships, IP addresses, referral cookies, Impact IDs, or +109. Referral tables that store user IDs, emails, referral relationships, IP addresses, referral cookies, Impact IDs, or reconciliation payloads MUST be included in GDPR soft-delete or anonymization flows. -109. GDPR deletion MUST delete or anonymize referral participant records, referral touch records, referral relationship +110. GDPR deletion MUST delete or anonymize referral participant records, referral touch records, referral relationship records, reconciliation payloads containing PII, and reward records to the extent required by policy. -110. Plain email stored for Impact Advocate compatibility MUST be deleted or anonymized during GDPR deletion. +111. Plain email stored for Impact Advocate compatibility MUST be deleted or anonymized during GDPR deletion. -111. Previously deleted user disqualification MUST use a legal-approved non-PII tombstone or irreversible hash. The system +112. Previously deleted user disqualification MUST use a legal-approved non-PII tombstone or irreversible hash. The system MUST NOT retain PII solely for this purpose. -112. Referral tracking values MUST NOT be logged in a way that exposes secrets, auth headers, cookies, or unnecessary PII. +113. Referral tracking values MUST NOT be logged in a way that exposes secrets, auth headers, cookies, or unnecessary PII. ### Reliability and Isolation -113. Referral touch capture, participant registration, conversion reporting, reconciliation processing, and reward +114. Referral touch capture, participant registration, conversion reporting, reconciliation processing, and reward fulfillment failures MUST NOT break unrelated product functionality. -114. Reward ledger operations MUST be transactional where needed to prevent duplicate grants, partial grants, or missing +115. Reward ledger operations MUST be transactional where needed to prevent duplicate grants, partial grants, or missing audit records. -115. Reward fulfillment failures MUST leave rewards in a retryable state unless the failure is a permanent eligibility or +116. Reward fulfillment failures MUST leave rewards in a retryable state unless the failure is a permanent eligibility or configuration failure. -116. The system MUST expose enough operational state to distinguish pending Impact registration, pending Impact conversion +117. The system MUST expose enough operational state to distinguish pending Impact registration, pending Impact conversion reporting, pending local reward application, applied rewards, reversed rewards, canceled rewards, review-required rewards, and disqualified referrals. -117. Admin-only subscription interventions, internal test conversions, and support adjustments MUST NOT emit referral +118. Admin-only subscription interventions, internal test conversions, and support adjustments MUST NOT emit referral rewards or Impact referral conversions unless explicitly marked as eligible by an authorized operator. ### Existing Internal Referral System -118. The existing internal referral-code system MUST NOT grant additional KiloClaw referral rewards for conversions already +119. The existing internal referral-code system MUST NOT grant additional KiloClaw referral rewards for conversions already governed by this spec. -119. Before launch, the existing internal referral system MUST be scoped away from KiloClaw, disabled for KiloClaw, or +120. Before launch, the existing internal referral system MUST be scoped away from KiloClaw, disabled for KiloClaw, or migrated into this program's rules to prevent double rewards. ## Error Handling diff --git a/apps/web/src/app/api/impact-advocate/token/route.ts b/apps/web/src/app/api/impact-advocate/token/route.ts index e4763cb552..93dd129b4e 100644 --- a/apps/web/src/app/api/impact-advocate/token/route.ts +++ b/apps/web/src/app/api/impact-advocate/token/route.ts @@ -1,6 +1,6 @@ -import assert from 'node:assert'; -import { eq } from 'drizzle-orm'; +import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; + import { referral_codes } from '@kilocode/db/schema'; import { db } from '@/lib/drizzle'; import { getUserFromAuth } from '@/lib/user.server'; @@ -8,21 +8,24 @@ import { getImpactAdvocateWidgetId, issueImpactAdvocateVerifiedAccessToken, } from '@/lib/impact-advocate'; -import { ensureImpactAdvocateParticipantProfile } from '@/lib/impact-referral'; +import { + countryCodeFromHeaders, + localeFromHeaders, + queueImpactAdvocateSelfRegistration, +} from '@/lib/impact-referral'; -async function getOrCreateOpaqueReferralIdentifier(userId: string): Promise { - const generated = crypto.randomUUID(); +/** + * Internal Kilo referral code (kept for legacy/internal attribution flows in + * `referral_codes`). This is intentionally NOT linked to + * `impact_advocate_participants.opaque_referral_identifier` anymore — that + * column is now reserved for the SaaSquatch-issued referral code so the + * conversion lifecycle's referrer-resolution lookup actually works. + */ +async function ensureInternalReferralCode(userId: string): Promise { await db .insert(referral_codes) - .values({ kilo_user_id: userId, code: generated }) - .onConflictDoNothing(); - - const rows = await db - .select() - .from(referral_codes) - .where(eq(referral_codes.kilo_user_id, userId)); - assert.equal(rows.length, 1); - return rows[0].code; + .values({ kilo_user_id: userId, code: crypto.randomUUID() }) + .onConflictDoNothing({ target: [referral_codes.kilo_user_id] }); } export async function GET() { @@ -41,10 +44,18 @@ export async function GET() { } try { - const opaqueReferralIdentifier = await getOrCreateOpaqueReferralIdentifier(user.id); - await ensureImpactAdvocateParticipantProfile({ + await ensureInternalReferralCode(user.id); + + // Mirror the user into SaaSquatch as an advocate so they become + // discoverable when their referees convert. The dispatcher reads the + // SaaSquatch-issued code out of the response and persists it as + // `participants.opaque_referral_identifier`. Idempotent across repeat + // page loads via dedupe key. + const requestHeaders = await headers(); + await queueImpactAdvocateSelfRegistration({ user, - opaqueReferralIdentifier, + locale: localeFromHeaders(requestHeaders), + countryCode: countryCodeFromHeaders(requestHeaders), }); } catch (error) { console.error('[impact-advocate-token] failed to prepare referral sharing identity', { diff --git a/apps/web/src/lib/impact-referral.test.ts b/apps/web/src/lib/impact-referral.test.ts index 4fa0a818e9..4d06b21150 100644 --- a/apps/web/src/lib/impact-referral.test.ts +++ b/apps/web/src/lib/impact-referral.test.ts @@ -423,4 +423,120 @@ describe('impact referral participant registration dispatch', () => { expect(incumbentParticipant?.opaque_referral_identifier).toBe('COLLIDING_CODE'); } ); + + describe('queueImpactAdvocateSelfRegistration', () => { + it('queues an Upsert User attempt with empty cookies and persists the SaaSquatch code on dispatch', async () => { + const fetchMock = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 'sq-self-id', + email: 'advocate@example.com', + referralCodes: { '51699': 'ADVOCATE7777' }, + referable: true, + }), + { status: 200 } + ) + ); + global.fetch = fetchMock; + + const user = await insertTestUser({ + google_user_email: 'advocate@example.com', + normalized_email: 'advocate@example.com', + }); + + const { + dispatchQueuedImpactAdvocateRegistrationAttempts, + queueImpactAdvocateSelfRegistration, + } = await import('@/lib/impact-referral'); + + await queueImpactAdvocateSelfRegistration({ + user, + locale: 'en-US', + countryCode: 'US', + }); + + // Attempt was queued without a cookie value. + const [queued] = await db.select().from(impact_advocate_registration_attempts); + expect(queued.delivery_state).toBe('queued'); + expect(queued.opaque_cookie_value).toBeNull(); + expect(queued.cookie_value_length).toBe(0); + + const summary = await dispatchQueuedImpactAdvocateRegistrationAttempts(); + expect(summary).toEqual({ claimed: 1, delivered: 1, retried: 0, failed: 0 }); + + // Body sent over the wire has empty cookies and locale normalised. + const requestBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + expect(requestBody).toEqual({ + id: 'advocate@example.com', + accountId: 'advocate@example.com', + email: 'advocate@example.com', + cookies: '', + locale: 'en_US', + countryCode: 'US', + }); + + // Participant now carries the SaaSquatch code so future referee touches + // resolve back to this user. + const participant = await db.query.impact_advocate_participants.findFirst({ + where: eq(impact_advocate_participants.user_id, user.id), + }); + expect(participant?.registration_state).toBe('registered'); + expect(participant?.opaque_referral_identifier).toBe('ADVOCATE7777'); + }); + + it('is idempotent across repeat calls (deduped by user id)', async () => { + const fetchMock = jest.fn().mockResolvedValue( + new Response( + JSON.stringify({ + id: 'sq-id', + email: 'advocate@example.com', + referralCodes: { '51699': 'ADVOCATE7777' }, + referable: true, + }), + { status: 200 } + ) + ); + global.fetch = fetchMock; + + const user = await insertTestUser({ + google_user_email: 'advocate@example.com', + normalized_email: 'advocate@example.com', + }); + + const { queueImpactAdvocateSelfRegistration } = await import('@/lib/impact-referral'); + + await queueImpactAdvocateSelfRegistration({ user }); + await queueImpactAdvocateSelfRegistration({ user }); + await queueImpactAdvocateSelfRegistration({ user }); + + const attempts = await db.select().from(impact_advocate_registration_attempts); + expect(attempts).toHaveLength(1); + }); + + it('skips queueing once the participant is already registered with a code', async () => { + const fetchMock = jest.fn(); + global.fetch = fetchMock; + + const user = await insertTestUser({ + google_user_email: 'advocate@example.com', + normalized_email: 'advocate@example.com', + }); + // Pretend SaaSquatch has already registered them and we have the code. + await db.insert(impact_advocate_participants).values({ + user_id: user.id, + advocate_id: user.google_user_email, + advocate_account_id: user.google_user_email, + opaque_referral_identifier: 'ADVOCATE7777', + registration_state: 'registered', + registered_at: new Date('2026-04-01T00:00:00.000Z').toISOString(), + }); + + const { queueImpactAdvocateSelfRegistration } = await import('@/lib/impact-referral'); + await queueImpactAdvocateSelfRegistration({ user }); + + const attempts = await db.select().from(impact_advocate_registration_attempts); + expect(attempts).toHaveLength(0); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/web/src/lib/impact-referral.ts b/apps/web/src/lib/impact-referral.ts index 05fd03885a..a80008240c 100644 --- a/apps/web/src/lib/impact-referral.ts +++ b/apps/web/src/lib/impact-referral.ts @@ -352,6 +352,126 @@ export async function queueImpactAdvocateParticipantRegistration(params: { .where(eq(impact_advocate_participants.id, participant.id)); } +/** + * Queue an Upsert User attempt for an advocate-only Kilo user — someone who + * has not arrived through a referral cookie themselves but is now actively + * trying to share a referral link (e.g. the user has loaded /claw/refer). + * + * Without this, the only Kilo users with `impact_advocate_participants` rows + * are referees; advocate-only users would have either no row or a row whose + * `opaque_referral_identifier` was a Kilo-side UUID with no relationship to + * the SaaSquatch-issued referral code. That UUID can never match an inbound + * referee touch's `rs_code`, so the conversion lifecycle would resolve + * `referrerUserId = null` and silently undercount attribution on the Kilo + * side. See spec rules 11 and 51. + * + * Idempotent: deduped by user id, so repeated `/claw/refer` visits don't + * stack attempts. The dispatcher (dispatchImpactAdvocateRegistrationAttemptById) + * extracts the SaaSquatch code from the response and writes it to + * `participants.opaque_referral_identifier` exactly the same way as for + * referee registrations. + */ +export async function queueImpactAdvocateSelfRegistration(params: { + database?: DatabaseClient; + user: Pick; + locale?: string | null; + countryCode?: string | null; +}): Promise { + const database = getDatabaseClient(params.database); + const isConfigured = isImpactAdvocateConfigured(); + const nowIso = new Date().toISOString(); + + // Empty cookie envelope — advocate-only users have no inbound attribution. + // SaaSquatch's Verified Access widget creates such users on the fly when + // the JWT identifies them; this server-side mirror produces the same + // outcome and lets us read the referralCodes back from the response. + const payload = buildImpactAdvocateRegisterParticipantPayload({ + user: params.user, + referralCookieValue: '', + locale: params.locale, + countryCode: params.countryCode, + }); + + const participant = await ensureImpactAdvocateParticipantProfile({ + database, + user: params.user, + locale: params.locale, + countryCode: params.countryCode, + }); + + const existing = await database.query.impact_advocate_participants.findFirst({ + where: eq(impact_advocate_participants.id, participant.id), + columns: { registration_state: true, opaque_referral_identifier: true }, + }); + if ( + existing?.registration_state === ImpactAdvocateRegistrationState.Registered && + existing.opaque_referral_identifier?.trim() + ) { + logImpactReferralDebug( + 'Skipped Impact Advocate self-registration; participant already registered with code', + { + userId: params.user.id, + participantId: participant.id, + } + ); + return; + } + + const attemptDedupeKey = buildHashedDedupeKey([ + 'impact-advocate-self-registration', + params.user.id, + ]); + + const [insertedAttempt] = await database + .insert(impact_advocate_registration_attempts) + .values({ + participant_id: participant.id, + dedupe_key: attemptDedupeKey, + opaque_cookie_value: null, + cookie_value_length: 0, + delivery_state: isConfigured + ? ImpactAdvocateAttemptDeliveryState.Queued + : ImpactAdvocateAttemptDeliveryState.Failed, + request_payload: payload satisfies Record, + response_payload: isConfigured + ? null + : ({ error: 'missing_configuration' } satisfies Record), + response_status_code: isConfigured ? null : 503, + }) + .onConflictDoNothing({ target: [impact_advocate_registration_attempts.dedupe_key] }) + .returning({ id: impact_advocate_registration_attempts.id }); + + logImpactReferralDebug( + insertedAttempt + ? 'Queued Impact Advocate self-registration attempt' + : 'Impact Advocate self-registration attempt already existed', + { + userId: params.user.id, + participantId: participant.id, + attemptId: insertedAttempt?.id ?? null, + impactAdvocateConfigured: isConfigured, + localePresent: Boolean(params.locale?.trim()), + countryCode: params.countryCode ?? null, + } + ); + + if (!insertedAttempt) { + return; + } + + await database + .update(impact_advocate_participants) + .set({ + registration_state: isConfigured + ? ImpactAdvocateRegistrationState.Pending + : ImpactAdvocateRegistrationState.Failed, + last_error_code: isConfigured ? null : 'missing_configuration', + last_error_message: isConfigured ? null : 'Impact Advocate configuration is incomplete', + last_registration_attempt_at: nowIso, + }) + .where(eq(impact_advocate_participants.id, participant.id)); +} + export async function createDeletedUserEmailTombstone(params: { database?: DatabaseClient; normalizedEmail: string | null; From 59ad8ac33d9fd5ace5edf99dfa5d8580b452d9d4 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 15:08:11 +0200 Subject: [PATCH 21/32] feat(dev-seed): support structured seed output --- dev/seed/kiloclaw/referrals-cap-boundary.ts | 23 ++++++++------- dev/seed/kiloclaw/referrals-happy-path.ts | 29 ++++++++++--------- .../kiloclaw/referrals-pending-referrer.ts | 27 ++++++++--------- .../kiloclaw/referrals-support-override.ts | 25 ++++++++-------- 4 files changed, 54 insertions(+), 50 deletions(-) diff --git a/dev/seed/kiloclaw/referrals-cap-boundary.ts b/dev/seed/kiloclaw/referrals-cap-boundary.ts index d249324470..53c8ddbcee 100644 --- a/dev/seed/kiloclaw/referrals-cap-boundary.ts +++ b/dev/seed/kiloclaw/referrals-cap-boundary.ts @@ -13,6 +13,7 @@ import { } from '@kilocode/db/schema-types'; import { getSeedDb } from '../lib/db'; +import type { SeedResult } from '../index'; import { addDays, assertUserCount, @@ -43,7 +44,7 @@ function buildHistoricalReferee(i: number) { }; } -export async function run(): Promise { +export async function run(): Promise { const db = getSeedDb(); const historicalReferees = Array.from({ length: 12 }, (_, index) => buildHistoricalReferee(index + 1) @@ -257,16 +258,6 @@ export async function run(): Promise { ) ); - console.log(''); - console.log(`[${SEED_SCOPE}] Seed complete`); - console.log(''); - console.log(`referrerUserId: ${referrerUserId}`); - console.log(`currentRefereeUserId: ${currentRefereeUserId}`); - console.log(`currentReferralId: ${currentReferral.id}`); - console.log(`currentConversionId: ${currentConversion.id}`); - console.log(`currentCapLimitedDecisionId: ${currentCapLimitedDecisionId}`); - console.log(`referrerSubscriptionId: ${referrerSubscription.id}`); - console.log(`grantedReferrerMonthsBeforeCapDecision: ${referrerGrantedMonths.length}`); console.log(''); console.log('This fixture represents:'); console.log('- 12 previously granted referrer reward months already recorded'); @@ -274,4 +265,14 @@ export async function run(): Promise { console.log( '- the referrer decision is recorded as cap-limited with no extra referrer reward row' ); + + return { + referrerUserId, + currentRefereeUserId, + currentReferralId: currentReferral.id, + currentConversionId: currentConversion.id, + currentCapLimitedDecisionId, + referrerSubscriptionId: referrerSubscription.id, + grantedReferrerMonthsBeforeCapDecision: referrerGrantedMonths.length, + }; } diff --git a/dev/seed/kiloclaw/referrals-happy-path.ts b/dev/seed/kiloclaw/referrals-happy-path.ts index 2a00ae4b16..43736ce0ec 100644 --- a/dev/seed/kiloclaw/referrals-happy-path.ts +++ b/dev/seed/kiloclaw/referrals-happy-path.ts @@ -19,6 +19,7 @@ import { } from '@kilocode/db/schema-types'; import { getSeedDb } from '../lib/db'; +import type { SeedResult } from '../index'; import { assertUserCount, cleanupKiloClawReferralSeedScenario, @@ -51,7 +52,7 @@ const convertedAt = '2026-04-15T16:30:00.000Z'; const previousRenewalBoundary = '2026-05-01T00:00:00.000Z'; const newRenewalBoundary = '2026-06-01T00:00:00.000Z'; -export async function run(): Promise { +export async function run(): Promise { const db = getSeedDb(); console.log(`[${SEED_SCOPE}] Resetting existing seed data`); @@ -294,19 +295,6 @@ export async function run(): Promise { delivered_at: '2026-04-15T16:35:00.000Z', }); - console.log(''); - console.log(`[${SEED_SCOPE}] Seed complete`); - console.log(''); - console.log(`referrerUserId: ${referrerUserId}`); - console.log(`refereeUserId: ${refereeUserId}`); - console.log(`referralId: ${referral.id}`); - console.log(`conversionId: ${conversion.id}`); - console.log(`affiliateTouchId: ${affiliateTouch.id}`); - console.log(`referralTouchId: ${referralTouch.id}`); - console.log(`sourcePaymentId: ${sourcePaymentId}`); - console.log(`orderId: ${orderId}`); - console.log(`referrerSubscriptionId: ${referrerSubscription.id}`); - console.log(`refereeSubscriptionId: ${refereeSubscription.id}`); console.log(''); console.log('This fixture represents:'); console.log('- affiliate touch first, referral touch second'); @@ -314,4 +302,17 @@ export async function run(): Promise { console.log('- referral wins at first paid conversion'); console.log('- both rewards already applied to personal credits subscriptions'); console.log('- Impact sale report already delivered'); + + return { + referrerUserId, + refereeUserId, + referralId: referral.id, + conversionId: conversion.id, + affiliateTouchId: affiliateTouch.id, + referralTouchId: referralTouch.id, + sourcePaymentId, + orderId, + referrerSubscriptionId: referrerSubscription.id, + refereeSubscriptionId: refereeSubscription.id, + }; } diff --git a/dev/seed/kiloclaw/referrals-pending-referrer.ts b/dev/seed/kiloclaw/referrals-pending-referrer.ts index 4238ac413a..912684e295 100644 --- a/dev/seed/kiloclaw/referrals-pending-referrer.ts +++ b/dev/seed/kiloclaw/referrals-pending-referrer.ts @@ -19,6 +19,7 @@ import { } from '@kilocode/db/schema-types'; import { getSeedDb } from '../lib/db'; +import type { SeedResult } from '../index'; import { addMonthsUtc, assertUserCount, @@ -50,7 +51,7 @@ const convertedAt = '2026-04-15T16:30:00.000Z'; const previousRenewalBoundary = '2026-05-01T00:00:00.000Z'; const newRenewalBoundary = '2026-06-01T00:00:00.000Z'; -export async function run(): Promise { +export async function run(): Promise { const db = getSeedDb(); console.log(`[${SEED_SCOPE}] Resetting existing seed data`); @@ -260,22 +261,22 @@ export async function run(): Promise { delivered_at: '2026-04-15T16:35:00.000Z', }); - console.log(''); - console.log(`[${SEED_SCOPE}] Seed complete`); - console.log(''); - console.log(`referrerUserId: ${referrerUserId}`); - console.log(`refereeUserId: ${refereeUserId}`); - console.log(`referralId: ${referral.id}`); - console.log(`conversionId: ${conversion.id}`); - console.log(`sourcePaymentId: ${sourcePaymentId}`); - console.log(`orderId: ${orderId}`); - console.log(`referrerTrialSubscriptionId: ${referrerTrialSubscription.id}`); - console.log(`refereeSubscriptionId: ${refereeSubscription.id}`); - console.log(`pendingReferrerRewardId: ${referrerRewardId}`); console.log(''); console.log('This fixture represents:'); console.log('- a qualified referral conversion'); console.log('- the referee reward already applied'); console.log('- the referrer still on a trial, so their reward remains pending'); console.log('- the pending reward already has a 12-month expiration timestamp'); + + return { + referrerUserId, + refereeUserId, + referralId: referral.id, + conversionId: conversion.id, + sourcePaymentId, + orderId, + referrerTrialSubscriptionId: referrerTrialSubscription.id, + refereeSubscriptionId: refereeSubscription.id, + pendingReferrerRewardId: referrerRewardId, + }; } diff --git a/dev/seed/kiloclaw/referrals-support-override.ts b/dev/seed/kiloclaw/referrals-support-override.ts index 531f82d634..163bdbc621 100644 --- a/dev/seed/kiloclaw/referrals-support-override.ts +++ b/dev/seed/kiloclaw/referrals-support-override.ts @@ -5,6 +5,7 @@ import { } from '@kilocode/db/schema-types'; import { getSeedDb } from '../lib/db'; +import type { SeedResult } from '../index'; import { assertUserCount, cleanupKiloClawReferralSeedScenario, @@ -31,7 +32,7 @@ const sourcePaymentId = seedSourcePaymentId(SCENARIO, 'manual-adjustment'); const orderId = seedOrderId(SCENARIO, 'manual-adjustment'); const convertedAt = '2026-04-15T16:30:00.000Z'; -export async function run(): Promise { +export async function run(): Promise { const db = getSeedDb(); console.log(`[${SEED_SCOPE}] Resetting existing seed data`); @@ -137,17 +138,6 @@ export async function run(): Promise { created_at: convertedAt, }); - console.log(''); - console.log(`[${SEED_SCOPE}] Seed complete`); - console.log(''); - console.log(`referrerUserId: ${referrerUserId}`); - console.log(`refereeUserId: ${refereeUserId}`); - console.log(`referrerSubscriptionId: ${referrerSubscription.id}`); - console.log(`refereeSubscriptionId: ${refereeSubscription.id}`); - console.log(`affiliateTouchId: ${affiliateTouch.id}`); - console.log(`referralTouchId: ${referralTouch.id}`); - console.log(`sourcePaymentId: ${sourcePaymentId}`); - console.log(`orderId: ${orderId}`); console.log(''); console.log('This fixture represents:'); console.log('- a valid referral touch that would normally win over the affiliate touch'); @@ -169,4 +159,15 @@ export async function run(): Promise { } )}'` ); + + return { + referrerUserId, + refereeUserId, + referrerSubscriptionId: referrerSubscription.id, + refereeSubscriptionId: refereeSubscription.id, + affiliateTouchId: affiliateTouch.id, + referralTouchId: referralTouch.id, + sourcePaymentId, + orderId, + }; } From 696ff93dd775625748fd32058653030fe38ba94c Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Tue, 5 May 2026 15:08:21 +0200 Subject: [PATCH 22/32] docs(dev-seed): document seed module contract --- dev/seed/AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dev/seed/AGENTS.md b/dev/seed/AGENTS.md index 4eb482c53f..26d25f6e84 100644 --- a/dev/seed/AGENTS.md +++ b/dev/seed/AGENTS.md @@ -11,6 +11,7 @@ dev/seed/ preflight.ts Import FIRST; mutates process.env from argv. db.ts Lazy drizzle client. stripe.ts Lazy Stripe test-mode client/customer helpers. + kiloclaw-referrals.ts KiloClaw referral fixtures/helpers. /.ts Topic module. Scope = folder; topic = filename. ``` @@ -40,7 +41,7 @@ Topic files MUST: - `type SeedResult = Record`: flat JSON primitives only; no nested objects; stringify Dates. - Return every id/email/handle/balance needed by follow-up commands; the runner formats all output from this object. - Support `--help`/`-h` via local `printUsage()` and early return. -- Reset only their own data at start for idempotent reruns. Delete by stable ids/emails, stable sandbox prefixes, or `dev-seed:` category prefixes. +- Reset only their own data at start for idempotent reruns. Referral seeds use `cleanupKiloClawReferralSeedScenario`; ad-hoc topics delete by stable ids/emails, stable sandbox prefixes, or `dev-seed:` category prefixes. - Avoid module-level side effects (DB writes/network); no-args listing imports modules. Topic files SHOULD: @@ -64,6 +65,7 @@ Topic files MUST NOT: - `deleteSeedStripeCustomer(id)`: rollback helper; swallows "no such customer". - `lib/stripe.ts` rejects missing/non-`sk_test_...` `STRIPE_SECRET_KEY`. - For seeded users used by Stripe-touching app code (`/profile`, billing pages, KiloClaw subscriptions), create a real Stripe customer. Never use `cus_seed_...`: it causes `StripeInvalidRequestError: No such customer` 400s. Order matches `createUserOnSignIn`: create Stripe customer, insert DB row, delete Stripe customer in `catch` on insert failure. +- `lib/kiloclaw-referrals.ts`: deterministic id/email/payment-id factories (`seedUserId`, `seedEmail`, `seedOpaqueReferralIdentifier`, ...), `cleanupKiloClawReferralSeedScenario`, and `insertSeedUsers`. Use for new referral scenarios so cleanup stays consistent. ## Direct user inserts From 28250b902262858e4f767dc1e6e11dba84fce981 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 7 May 2026 10:24:37 +0200 Subject: [PATCH 23/32] fix(kiloclaw): resolve rebase fallout --- apps/web/src/routers/kiloclaw-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index 7e0dc12e14..f9c443d325 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -2731,7 +2731,7 @@ export const kiloclawRouter = createTRPCRouter({ } if (isFakeSeedInstance(instance)) { - return createFakeSeedInstanceStatus(instance, workerUrl); + return createFakeSeedInstanceStatus(instance, legacyWorkerUrl); } const client = new KiloClawInternalClient(); From e57a53c169136f8b7efbcf52c9ff9e8f733fb0b4 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 7 May 2026 12:30:10 +0200 Subject: [PATCH 24/32] feat(referrals): redeem Impact Advocate rewards --- .specs/kiloclaw-referrals.md | 35 ++ .../dispatch-affiliate-events/route.test.ts | 19 + .../cron/dispatch-affiliate-events/route.ts | 4 + apps/web/src/lib/impact-advocate.test.ts | 71 +++ apps/web/src/lib/impact-advocate.ts | 218 +++++++++ apps/web/src/lib/kiloclaw-referrals.test.ts | 213 ++++++++- apps/web/src/lib/kiloclaw-referrals.ts | 443 +++++++++++++++++- apps/web/src/lib/user.test.ts | 22 + apps/web/src/lib/user.ts | 4 + .../admin/kiloclaw-referrals-router.test.ts | 11 + .../admin/kiloclaw-referrals-router.ts | 46 ++ packages/db/src/schema-types.ts | 10 + packages/db/src/schema.test.ts | 1 + packages/db/src/schema.ts | 58 +++ 14 files changed, 1153 insertions(+), 2 deletions(-) diff --git a/.specs/kiloclaw-referrals.md b/.specs/kiloclaw-referrals.md index 519685ed7d..ae855ec829 100644 --- a/.specs/kiloclaw-referrals.md +++ b/.specs/kiloclaw-referrals.md @@ -12,6 +12,7 @@ and code, not here. ## Status Draft -- created 2026-04-21. +Updated 2026-05-06 -- require Impact Advocate reward redemption after local reward application. ## Conventions @@ -486,6 +487,28 @@ interventions, and non-KiloClaw purchases are out of scope. 120. Before launch, the existing internal referral system MUST be scoped away from KiloClaw, disabled for KiloClaw, or migrated into this program's rules to prevent double rewards. +### Impact Reward Redemption + +121. When a local free-month reward is applied to KiloClaw billing, the system MUST mark the corresponding Impact Advocate + credit reward as redeemed so Impact reporting matches Kilo's fulfillment state. + +122. Impact Advocate reward redemption MUST happen asynchronously and MUST NOT block reward application, billing + settlement, or user access. + +123. Before redeeming an Impact Advocate reward, the system MUST fetch the beneficiary account's rewards from Impact + Advocate and select the corresponding credit reward ID. + +124. Redeeming an Impact Advocate reward MUST use Impact Advocate's single-reward redemption endpoint with the local + reward's granted month count and the configured free-month reward unit. + +125. Impact Advocate reward lookup and redemption attempts MUST be idempotently queued per local reward. + +126. If the Impact reward is not yet visible when redemption is attempted, the system MUST leave the redemption work in a + retryable state. + +127. Impact reward redemption state is for reporting and reconciliation only. It MUST NOT be the source of truth for local + reward eligibility, application, cancellation, or reversal. + ## Error Handling 1. If referral touch capture fails, the system SHOULD log the failure and continue the primary request. @@ -512,8 +535,20 @@ interventions, and non-KiloClaw purchases are out of scope. 8. If required billing state is ambiguous, the system MUST NOT apply a reward. It MUST leave the reward pending and log the ambiguity for investigation. +9. If Impact Advocate reward lookup or redemption fails with a server error or timeout, the system MUST leave the + redemption work in a retryable state. + +10. If Impact Advocate reward lookup or redemption fails with a client error, the system MUST log the error and MUST NOT + retry unchanged payloads, except an already-redeemed response MAY be treated as idempotent success. + ## Changelog +### 2026-05-06 -- Redeem applied rewards in Impact Advocate + +Added rules requiring local free-month reward application to enqueue asynchronous Impact Advocate reward lookup and +single-reward redemption, including retry behavior when rewards are not yet visible and idempotent handling for already +redeemed rewards. + ### 2026-04-21 -- Initial spec Created source-of-truth rules for the KiloClaw referral program using Impact Advocate. Defined program identifiers, diff --git a/apps/web/src/app/api/cron/dispatch-affiliate-events/route.test.ts b/apps/web/src/app/api/cron/dispatch-affiliate-events/route.test.ts index c8d7154cce..1bd18fda8a 100644 --- a/apps/web/src/app/api/cron/dispatch-affiliate-events/route.test.ts +++ b/apps/web/src/app/api/cron/dispatch-affiliate-events/route.test.ts @@ -13,6 +13,7 @@ jest.mock('@/lib/impact-referral', () => ({ })); jest.mock('@/lib/kiloclaw-referrals', () => ({ + dispatchQueuedImpactAdvocateRewardRedemptions: jest.fn(), dispatchQueuedImpactConversionReports: jest.fn(), processQueuedKiloClawReferralRewards: jest.fn(), })); @@ -20,6 +21,7 @@ jest.mock('@/lib/kiloclaw-referrals', () => ({ import { dispatchQueuedAffiliateEvents } from '@/lib/affiliate-events'; import { dispatchQueuedImpactAdvocateRegistrationAttempts } from '@/lib/impact-referral'; import { + dispatchQueuedImpactAdvocateRewardRedemptions, dispatchQueuedImpactConversionReports, processQueuedKiloClawReferralRewards, } from '@/lib/kiloclaw-referrals'; @@ -32,6 +34,9 @@ const mockDispatchQueuedImpactAdvocateRegistrationAttempts = jest.mocked( const mockDispatchQueuedImpactConversionReports = jest.mocked( dispatchQueuedImpactConversionReports ); +const mockDispatchQueuedImpactAdvocateRewardRedemptions = jest.mocked( + dispatchQueuedImpactAdvocateRewardRedemptions +); const mockProcessQueuedKiloClawReferralRewards = jest.mocked(processQueuedKiloClawReferralRewards); describe('GET /api/cron/dispatch-affiliate-events', () => { @@ -52,6 +57,7 @@ describe('GET /api/cron/dispatch-affiliate-events', () => { expect(mockDispatchQueuedImpactAdvocateRegistrationAttempts).not.toHaveBeenCalled(); expect(mockDispatchQueuedImpactConversionReports).not.toHaveBeenCalled(); expect(mockProcessQueuedKiloClawReferralRewards).not.toHaveBeenCalled(); + expect(mockDispatchQueuedImpactAdvocateRewardRedemptions).not.toHaveBeenCalled(); }); it('dispatches queued affiliate events when authorized', async () => { @@ -82,6 +88,12 @@ describe('GET /api/cron/dispatch-affiliate-events', () => { pending: 0, failed: 0, }); + mockDispatchQueuedImpactAdvocateRewardRedemptions.mockResolvedValue({ + claimed: 2, + redeemed: 2, + retried: 0, + failed: 0, + }); const response = await GET( new NextRequest('http://localhost:3000/api/cron/dispatch-affiliate-events', { @@ -97,6 +109,7 @@ describe('GET /api/cron/dispatch-affiliate-events', () => { expect(mockDispatchQueuedImpactAdvocateRegistrationAttempts).toHaveBeenCalledTimes(1); expect(mockDispatchQueuedImpactConversionReports).toHaveBeenCalledTimes(1); expect(mockProcessQueuedKiloClawReferralRewards).toHaveBeenCalledTimes(1); + expect(mockDispatchQueuedImpactAdvocateRewardRedemptions).toHaveBeenCalledTimes(1); await expect(response.json()).resolves.toEqual( expect.objectContaining({ success: true, @@ -128,6 +141,12 @@ describe('GET /api/cron/dispatch-affiliate-events', () => { pending: 0, failed: 0, }, + impactAdvocateRewardRedemptions: { + claimed: 2, + redeemed: 2, + retried: 0, + failed: 0, + }, }, timestamp: expect.any(String), }) diff --git a/apps/web/src/app/api/cron/dispatch-affiliate-events/route.ts b/apps/web/src/app/api/cron/dispatch-affiliate-events/route.ts index 487305edec..723cca30ee 100644 --- a/apps/web/src/app/api/cron/dispatch-affiliate-events/route.ts +++ b/apps/web/src/app/api/cron/dispatch-affiliate-events/route.ts @@ -4,6 +4,7 @@ import { CRON_SECRET } from '@/lib/config.server'; import { dispatchQueuedAffiliateEvents } from '@/lib/affiliate-events'; import { dispatchQueuedImpactAdvocateRegistrationAttempts } from '@/lib/impact-referral'; import { + dispatchQueuedImpactAdvocateRewardRedemptions, dispatchQueuedImpactConversionReports, processQueuedKiloClawReferralRewards, } from '@/lib/kiloclaw-referrals'; @@ -32,11 +33,13 @@ export async function GET(request: Request) { impactAdvocateRegistrationSummary, impactConversionSummary, referralRewardSummary, + impactAdvocateRewardRedemptionSummary, ] = await Promise.all([ dispatchQueuedAffiliateEvents(), dispatchQueuedImpactAdvocateRegistrationAttempts(), dispatchQueuedImpactConversionReports(), processQueuedKiloClawReferralRewards(), + dispatchQueuedImpactAdvocateRewardRedemptions(), ]); return NextResponse.json( @@ -47,6 +50,7 @@ export async function GET(request: Request) { impactAdvocateRegistrations: impactAdvocateRegistrationSummary, impactConversionReports: impactConversionSummary, referralRewards: referralRewardSummary, + impactAdvocateRewardRedemptions: impactAdvocateRewardRedemptionSummary, }, timestamp: new Date().toISOString(), }, diff --git a/apps/web/src/lib/impact-advocate.test.ts b/apps/web/src/lib/impact-advocate.test.ts index 2b602ff65c..851e1f08aa 100644 --- a/apps/web/src/lib/impact-advocate.test.ts +++ b/apps/web/src/lib/impact-advocate.test.ts @@ -131,6 +131,77 @@ describe('impact advocate', () => { }); }); + it('looks up account rewards with account and user filters', async () => { + process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; + const fetchMock = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ rewards: [{ id: 'reward-123', type: 'CREDIT' }] }), { + status: 200, + }) + ); + global.fetch = fetchMock; + + const { sendImpactAdvocateRewardLookupPayload } = await import('@/lib/impact-advocate'); + const result = await sendImpactAdvocateRewardLookupPayload({ + accountId: 'user@example.com', + userId: 'user@example.com', + rewardTypeFilter: 'CREDIT', + }); + + expect(result).toEqual({ + ok: true, + statusCode: 200, + responseBody: '{"rewards":[{"id":"reward-123","type":"CREDIT"}]}', + rewards: [{ id: 'reward-123', type: 'CREDIT' }], + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'https://app.referralsaasquatch.com/api/v1/tenant-alias/reward?accountId=user%40example.com&userId=user%40example.com&rewardTypeFilter=CREDIT' + ); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Basic ' + Buffer.from('impact-account-sid:secret').toString('base64'), + Accept: 'application/json', + }), + }); + }); + + it('redeems a credit reward with amount and unit', async () => { + process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; + process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; + process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; + process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; + const fetchMock = jest + .fn() + .mockResolvedValue(new Response('{"ok":true}', { status: 200 })); + global.fetch = fetchMock; + + const { sendImpactAdvocateRewardRedemptionPayload } = await import('@/lib/impact-advocate'); + const result = await sendImpactAdvocateRewardRedemptionPayload({ + rewardId: 'reward-123', + amount: 1, + unit: 'free-months', + }); + + expect(result).toEqual({ ok: true, statusCode: 200, responseBody: '{"ok":true}' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'https://app.referralsaasquatch.com/api/v1/tenant-alias/credit/reward-123/redeem' + ); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Basic ' + Buffer.from('impact-account-sid:secret').toString('base64'), + Accept: 'application/json', + 'Content-Type': 'application/json', + }), + body: '{"amount":1,"unit":"free-months"}', + }); + }); + it('strips legacy programId and normalises locale at send time', async () => { const { sanitizeRegisterParticipantPayloadForWire } = await import('@/lib/impact-advocate'); diff --git a/apps/web/src/lib/impact-advocate.ts b/apps/web/src/lib/impact-advocate.ts index a55652b86f..c0d2b4ee81 100644 --- a/apps/web/src/lib/impact-advocate.ts +++ b/apps/web/src/lib/impact-advocate.ts @@ -121,6 +121,22 @@ export type ImpactAdvocateDispatchResult = error?: string; }; +export type ImpactAdvocateRewardLookupPayload = { + accountId: string; + userId?: string; + rewardTypeFilter?: 'CREDIT'; +}; + +export type ImpactAdvocateRewardRedemptionPayload = { + rewardId: string; + amount: number; + unit: string; +}; + +export type ImpactAdvocateRewardListResult = ImpactAdvocateDispatchResult & { + rewards?: unknown[]; +}; + function getDebuggableRegisterParticipantPayload( payload: ImpactAdvocateRegisterParticipantPayload ) { @@ -251,6 +267,51 @@ function trimTrailingSlashes(value: string): string { return value.replace(/\/+$/, ''); } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function getCaseInsensitiveProperty(record: Record, key: string): unknown { + if (Object.prototype.hasOwnProperty.call(record, key)) { + return record[key]; + } + + const lowerKey = key.toLowerCase(); + const matchedKey = Object.keys(record).find(candidate => candidate.toLowerCase() === lowerKey); + return matchedKey ? record[matchedKey] : undefined; +} + +export function extractImpactAdvocateRewards(responseBody: string | null | undefined): unknown[] { + if (!responseBody) return []; + + let parsed: unknown; + try { + parsed = JSON.parse(responseBody); + } catch { + return []; + } + + if (Array.isArray(parsed)) return parsed; + if (!isRecord(parsed)) return []; + + const candidateKeys = [ + 'rewards', + 'Rewards', + 'data', + 'Data', + 'items', + 'Items', + 'results', + 'Results', + ]; + for (const key of candidateKeys) { + const candidate = getCaseInsensitiveProperty(parsed, key); + if (Array.isArray(candidate)) return candidate; + } + + return []; +} + /** * SaaSquatch (Impact Advocate) Upsert User REST endpoint. * @@ -270,6 +331,28 @@ function getImpactAdvocateRegisterParticipantUrl( return `${base}/api/v1/${tenant}/open/account/${accountId}/user/${userId}`; } +function getImpactAdvocateRewardsUrl( + config: NonNullable>, + payload: ImpactAdvocateRewardLookupPayload +): string { + const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); + const tenant = encodeURIComponent(config.tenantAlias); + const url = new URL(`${base}/api/v1/${tenant}/reward`); + url.searchParams.set('accountId', payload.accountId); + if (payload.userId) url.searchParams.set('userId', payload.userId); + if (payload.rewardTypeFilter) url.searchParams.set('rewardTypeFilter', payload.rewardTypeFilter); + return url.toString(); +} + +function getImpactAdvocateRedeemRewardUrl( + config: NonNullable>, + rewardId: string +): string { + const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); + const tenant = encodeURIComponent(config.tenantAlias); + return `${base}/api/v1/${tenant}/credit/${encodeURIComponent(rewardId)}/redeem`; +} + export async function sendImpactAdvocateRegisterParticipantPayload( payload: ImpactAdvocateRegisterParticipantPayload ): Promise { @@ -344,6 +427,141 @@ export async function sendImpactAdvocateRegisterParticipantPayload( } } +export async function sendImpactAdvocateRewardLookupPayload( + payload: ImpactAdvocateRewardLookupPayload +): Promise { + const config = getImpactAdvocateConfig(); + if (!config) { + return { + ok: false, + failureKind: 'http_4xx', + error: 'Impact Advocate is unconfigured', + }; + } + + try { + const url = getImpactAdvocateRewardsUrl(config, payload); + logImpactAdvocateDebug('[impact-advocate] sending reward lookup request', { + url, + method: 'GET', + accountIdPresent: Boolean(payload.accountId.trim()), + userIdPresent: Boolean(payload.userId?.trim()), + rewardTypeFilter: payload.rewardTypeFilter ?? null, + }); + + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: getImpactAdvocateAuthorizationHeader(config), + Accept: 'application/json', + }, + }); + const responseBody = await response.text(); + + logImpactAdvocateDebug('[impact-advocate] reward lookup response', { + url, + ok: response.ok, + statusCode: response.status, + responseBody: truncateForLog(responseBody), + }); + + if (response.ok) { + return { + ok: true, + statusCode: response.status, + responseBody, + rewards: extractImpactAdvocateRewards(responseBody), + }; + } + + return { + ok: false, + failureKind: response.status >= 500 ? 'http_5xx' : 'http_4xx', + statusCode: response.status, + responseBody, + }; + } catch (error) { + logImpactAdvocateDebug('[impact-advocate] reward lookup network error', { + error: error instanceof Error ? error.message : String(error), + }); + return { + ok: false, + failureKind: 'network', + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export async function sendImpactAdvocateRewardRedemptionPayload( + payload: ImpactAdvocateRewardRedemptionPayload +): Promise { + const config = getImpactAdvocateConfig(); + if (!config) { + return { + ok: false, + failureKind: 'http_4xx', + error: 'Impact Advocate is unconfigured', + }; + } + + try { + const url = getImpactAdvocateRedeemRewardUrl(config, payload.rewardId); + const body = { + amount: payload.amount, + unit: payload.unit, + }; + logImpactAdvocateDebug('[impact-advocate] sending reward redemption request', { + url, + method: 'POST', + rewardIdPresent: Boolean(payload.rewardId.trim()), + amount: payload.amount, + unit: payload.unit, + }); + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: getImpactAdvocateAuthorizationHeader(config), + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + const responseBody = await response.text(); + + logImpactAdvocateDebug('[impact-advocate] reward redemption response', { + url, + ok: response.ok, + statusCode: response.status, + responseBody: truncateForLog(responseBody), + }); + + if (response.ok) { + return { + ok: true, + statusCode: response.status, + responseBody, + }; + } + + return { + ok: false, + failureKind: response.status >= 500 ? 'http_5xx' : 'http_4xx', + statusCode: response.status, + responseBody, + }; + } catch (error) { + logImpactAdvocateDebug('[impact-advocate] reward redemption network error', { + error: error instanceof Error ? error.message : String(error), + }); + return { + ok: false, + failureKind: 'network', + error: error instanceof Error ? error.message : String(error), + }; + } +} + export function issueImpactAdvocateVerifiedAccessToken( user: Pick, now: Date = new Date() diff --git a/apps/web/src/lib/kiloclaw-referrals.test.ts b/apps/web/src/lib/kiloclaw-referrals.test.ts index b1001a792f..f268f9b070 100644 --- a/apps/web/src/lib/kiloclaw-referrals.test.ts +++ b/apps/web/src/lib/kiloclaw-referrals.test.ts @@ -16,6 +16,17 @@ jest.mock('@/lib/impact-advocate', () => { return { ...actual, isImpactAdvocateConfigured: jest.fn(() => true), + sendImpactAdvocateRewardLookupPayload: jest.fn(async () => ({ + ok: true, + statusCode: 200, + responseBody: JSON.stringify({ rewards: [{ id: 'impact-reward-123', type: 'CREDIT' }] }), + rewards: [{ id: 'impact-reward-123', type: 'CREDIT' }], + })), + sendImpactAdvocateRewardRedemptionPayload: jest.fn(async () => ({ + ok: true, + statusCode: 200, + responseBody: '{}', + })), }; }); @@ -31,6 +42,7 @@ import { db } from '@/lib/drizzle'; import { credit_transactions, impact_advocate_participants, + impact_advocate_reward_redemptions, impact_conversion_reports, kiloclaw_attribution_touches, kiloclaw_instances, @@ -47,18 +59,29 @@ import { } from '@kilocode/db/schema'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { + dispatchQueuedImpactAdvocateRewardRedemptions, markPersonalKiloClawReferralPaymentAdverse, processPersonalKiloClawPaidConversion, processQueuedKiloClawReferralRewards, resolveWinningAttributionTouch, } from '@/lib/kiloclaw-referrals'; import { isImpactConfigured, reverseImpactAction, sendImpactConversionPayload } from '@/lib/impact'; -import { isImpactAdvocateConfigured } from '@/lib/impact-advocate'; +import { + isImpactAdvocateConfigured, + sendImpactAdvocateRewardLookupPayload, + sendImpactAdvocateRewardRedemptionPayload, +} from '@/lib/impact-advocate'; import { client as stripeClient } from '@/lib/stripe-client'; const mockIsImpactConfigured = jest.mocked(isImpactConfigured); const mockIsImpactAdvocateConfigured = jest.mocked(isImpactAdvocateConfigured); const mockSendImpactConversionPayload = jest.mocked(sendImpactConversionPayload); +const mockSendImpactAdvocateRewardLookupPayload = jest.mocked( + sendImpactAdvocateRewardLookupPayload +); +const mockSendImpactAdvocateRewardRedemptionPayload = jest.mocked( + sendImpactAdvocateRewardRedemptionPayload +); const mockReverseImpactAction = jest.mocked(reverseImpactAction); const mockStripeSubscriptionUpdate = jest.mocked(stripeClient.subscriptions.update); @@ -146,12 +169,71 @@ async function insertImpactAdvocateParticipant(userId: string, opaqueReferralIde return identifier; } +async function insertAppliedReferralRewardForUser(userId: string): Promise { + const [conversion] = await db + .insert(kiloclaw_referral_conversions) + .values({ + referee_user_id: userId, + referrer_user_id: null, + winning_touch_type: 'none', + source_payment_id: `reward-redemption-test:${randomUUID()}`, + qualified: true, + converted_at: '2026-04-10T00:00:00.000Z', + }) + .returning({ id: kiloclaw_referral_conversions.id }); + + if (!conversion) throw new Error('Failed to insert referral conversion'); + + const [decision] = await db + .insert(kiloclaw_referral_reward_decisions) + .values({ + conversion_id: conversion.id, + beneficiary_user_id: userId, + beneficiary_role: 'referee', + outcome: 'granted', + months_granted: 1, + }) + .returning({ id: kiloclaw_referral_reward_decisions.id }); + + if (!decision) throw new Error('Failed to insert referral reward decision'); + + const [reward] = await db + .insert(kiloclaw_referral_rewards) + .values({ + conversion_id: conversion.id, + decision_id: decision.id, + beneficiary_user_id: userId, + beneficiary_role: 'referee', + months_granted: 1, + status: 'applied', + earned_at: '2026-04-10T00:00:00.000Z', + applied_at: '2026-04-10T00:05:00.000Z', + }) + .returning({ id: kiloclaw_referral_rewards.id }); + + if (!reward) throw new Error('Failed to insert referral reward'); + + return reward.id; +} + describe('kiloclaw referrals', () => { afterEach(async () => { jest.clearAllMocks(); mockIsImpactConfigured.mockReturnValue(true); mockIsImpactAdvocateConfigured.mockReturnValue(true); + mockSendImpactAdvocateRewardLookupPayload.mockResolvedValue({ + ok: true, + statusCode: 200, + responseBody: JSON.stringify({ rewards: [{ id: 'impact-reward-123', type: 'CREDIT' }] }), + rewards: [{ id: 'impact-reward-123', type: 'CREDIT' }], + }); + mockSendImpactAdvocateRewardRedemptionPayload.mockResolvedValue({ + ok: true, + statusCode: 200, + responseBody: '{}', + }); await db.delete(impact_conversion_reports).where(sql`true`); + await db.delete(impact_advocate_reward_redemptions).where(sql`true`); await db.delete(kiloclaw_referral_reward_applications).where(sql`true`); await db.delete(kiloclaw_referral_rewards).where(sql`true`); await db.delete(kiloclaw_referral_reward_decisions).where(sql`true`); @@ -167,6 +249,90 @@ describe('kiloclaw referrals', () => { await db.delete(kilocode_users).where(sql`true`); }); + describe('dispatchQueuedImpactAdvocateRewardRedemptions', () => { + it('does not treat already-redeemed Impact responses as success before this row has selected the reward', async () => { + const user = await insertTestUser({ + google_user_email: 'first-already-redeemed@example.com', + normalized_email: 'first-already-redeemed@example.com', + }); + const rewardId = await insertAppliedReferralRewardForUser(user.id); + await db.insert(impact_advocate_reward_redemptions).values({ + reward_id: rewardId, + dedupe_key: `first-already-redeemed:${rewardId}`, + beneficiary_user_id: user.id, + state: 'queued', + request_payload: { + lookup: { + accountId: user.google_user_email, + userId: user.google_user_email, + rewardTypeFilter: 'CREDIT', + }, + redemption: { amount: 1, unit: 'free-months' }, + }, + }); + mockSendImpactAdvocateRewardRedemptionPayload.mockResolvedValueOnce({ + ok: false, + failureKind: 'http_4xx', + statusCode: 400, + responseBody: 'Reward already redeemed', + }); + + const summary = await dispatchQueuedImpactAdvocateRewardRedemptions(); + + expect(summary).toEqual({ claimed: 1, redeemed: 0, retried: 0, failed: 1 }); + const [redemption] = await db.select().from(impact_advocate_reward_redemptions); + expect(redemption).toEqual( + expect.objectContaining({ + state: 'failed', + impact_reward_id: 'impact-reward-123', + response_status_code: 400, + }) + ); + }); + + it('treats already-redeemed Impact responses as idempotent success for a previously selected reward', async () => { + const user = await insertTestUser({ + google_user_email: 'retry-already-redeemed@example.com', + normalized_email: 'retry-already-redeemed@example.com', + }); + const rewardId = await insertAppliedReferralRewardForUser(user.id); + await db.insert(impact_advocate_reward_redemptions).values({ + reward_id: rewardId, + dedupe_key: `retry-already-redeemed:${rewardId}`, + beneficiary_user_id: user.id, + state: 'queued', + impact_reward_id: 'impact-reward-123', + request_payload: { + lookup: { + accountId: user.google_user_email, + userId: user.google_user_email, + rewardTypeFilter: 'CREDIT', + }, + redemption: { amount: 1, unit: 'free-months' }, + }, + }); + mockSendImpactAdvocateRewardRedemptionPayload.mockResolvedValueOnce({ + ok: false, + failureKind: 'http_4xx', + statusCode: 400, + responseBody: 'Reward already redeemed', + }); + + const summary = await dispatchQueuedImpactAdvocateRewardRedemptions(); + + expect(summary).toEqual({ claimed: 1, redeemed: 1, retried: 0, failed: 0 }); + const [redemption] = await db.select().from(impact_advocate_reward_redemptions); + expect(redemption).toEqual( + expect.objectContaining({ + state: 'redeemed', + impact_reward_id: 'impact-reward-123', + response_status_code: 400, + redeem_response_payload: expect.objectContaining({ alreadyRedeemed: true }), + }) + ); + }); + }); + describe('resolveWinningAttributionTouch', () => { const convertedAt = new Date('2026-04-10T00:00:00.000Z'); @@ -1153,6 +1319,51 @@ describe('kiloclaw referrals', () => { .from(kiloclaw_referral_rewards) .where(eq(kiloclaw_referral_rewards.beneficiary_user_id, referrer.id)); expect(referrerReward.status).toBe('applied'); + + const queuedRedemptions = await db.select().from(impact_advocate_reward_redemptions); + expect(queuedRedemptions).toHaveLength(2); + expect(queuedRedemptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ beneficiary_user_id: referee.id, state: 'queued' }), + expect.objectContaining({ beneficiary_user_id: referrer.id, state: 'queued' }), + ]) + ); + + const redemptionSummary = await dispatchQueuedImpactAdvocateRewardRedemptions(); + expect(redemptionSummary).toEqual({ claimed: 2, redeemed: 2, retried: 0, failed: 0 }); + expect(mockSendImpactAdvocateRewardLookupPayload).toHaveBeenCalledTimes(2); + expect(mockSendImpactAdvocateRewardLookupPayload).toHaveBeenCalledWith({ + accountId: 'pending-referee@example.com', + userId: 'pending-referee@example.com', + rewardTypeFilter: 'CREDIT', + }); + expect(mockSendImpactAdvocateRewardLookupPayload).toHaveBeenCalledWith({ + accountId: 'pending-referrer@example.com', + userId: 'pending-referrer@example.com', + rewardTypeFilter: 'CREDIT', + }); + expect(mockSendImpactAdvocateRewardRedemptionPayload).toHaveBeenCalledTimes(2); + expect(mockSendImpactAdvocateRewardRedemptionPayload).toHaveBeenCalledWith({ + rewardId: 'impact-reward-123', + amount: 1, + unit: 'free-months', + }); + + const redeemedRedemptions = await db.select().from(impact_advocate_reward_redemptions); + expect(redeemedRedemptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + beneficiary_user_id: referee.id, + state: 'redeemed', + impact_reward_id: 'impact-reward-123', + }), + expect.objectContaining({ + beneficiary_user_id: referrer.id, + state: 'redeemed', + impact_reward_id: 'impact-reward-123', + }), + ]) + ); }); it('leaves local reward state unchanged when Stripe reward application fails', async () => { diff --git a/apps/web/src/lib/kiloclaw-referrals.ts b/apps/web/src/lib/kiloclaw-referrals.ts index bb2730cc58..6a2821246a 100644 --- a/apps/web/src/lib/kiloclaw-referrals.ts +++ b/apps/web/src/lib/kiloclaw-referrals.ts @@ -14,7 +14,12 @@ import { type ImpactConversionPayload, type ImpactDispatchResult, } from '@/lib/impact'; -import { isImpactAdvocateConfigured } from '@/lib/impact-advocate'; +import { + isImpactAdvocateConfigured, + sendImpactAdvocateRewardLookupPayload, + sendImpactAdvocateRewardRedemptionPayload, + type ImpactAdvocateDispatchResult, +} from '@/lib/impact-advocate'; import { logImpactReferralDebug } from '@/lib/impact-debug'; import { hashNormalizedEmailForDeletionTombstone } from '@/lib/impact-referral'; import { resolveCurrentPersonalSubscriptionRow } from '@/lib/kiloclaw/current-personal-subscription'; @@ -24,6 +29,7 @@ import { credit_transactions, deleted_user_email_tombstones, impact_advocate_participants, + impact_advocate_reward_redemptions, impact_conversion_reports, kiloclaw_attribution_touches, kiloclaw_referral_conversions, @@ -39,6 +45,7 @@ import { type KiloClawSubscription, } from '@kilocode/db/schema'; import { + ImpactAdvocateRewardRedemptionState, ImpactConversionReportState, KiloClawAttributionTouchType, KiloClawReferralBeneficiaryRole, @@ -88,6 +95,13 @@ export type ReferralRewardProcessingSummary = { failed: number; }; +export type ImpactAdvocateRewardRedemptionDispatchSummary = { + claimed: number; + redeemed: number; + retried: number; + failed: number; +}; + export type AdverseReferralPaymentReason = 'chargeback' | 'refund' | 'fraud'; export type PaidConversionQualificationContext = { @@ -108,6 +122,7 @@ const REFERRAL_REWARD_ACTOR = { } as const; const SIGNUP_REFERRAL_TOUCH_CAPTURE_GRACE_MS = 10 * 60 * 1000; +const IMPACT_ADVOCATE_REWARD_UNIT = 'free-months'; function getDatabaseClient(database?: DatabaseClient): DatabaseClient { return database ?? db; @@ -484,6 +499,118 @@ function getObjectProperty(record: unknown, key: string): unknown { return Reflect.get(record, key); } +function getCaseInsensitiveObjectProperty(record: unknown, key: string): unknown { + if (typeof record !== 'object' || record === null) { + return undefined; + } + + const keys = Object.keys(record); + const matchedKey = keys.find(candidate => candidate.toLowerCase() === key.toLowerCase()); + return matchedKey ? Reflect.get(record, matchedKey) : undefined; +} + +function getStringProperty(record: unknown, keys: string[]): string | null { + for (const key of keys) { + const value = getCaseInsensitiveObjectProperty(record, key); + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return null; +} + +function getNumberProperty(record: unknown, keys: string[]): number | null { + for (const key of keys) { + const value = getCaseInsensitiveObjectProperty(record, key); + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + } + return null; +} + +function getNestedObjectProperty(record: unknown, key: string): unknown { + return getCaseInsensitiveObjectProperty(record, key); +} + +function rewardHasUnit(reward: unknown, unit: string): boolean { + const unitValue = + getStringProperty(reward, ['unit', 'Unit', 'currency']) ?? + getStringProperty(getNestedObjectProperty(reward, 'credit'), ['unit', 'Unit']) ?? + getStringProperty(getNestedObjectProperty(reward, 'value'), ['unit', 'Unit']); + return !unitValue || unitValue.toLowerCase() === unit.toLowerCase(); +} + +function rewardHasAmount(reward: unknown, amount: number): boolean { + const amountValue = + getNumberProperty(reward, ['amount', 'Amount', 'remainingAmount', 'RemainingAmount']) ?? + getNumberProperty(getNestedObjectProperty(reward, 'credit'), ['amount', 'Amount']) ?? + getNumberProperty(getNestedObjectProperty(reward, 'value'), ['amount', 'Amount']); + return amountValue === null || amountValue >= amount; +} + +function rewardIsCredit(reward: unknown): boolean { + const type = getStringProperty(reward, ['type', 'Type', 'rewardType', 'RewardType']); + return !type || type.toUpperCase() === 'CREDIT'; +} + +function rewardIsRedeemable(reward: unknown): boolean { + const status = getStringProperty(reward, ['status', 'Status', 'state', 'State']); + if (status) { + const normalizedStatus = status.toUpperCase().replaceAll(' ', '_'); + if ( + normalizedStatus === 'REDEEMED' || + normalizedStatus === 'CANCELLED' || + normalizedStatus === 'CANCELED' + ) { + return false; + } + } + + const redeemed = getCaseInsensitiveObjectProperty(reward, 'redeemed'); + if (redeemed === true) return false; + + const terminalTimestamps = [ + 'redeemedAt', + 'dateRedeemed', + 'cancelledAt', + 'canceledAt', + 'dateCancelled', + 'dateCanceled', + ]; + return !terminalTimestamps.some(key => Boolean(getCaseInsensitiveObjectProperty(reward, key))); +} + +function getImpactAdvocateRewardId(reward: unknown): string | null { + return getStringProperty(reward, ['id', 'Id', 'ID', 'rewardId', 'RewardId']); +} + +function selectImpactAdvocateRewardId(params: { + rewards: unknown[]; + amount: number; + unit: string; +}): string | null { + for (const reward of params.rewards) { + const rewardId = getImpactAdvocateRewardId(reward); + if ( + rewardId && + rewardIsCredit(reward) && + rewardHasUnit(reward, params.unit) && + rewardHasAmount(reward, params.amount) && + rewardIsRedeemable(reward) + ) { + return rewardId; + } + } + + return null; +} + +function isAlreadyRedeemedResponse(responseBody: string | null | undefined): boolean { + const normalized = responseBody?.toLowerCase() ?? ''; + return normalized.includes('already') && normalized.includes('redeem'); +} + function getImpactActionIdFromResponsePayload(payload: unknown): string | null { const value = getObjectProperty(payload, 'actionId'); return typeof value === 'string' && value.trim() ? value : null; @@ -755,6 +882,8 @@ async function applyReferralRewardById( }); } + await queueImpactAdvocateRewardRedemption({ rewardId: reward.id, database: tx }); + return 'applied'; }); @@ -824,6 +953,318 @@ export async function processQueuedKiloClawReferralRewards(params?: { return summary; } +async function queueImpactAdvocateRewardRedemption(params: { + rewardId: string; + database: DatabaseClient; +}): Promise { + const [reward] = await params.database + .select({ + id: kiloclaw_referral_rewards.id, + beneficiaryUserId: kiloclaw_referral_rewards.beneficiary_user_id, + monthsGranted: kiloclaw_referral_rewards.months_granted, + status: kiloclaw_referral_rewards.status, + email: kilocode_users.google_user_email, + }) + .from(kiloclaw_referral_rewards) + .innerJoin(kilocode_users, eq(kilocode_users.id, kiloclaw_referral_rewards.beneficiary_user_id)) + .where(eq(kiloclaw_referral_rewards.id, params.rewardId)) + .limit(1); + + if (!reward || reward.status !== KiloClawReferralRewardStatus.Applied) { + return; + } + + const accountId = reward.email.trim(); + if (!accountId) { + console.error('[kiloclaw-referrals] missing beneficiary email for Impact reward redemption', { + rewardId: params.rewardId, + beneficiaryUserId: reward.beneficiaryUserId, + }); + return; + } + + await params.database + .insert(impact_advocate_reward_redemptions) + .values({ + reward_id: reward.id, + dedupe_key: `impact-advocate-reward-redemption:${reward.id}`, + beneficiary_user_id: reward.beneficiaryUserId, + state: ImpactAdvocateRewardRedemptionState.Queued, + request_payload: { + lookup: { + accountId, + userId: accountId, + rewardTypeFilter: 'CREDIT', + }, + redemption: { + amount: reward.monthsGranted, + unit: IMPACT_ADVOCATE_REWARD_UNIT, + }, + } satisfies Record, + }) + .onConflictDoNothing({ target: [impact_advocate_reward_redemptions.reward_id] }); +} + +type ImpactAdvocateRewardRedemptionRequestPayload = { + lookup: { + accountId: string; + userId: string; + rewardTypeFilter: 'CREDIT'; + }; + redemption: { + amount: number; + unit: string; + }; +}; + +function isRewardRedemptionRequestPayload( + payload: unknown +): payload is ImpactAdvocateRewardRedemptionRequestPayload { + const lookup = getObjectProperty(payload, 'lookup'); + const redemption = getObjectProperty(payload, 'redemption'); + return ( + typeof lookup === 'object' && + lookup !== null && + typeof redemption === 'object' && + redemption !== null && + typeof getObjectProperty(lookup, 'accountId') === 'string' && + typeof getObjectProperty(lookup, 'userId') === 'string' && + getObjectProperty(lookup, 'rewardTypeFilter') === 'CREDIT' && + typeof getObjectProperty(redemption, 'amount') === 'number' && + typeof getObjectProperty(redemption, 'unit') === 'string' + ); +} + +function buildFailurePayload(result: ImpactAdvocateDispatchResult): Record { + return { + failureKind: result.ok ? null : result.failureKind, + responseBody: result.responseBody ?? null, + error: result.ok ? null : (result.error ?? null), + }; +} + +async function persistRewardRedemptionFailure(params: { + redemptionId: string; + attemptCount: number; + result: ImpactAdvocateDispatchResult; + stage: 'lookup' | 'redeem'; + terminal?: boolean; +}): Promise<'retried' | 'failed'> { + const terminal = + params.terminal ?? (!params.result.ok && params.result.failureKind === 'http_4xx'); + const responsePayload = buildFailurePayload(params.result); + await db + .update(impact_advocate_reward_redemptions) + .set({ + state: terminal + ? ImpactAdvocateRewardRedemptionState.Failed + : ImpactAdvocateRewardRedemptionState.Retrying, + attempt_count: params.attemptCount, + next_retry_at: terminal ? null : nextReportRetryAt(params.attemptCount), + response_status_code: params.result.ok ? null : (params.result.statusCode ?? null), + ...(params.stage === 'lookup' + ? { lookup_response_payload: responsePayload } + : { redeem_response_payload: responsePayload }), + }) + .where(eq(impact_advocate_reward_redemptions.id, params.redemptionId)); + + if (terminal) { + console.error('[kiloclaw-referrals] Impact Advocate reward redemption failed permanently', { + redemptionId: params.redemptionId, + stage: params.stage, + statusCode: params.result.ok ? null : (params.result.statusCode ?? null), + failureKind: params.result.ok ? null : params.result.failureKind, + }); + return 'failed'; + } + + return 'retried'; +} + +async function dispatchImpactAdvocateRewardRedemptionById( + redemptionId: string +): Promise<'redeemed' | 'retried' | 'failed'> { + const redemption = await db.query.impact_advocate_reward_redemptions.findFirst({ + where: eq(impact_advocate_reward_redemptions.id, redemptionId), + }); + if (!redemption) return 'failed'; + if (redemption.state === ImpactAdvocateRewardRedemptionState.Redeemed) return 'redeemed'; + if (redemption.state === ImpactAdvocateRewardRedemptionState.Failed) return 'failed'; + + const attemptCount = redemption.attempt_count + 1; + if (!isRewardRedemptionRequestPayload(redemption.request_payload)) { + await db + .update(impact_advocate_reward_redemptions) + .set({ + state: ImpactAdvocateRewardRedemptionState.Failed, + attempt_count: attemptCount, + redeem_response_payload: { error: 'missing_request_payload' } satisfies Record< + string, + unknown + >, + }) + .where(eq(impact_advocate_reward_redemptions.id, redemption.id)); + return 'failed'; + } + + const lookupResult = await sendImpactAdvocateRewardLookupPayload( + redemption.request_payload.lookup + ); + if (!lookupResult.ok) { + return await persistRewardRedemptionFailure({ + redemptionId: redemption.id, + attemptCount, + result: lookupResult, + stage: 'lookup', + }); + } + + const persistedImpactRewardId = redemption.impact_reward_id?.trim() || null; + const impactRewardId = + persistedImpactRewardId ?? + selectImpactAdvocateRewardId({ + rewards: lookupResult.rewards ?? [], + amount: redemption.request_payload.redemption.amount, + unit: redemption.request_payload.redemption.unit, + }); + if (!impactRewardId) { + await db + .update(impact_advocate_reward_redemptions) + .set({ + state: ImpactAdvocateRewardRedemptionState.Retrying, + attempt_count: attemptCount, + next_retry_at: nextReportRetryAt(attemptCount), + response_status_code: lookupResult.statusCode ?? null, + lookup_response_payload: { + error: 'impact_reward_not_found', + responseBody: lookupResult.responseBody ?? null, + } satisfies Record, + }) + .where(eq(impact_advocate_reward_redemptions.id, redemption.id)); + return 'retried'; + } + + if (!persistedImpactRewardId) { + await db + .update(impact_advocate_reward_redemptions) + .set({ + impact_reward_id: impactRewardId, + lookup_response_payload: { + selectedRewardId: impactRewardId, + responseBody: lookupResult.responseBody ?? null, + } satisfies Record, + }) + .where(eq(impact_advocate_reward_redemptions.id, redemption.id)); + } + + const redeemResult = await sendImpactAdvocateRewardRedemptionPayload({ + rewardId: impactRewardId, + ...redemption.request_payload.redemption, + }); + const isIdempotentAlreadyRedeemed = + !redeemResult.ok && + persistedImpactRewardId === impactRewardId && + isAlreadyRedeemedResponse(redeemResult.responseBody); + if (!redeemResult.ok && !isIdempotentAlreadyRedeemed) { + return await persistRewardRedemptionFailure({ + redemptionId: redemption.id, + attemptCount, + result: redeemResult, + stage: 'redeem', + }); + } + + await db + .update(impact_advocate_reward_redemptions) + .set({ + state: ImpactAdvocateRewardRedemptionState.Redeemed, + impact_reward_id: impactRewardId, + attempt_count: attemptCount, + next_retry_at: null, + redeemed_at: new Date().toISOString(), + response_status_code: redeemResult.statusCode ?? null, + lookup_response_payload: { + selectedRewardId: impactRewardId, + responseBody: lookupResult.responseBody ?? null, + } satisfies Record, + redeem_response_payload: redeemResult.ok + ? ({ responseBody: redeemResult.responseBody ?? null } satisfies Record) + : ({ + alreadyRedeemed: true, + responseBody: redeemResult.responseBody ?? null, + } satisfies Record), + }) + .where(eq(impact_advocate_reward_redemptions.id, redemption.id)); + + return 'redeemed'; +} + +export async function dispatchQueuedImpactAdvocateRewardRedemptions(params?: { + limit?: number; +}): Promise { + const limit = params?.limit ?? 100; + const nowIso = new Date().toISOString(); + const rows = await db + .update(impact_advocate_reward_redemptions) + .set({ + state: ImpactAdvocateRewardRedemptionState.Retrying, + next_retry_at: nextReportClaimExpiresAt(), + }) + .where( + and( + or( + eq(impact_advocate_reward_redemptions.state, ImpactAdvocateRewardRedemptionState.Queued), + eq(impact_advocate_reward_redemptions.state, ImpactAdvocateRewardRedemptionState.Retrying) + ), + or( + sql`${impact_advocate_reward_redemptions.next_retry_at} IS NULL`, + lte(impact_advocate_reward_redemptions.next_retry_at, nowIso) + ), + sql`${impact_advocate_reward_redemptions.id} IN ( + SELECT ${impact_advocate_reward_redemptions.id} + FROM ${impact_advocate_reward_redemptions} + WHERE ${or( + eq( + impact_advocate_reward_redemptions.state, + ImpactAdvocateRewardRedemptionState.Queued + ), + eq( + impact_advocate_reward_redemptions.state, + ImpactAdvocateRewardRedemptionState.Retrying + ) + )} + AND ${or( + sql`${impact_advocate_reward_redemptions.next_retry_at} IS NULL`, + lte(impact_advocate_reward_redemptions.next_retry_at, nowIso) + )} + ORDER BY ${impact_advocate_reward_redemptions.created_at}, ${impact_advocate_reward_redemptions.id} + LIMIT ${limit} + )` + ) + ) + .returning({ id: impact_advocate_reward_redemptions.id }); + + const summary: ImpactAdvocateRewardRedemptionDispatchSummary = { + claimed: rows.length, + redeemed: 0, + retried: 0, + failed: 0, + }; + + for (const row of rows) { + const outcome = await dispatchImpactAdvocateRewardRedemptionById(row.id); + if (outcome === 'redeemed') { + summary.redeemed++; + } else if (outcome === 'retried') { + summary.retried++; + } else { + summary.failed++; + } + } + + return summary; +} + async function persistImpactReportReversal(params: { reportId: string; reason: AdverseReferralPaymentReason; diff --git a/apps/web/src/lib/user.test.ts b/apps/web/src/lib/user.test.ts index 95f6fad0b5..2167c5e1b1 100644 --- a/apps/web/src/lib/user.test.ts +++ b/apps/web/src/lib/user.test.ts @@ -61,6 +61,7 @@ import { kiloclaw_referral_reward_decisions, kiloclaw_referral_rewards, kiloclaw_referral_reward_applications, + impact_advocate_reward_redemptions, impact_conversion_reports, } from '@kilocode/db/schema'; import { eq, count } from 'drizzle-orm'; @@ -101,6 +102,7 @@ describe('User', () => { await db.delete(impact_advocate_registration_attempts); await db.delete(impact_advocate_participants); await db.delete(impact_conversion_reports); + await db.delete(impact_advocate_reward_redemptions); await db.delete(kiloclaw_referral_reward_applications); await db.delete(kiloclaw_referral_rewards); await db.delete(kiloclaw_referral_reward_decisions); @@ -486,6 +488,20 @@ describe('User', () => { new_renewal_boundary: '2026-06-01T00:00:00.000Z', applied_at: '2026-04-23T00:00:00.000Z', }); + await db.insert(impact_advocate_reward_redemptions).values({ + reward_id: rewardId, + dedupe_key: 'reward-redemption-dedupe', + beneficiary_user_id: user.id, + state: 'queued', + request_payload: { + lookup: { + accountId: user.google_user_email, + userId: user.google_user_email, + rewardTypeFilter: 'CREDIT', + }, + redemption: { amount: 1, unit: 'free-months' }, + }, + }); await db.insert(impact_conversion_reports).values({ conversion_id: conversionId, dedupe_key: 'impact-report-dedupe', @@ -519,6 +535,12 @@ describe('User', () => { .where(eq(impact_advocate_participants.user_id, user.id)); expect(participantCount.count).toBe(0); + const [redemptionCount] = await db + .select({ count: count() }) + .from(impact_advocate_reward_redemptions) + .where(eq(impact_advocate_reward_redemptions.beneficiary_user_id, user.id)); + expect(redemptionCount.count).toBe(0); + const [conversionCount] = await db .select({ count: count() }) .from(kiloclaw_referral_conversions) diff --git a/apps/web/src/lib/user.ts b/apps/web/src/lib/user.ts index 0a1f57f566..e59f9f0fb5 100644 --- a/apps/web/src/lib/user.ts +++ b/apps/web/src/lib/user.ts @@ -70,6 +70,7 @@ import { kiloclaw_referral_reward_decisions, kiloclaw_referral_rewards, kiloclaw_referral_reward_applications, + impact_advocate_reward_redemptions, impact_conversion_reports, } from '@kilocode/db/schema'; import { eq, and, inArray, isNotNull, isNull, sql, or, gte, count } from 'drizzle-orm'; @@ -844,6 +845,9 @@ export async function softDeleteUser(userId: string) { await tx .delete(kiloclaw_referral_reward_applications) .where(eq(kiloclaw_referral_reward_applications.beneficiary_user_id, userId)); + await tx + .delete(impact_advocate_reward_redemptions) + .where(eq(impact_advocate_reward_redemptions.beneficiary_user_id, userId)); await tx .delete(kiloclaw_referral_rewards) .where(eq(kiloclaw_referral_rewards.beneficiary_user_id, userId)); diff --git a/apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts b/apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts index 210db06bfc..75eae4970c 100644 --- a/apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts +++ b/apps/web/src/routers/admin/kiloclaw-referrals-router.test.ts @@ -5,6 +5,7 @@ import { cleanupDbForTest, db } from '@/lib/drizzle'; import { createCallerForUser } from '@/routers/test-utils'; import { insertTestUser } from '@/tests/helpers/user.helper'; import { + impact_advocate_reward_redemptions, impact_conversion_reports, kiloclaw_attribution_touches, kiloclaw_referral_conversions, @@ -113,6 +114,14 @@ async function insertReferralInvestigationRow(params: { new_renewal_boundary: '2026-06-01T00:00:00.000Z', applied_at: '2026-04-10T00:05:00.000Z', }); + await db.insert(impact_advocate_reward_redemptions).values({ + reward_id: reward.id, + dedupe_key: `reward-redemption-${params.sourcePaymentId}`, + beneficiary_user_id: referrer.id, + state: 'redeemed', + impact_reward_id: `impact-reward-${params.sourcePaymentId}`, + redeemed_at: '2026-04-10T00:06:00.000Z', + }); } await db.insert(impact_conversion_reports).values({ @@ -177,6 +186,7 @@ describe('admin kiloclaw referrals investigation', () => { }), ], impactReports: [expect.objectContaining({ state: 'delivered' })], + impactRewardRedemptions: [expect.objectContaining({ state: 'redeemed' })], }), expect.objectContaining({ referee: expect.objectContaining({ @@ -190,6 +200,7 @@ describe('admin kiloclaw referrals investigation', () => { rewardDecisions: [expect.objectContaining({ outcome: 'disqualified' })], rewardApplications: [], impactReports: [expect.objectContaining({ state: 'failed' })], + impactRewardRedemptions: [], }), ]) ); diff --git a/apps/web/src/routers/admin/kiloclaw-referrals-router.ts b/apps/web/src/routers/admin/kiloclaw-referrals-router.ts index 9ca622e4d4..12fa2eedc3 100644 --- a/apps/web/src/routers/admin/kiloclaw-referrals-router.ts +++ b/apps/web/src/routers/admin/kiloclaw-referrals-router.ts @@ -5,6 +5,7 @@ import { desc, eq, inArray, or } from 'drizzle-orm'; import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; import { db } from '@/lib/drizzle'; import { + impact_advocate_reward_redemptions, impact_conversion_reports, kiloclaw_attribution_touches, kiloclaw_referral_conversions, @@ -106,6 +107,18 @@ const ReferralInvestigationOutputSchema = z.object({ responseStatusCode: z.number().nullable(), }) ), + impactRewardRedemptions: z.array( + z.object({ + id: z.string().uuid(), + rewardId: z.string().uuid(), + beneficiaryUserId: z.string(), + state: z.string(), + impactRewardId: NullableString, + redeemedAt: NullableString, + nextRetryAt: NullableString, + responseStatusCode: z.number().nullable(), + }) + ), }) ), }); @@ -263,6 +276,27 @@ async function investigateReferrer(search: string): Promise ({ + id: redemption.id, + rewardId: redemption.rewardId, + beneficiaryUserId: redemption.beneficiaryUserId, + state: redemption.state, + impactRewardId: redemption.impactRewardId, + redeemedAt: normalizeTimestamp(redemption.redeemedAt), + nextRetryAt: normalizeTimestamp(redemption.nextRetryAt), + responseStatusCode: redemption.responseStatusCode, + })) + : [], }; }), }; diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index bea31333ab..ada0bdcfcd 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -310,6 +310,16 @@ export const ImpactConversionReportState = { export type ImpactConversionReportState = (typeof ImpactConversionReportState)[keyof typeof ImpactConversionReportState]; +export const ImpactAdvocateRewardRedemptionState = { + Queued: 'queued', + Retrying: 'retrying', + Redeemed: 'redeemed', + Failed: 'failed', +} as const; + +export type ImpactAdvocateRewardRedemptionState = + (typeof ImpactAdvocateRewardRedemptionState)[keyof typeof ImpactAdvocateRewardRedemptionState]; + // NOTE: Do not change these action names. Use present tense for consistency. export const KiloClawAdminAuditAction = z.enum([ 'kiloclaw.volume.extend', diff --git a/packages/db/src/schema.test.ts b/packages/db/src/schema.test.ts index 2a2c6f8595..8d35b34ba6 100644 --- a/packages/db/src/schema.test.ts +++ b/packages/db/src/schema.test.ts @@ -139,6 +139,7 @@ describe('database schema', () => { 'review_required', ], ImpactConversionReportState: ['queued', 'retrying', 'delivered', 'failed'], + ImpactAdvocateRewardRedemptionState: ['queued', 'retrying', 'redeemed', 'failed'], }; const actualEnumValues: Record = {}; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 98759cd9fd..54c0991187 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -58,6 +58,7 @@ import { KiloClawReferralDecisionOutcome, KiloClawReferralRewardStatus, ImpactConversionReportState, + ImpactAdvocateRewardRedemptionState, } from './schema-types'; import type { CustomLlmDefinition, @@ -150,6 +151,7 @@ export const SCHEMA_CHECK_ENUMS = { KiloClawReferralDecisionOutcome, KiloClawReferralRewardStatus, ImpactConversionReportState, + ImpactAdvocateRewardRedemptionState, } as const; export type AffiliateEventPayloadJson = { @@ -830,6 +832,62 @@ export const kiloclaw_referral_reward_applications = pgTable( export type KiloClawReferralRewardApplication = typeof kiloclaw_referral_reward_applications.$inferSelect; +export const impact_advocate_reward_redemptions = pgTable( + 'impact_advocate_reward_redemptions', + { + id: uuid() + .default(sql`pg_catalog.gen_random_uuid()`) + .primaryKey() + .notNull(), + reward_id: uuid() + .notNull() + .references(() => kiloclaw_referral_rewards.id, { + onDelete: 'cascade', + onUpdate: 'cascade', + }), + dedupe_key: text().notNull(), + beneficiary_user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + state: text() + .notNull() + .$type() + .default(ImpactAdvocateRewardRedemptionState.Queued), + impact_reward_id: text(), + request_payload: jsonb().$type | null>(), + lookup_response_payload: jsonb().$type | null>(), + redeem_response_payload: jsonb().$type | null>(), + response_status_code: integer(), + attempt_count: integer().notNull().default(0), + next_retry_at: timestamp({ withTimezone: true, mode: 'string' }), + redeemed_at: timestamp({ withTimezone: true, mode: 'string' }), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp({ withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull() + .$onUpdateFn(() => sql`now()`), + }, + table => [ + unique('UQ_impact_advocate_reward_redemptions_reward_id').on(table.reward_id), + unique('UQ_impact_advocate_reward_redemptions_dedupe_key').on(table.dedupe_key), + index('IDX_impact_advocate_reward_redemptions_beneficiary_user_id').on( + table.beneficiary_user_id + ), + index('IDX_impact_advocate_reward_redemptions_state').on(table.state), + enumCheck( + 'impact_advocate_reward_redemptions_state_check', + table.state, + ImpactAdvocateRewardRedemptionState + ), + check( + 'impact_advocate_reward_redemptions_attempt_count_non_negative_check', + sql`${table.attempt_count} >= 0` + ), + ] +); + +export type ImpactAdvocateRewardRedemption = typeof impact_advocate_reward_redemptions.$inferSelect; + export const impact_conversion_reports = pgTable( 'impact_conversion_reports', { From cfbf6c530db498bdcae0219629f20864d57447db Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 7 May 2026 12:47:41 +0200 Subject: [PATCH 25/32] fix(referrals): address review feedback --- .../components/billing/SubscriptionCard.tsx | 10 ++- apps/web/src/lib/impact-advocate.test.ts | 13 ++- apps/web/src/lib/impact-advocate.ts | 72 ++++++++++++++--- .../web/src/lib/impact-referral-utils.test.ts | 9 +++ apps/web/src/lib/impact-referral-utils.ts | 17 ++++ apps/web/src/lib/user.server.ts | 10 +-- apps/web/src/lib/user.ts | 13 +-- .../kiloclaw-billing/src/lifecycle.test.ts | 80 +++++++++++++++++++ services/kiloclaw-billing/src/lifecycle.ts | 72 ++++++++++------- 9 files changed, 238 insertions(+), 58 deletions(-) diff --git a/apps/web/src/app/(app)/claw/components/billing/SubscriptionCard.tsx b/apps/web/src/app/(app)/claw/components/billing/SubscriptionCard.tsx index f742610e07..b3f3e2062d 100644 --- a/apps/web/src/app/(app)/claw/components/billing/SubscriptionCard.tsx +++ b/apps/web/src/app/(app)/claw/components/billing/SubscriptionCard.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, type ReactNode } from 'react'; import Link from 'next/link'; import { ExternalLink, Loader2 } from 'lucide-react'; import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { useTRPC } from '@/lib/trpc/utils'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -192,8 +193,15 @@ function ActiveSubscriptionCard({ void (async () => { try { await activeConfirmation.run(); - await invalidate(); setConfirmationAction(null); + try { + await invalidate(); + } catch (error) { + console.error('[kiloclaw-billing] failed to refresh after confirmation action', error); + toast.error('Action completed, but billing did not refresh. Refresh the page.'); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Action failed. Try again.'); } finally { setPendingAction(null); } diff --git a/apps/web/src/lib/impact-advocate.test.ts b/apps/web/src/lib/impact-advocate.test.ts index 851e1f08aa..4f2a4da994 100644 --- a/apps/web/src/lib/impact-advocate.test.ts +++ b/apps/web/src/lib/impact-advocate.test.ts @@ -62,7 +62,7 @@ describe('impact advocate', () => { expect(getImpactAdvocateWidgetId()).toBe('p/51699/w/referrerWidget'); }); - it('logs debug data without tokens, credentials, authorization headers, or cookie values', async () => { + it('logs debug data without tokens, credentials, authorization headers, cookie values, or email identities', async () => { process.env.IMPACT_ADVOCATE_PROGRAM_ID = '51699'; process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; @@ -87,8 +87,9 @@ describe('impact advocate', () => { const loggedData = JSON.stringify(logSpy.mock.calls); expect(loggedData).toContain('[impact-advocate] built register participant payload'); expect(loggedData).toContain('[impact-advocate] issued verified access token'); - expect(loggedData).toContain('referee@example.com'); - expect(loggedData).toContain('referrer@example.com'); + expect(loggedData).toContain('[omitted: email identity is PII]'); + expect(loggedData).not.toContain('referee@example.com'); + expect(loggedData).not.toContain('referrer@example.com'); expect(loggedData).toContain('impact-account-sid'); expect(loggedData).toContain('segmentLengths'); expect(loggedData).toContain('[omitted: cookie value is sensitive]'); @@ -136,6 +137,8 @@ describe('impact advocate', () => { process.env.IMPACT_ADVOCATE_TENANT_ALIAS = 'tenant-alias'; process.env.IMPACT_ADVOCATE_AUTH_TOKEN = 'secret'; process.env.IMPACT_ADVOCATE_ACCOUNT_SID = 'impact-account-sid'; + process.env.IMPACT_ADVOCATE_DEBUG_LOGGING = 'true'; + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); const fetchMock = jest.fn().mockResolvedValue( new Response(JSON.stringify({ rewards: [{ id: 'reward-123', type: 'CREDIT' }] }), { status: 200, @@ -167,6 +170,10 @@ describe('impact advocate', () => { Accept: 'application/json', }), }); + const loggedData = JSON.stringify(logSpy.mock.calls); + expect(loggedData).toContain('accountId=redacted'); + expect(loggedData).toContain('userId=redacted'); + expect(loggedData).not.toContain('user@example.com'); }); it('redeems a credit reward with amount and unit', async () => { diff --git a/apps/web/src/lib/impact-advocate.ts b/apps/web/src/lib/impact-advocate.ts index c0d2b4ee81..070b84f3ad 100644 --- a/apps/web/src/lib/impact-advocate.ts +++ b/apps/web/src/lib/impact-advocate.ts @@ -137,12 +137,45 @@ export type ImpactAdvocateRewardListResult = ImpactAdvocateDispatchResult & { rewards?: unknown[]; }; +function redactAdvocateEmailIdentityForLog(value: string | null | undefined): string | null { + return value?.trim() ? '[omitted: email identity is PII]' : null; +} + +function truncateAndRedactAdvocateResponseForLog(value: string | null | undefined): string | null { + return ( + truncateForLog(value)?.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted-email]') ?? + null + ); +} + function getDebuggableRegisterParticipantPayload( payload: ImpactAdvocateRegisterParticipantPayload ) { return { - ...payload, + id: redactAdvocateEmailIdentityForLog(payload.id), + accountId: redactAdvocateEmailIdentityForLog(payload.accountId), + email: redactAdvocateEmailIdentityForLog(payload.email), cookies: '[omitted: cookie value is sensitive]', + firstName: payload.firstName ? '[omitted: name is PII]' : undefined, + lastName: payload.lastName ? '[omitted: name is PII]' : undefined, + locale: payload.locale, + countryCode: payload.countryCode, + segments: payload.segments, + customFieldsPresent: payload.customFields ? true : undefined, + }; +} + +function getDebuggableVerifiedAccessTokenPayload( + payload: ImpactAdvocateVerifiedAccessTokenPayload +): ImpactAdvocateVerifiedAccessTokenPayload { + return { + ...payload, + user: { + ...payload.user, + id: redactAdvocateEmailIdentityForLog(payload.user.id) ?? '', + accountId: redactAdvocateEmailIdentityForLog(payload.user.accountId) ?? '', + email: redactAdvocateEmailIdentityForLog(payload.user.email) ?? '', + }, }; } @@ -331,6 +364,14 @@ function getImpactAdvocateRegisterParticipantUrl( return `${base}/api/v1/${tenant}/open/account/${accountId}/user/${userId}`; } +function getDebuggableImpactAdvocateRegisterParticipantUrl( + config: NonNullable> +): string { + const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); + const tenant = encodeURIComponent(config.tenantAlias); + return `${base}/api/v1/${tenant}/open/account/[redacted-account-id]/user/[redacted-user-id]`; +} + function getImpactAdvocateRewardsUrl( config: NonNullable>, payload: ImpactAdvocateRewardLookupPayload @@ -344,6 +385,19 @@ function getImpactAdvocateRewardsUrl( return url.toString(); } +function getDebuggableImpactAdvocateRewardsUrl( + config: NonNullable>, + payload: ImpactAdvocateRewardLookupPayload +): string { + const base = trimTrailingSlashes(IMPACT_ADVOCATE_API_BASE_URL); + const tenant = encodeURIComponent(config.tenantAlias); + const url = new URL(`${base}/api/v1/${tenant}/reward`); + url.searchParams.set('accountId', 'redacted'); + if (payload.userId) url.searchParams.set('userId', 'redacted'); + if (payload.rewardTypeFilter) url.searchParams.set('rewardTypeFilter', payload.rewardTypeFilter); + return url.toString(); +} + function getImpactAdvocateRedeemRewardUrl( config: NonNullable>, rewardId: string @@ -371,7 +425,7 @@ export async function sendImpactAdvocateRegisterParticipantPayload( payload as unknown as Record ); logImpactAdvocateDebug('[impact-advocate] sending register participant request', { - url, + url: getDebuggableImpactAdvocateRegisterParticipantUrl(config), method: 'PUT', headers: { Authorization: 'not_logged', @@ -395,10 +449,10 @@ export async function sendImpactAdvocateRegisterParticipantPayload( const responseBody = await response.text(); logImpactAdvocateDebug('[impact-advocate] register participant response', { - url, + url: getDebuggableImpactAdvocateRegisterParticipantUrl(config), ok: response.ok, statusCode: response.status, - responseBody: truncateForLog(responseBody), + responseBody: truncateAndRedactAdvocateResponseForLog(responseBody), }); if (response.ok) { @@ -442,7 +496,7 @@ export async function sendImpactAdvocateRewardLookupPayload( try { const url = getImpactAdvocateRewardsUrl(config, payload); logImpactAdvocateDebug('[impact-advocate] sending reward lookup request', { - url, + url: getDebuggableImpactAdvocateRewardsUrl(config, payload), method: 'GET', accountIdPresent: Boolean(payload.accountId.trim()), userIdPresent: Boolean(payload.userId?.trim()), @@ -459,10 +513,10 @@ export async function sendImpactAdvocateRewardLookupPayload( const responseBody = await response.text(); logImpactAdvocateDebug('[impact-advocate] reward lookup response', { - url, + url: getDebuggableImpactAdvocateRewardsUrl(config, payload), ok: response.ok, statusCode: response.status, - responseBody: truncateForLog(responseBody), + responseBody: truncateAndRedactAdvocateResponseForLog(responseBody), }); if (response.ok) { @@ -533,7 +587,7 @@ export async function sendImpactAdvocateRewardRedemptionPayload( url, ok: response.ok, statusCode: response.status, - responseBody: truncateForLog(responseBody), + responseBody: truncateAndRedactAdvocateResponseForLog(responseBody), }); if (response.ok) { @@ -586,7 +640,7 @@ export function issueImpactAdvocateVerifiedAccessToken( logImpactAdvocateDebug('[impact-advocate] issued verified access token', { jwtHeader: header, - jwtPayload: payload, + jwtPayload: getDebuggableVerifiedAccessTokenPayload(payload), signOptions: { algorithm: options.algorithm, noTimestamp: options.noTimestamp, diff --git a/apps/web/src/lib/impact-referral-utils.test.ts b/apps/web/src/lib/impact-referral-utils.test.ts index 6359850f2b..c344ad0405 100644 --- a/apps/web/src/lib/impact-referral-utils.test.ts +++ b/apps/web/src/lib/impact-referral-utils.test.ts @@ -4,6 +4,7 @@ import { IMPACT_REFERRAL_TOUCH_VALIDITY_MS, parseImpactAffiliateTouchFromUrl, parseImpactReferralTouchFromUrl, + redactLandingPathForLogs, redactOpaqueTrackingValueForLogs, sanitizeOpaqueTrackingValue, } from '@/lib/impact-referral-utils'; @@ -32,6 +33,14 @@ describe('impact referral utils', () => { expect(redactOpaqueTrackingValueForLogs(null)).toBeNull(); }); + it('redacts landing path query values for logs', () => { + expect( + redactLandingPathForLogs('/get-started?_saasquatch=sq-cookie&rsCode=abc&utm_campaign=launch') + ).toBe('/get-started?_saasquatch=redacted&rsCode=redacted&utm_campaign=redacted'); + expect(redactLandingPathForLogs('/get-started')).toBe('/get-started'); + expect(redactLandingPathForLogs(null)).toBeNull(); + }); + it('parses referral touches and applies a 30 day expiry window', () => { const now = new Date('2026-04-23T10:00:00.000Z'); const touch = parseImpactReferralTouchFromUrl( diff --git a/apps/web/src/lib/impact-referral-utils.ts b/apps/web/src/lib/impact-referral-utils.ts index 5c01d601ae..e557d90b88 100644 --- a/apps/web/src/lib/impact-referral-utils.ts +++ b/apps/web/src/lib/impact-referral-utils.ts @@ -97,6 +97,23 @@ export function redactOpaqueTrackingValueForLogs(value: string | null | undefine return `${normalized.slice(0, 4)}…${normalized.slice(-4)}`; } +export function redactLandingPathForLogs(value: string | null | undefined): string | null { + const normalized = normalizeInput(value); + if (!normalized) return null; + + try { + const url = new URL(normalized, 'http://localhost'); + const redactedSearchParams = new URLSearchParams(); + for (const [key] of url.searchParams) { + redactedSearchParams.append(key, 'redacted'); + } + const search = redactedSearchParams.toString(); + return `${url.pathname}${search ? `?${search}` : ''}`; + } catch { + return '[redacted: invalid landing path]'; + } +} + export function parseImpactReferralTouchFromUrl( candidateUrl: string | URL, now: Date = new Date() diff --git a/apps/web/src/lib/user.server.ts b/apps/web/src/lib/user.server.ts index 99413ebaba..91c568fddc 100644 --- a/apps/web/src/lib/user.server.ts +++ b/apps/web/src/lib/user.server.ts @@ -423,20 +423,14 @@ async function getImpactTrackingContextFromAuthFlow(requestHeaders?: Headers): P try { const callbackUrl = new URL(callbackUrlCookie, 'http://localhost'); const referralTouch = parseImpactReferralTouchFromUrl(callbackUrl); - const urlImRefParam = callbackUrl.searchParams.get('im_ref')?.trim() || null; - const ignoreUrlImRefForReferralTouch = Boolean( - referralTouch?.opaqueTrackingValue && urlImRefParam - ); - const affiliateTouch = ignoreUrlImRefForReferralTouch - ? null - : parseImpactAffiliateTouchFromUrl(callbackUrl); + const affiliateTouch = parseImpactAffiliateTouchFromUrl(callbackUrl); logImpactReferralDebug('Auth flow parsed Impact tracking context from callback URL cookie', { affiliateTouchPresent: Boolean(affiliateTouch), referralTouchPresent: Boolean(referralTouch), referralCookieValuePresent: Boolean(referralTouch?.opaqueTrackingValue), affiliateTrackingIdPresent: Boolean(affiliateTouch?.trackingId?.trim()), - ignoredUrlImRefForReferralTouch: ignoreUrlImRefForReferralTouch, + urlImRefParamPresent: Boolean(callbackUrl.searchParams.get('im_ref')?.trim()), callbackPath: callbackUrl.pathname, }); diff --git a/apps/web/src/lib/user.ts b/apps/web/src/lib/user.ts index e59f9f0fb5..fcf55cdecf 100644 --- a/apps/web/src/lib/user.ts +++ b/apps/web/src/lib/user.ts @@ -98,9 +98,10 @@ import { recordImpactAffiliateTouch, recordImpactReferralTouch, } from '@/lib/impact-referral'; -import type { - ParsedImpactAffiliateTouch, - ParsedImpactReferralTouch, +import { + redactLandingPathForLogs, + type ParsedImpactAffiliateTouch, + type ParsedImpactReferralTouch, } from '@/lib/impact-referral-utils'; const workos = new WorkOS(WORKOS_API_KEY); @@ -499,7 +500,7 @@ export async function createOrUpdateUser( logImpactReferralDebug('Signup recording Impact affiliate touch', { userId: inserted.id, anonymousIdPresent: Boolean(trackingContext.anonymousId?.trim()), - landingPath: trackingContext.affiliateTouch.landingPath, + landingPath: redactLandingPathForLogs(trackingContext.affiliateTouch.landingPath), trackingValueLength: trackingContext.affiliateTouch.trackingValueLength, isTrackingValueAccepted: trackingContext.affiliateTouch.isTrackingValueAccepted, }); @@ -522,7 +523,7 @@ export async function createOrUpdateUser( logImpactReferralDebug('Signup recording Impact Advocate referral touch', { userId: inserted.id, anonymousIdPresent: Boolean(trackingContext.anonymousId?.trim()), - landingPath: trackingContext.referralTouch.landingPath, + landingPath: redactLandingPathForLogs(trackingContext.referralTouch.landingPath), rsCodePresent: Boolean(trackingContext.referralTouch.rsCode?.trim()), trackingValueLength: trackingContext.referralTouch.trackingValueLength, isTrackingValueAccepted: trackingContext.referralTouch.isTrackingValueAccepted, @@ -543,7 +544,7 @@ export async function createOrUpdateUser( try { logImpactReferralDebug('Signup queueing Impact Advocate participant registration', { userId: inserted.id, - landingPath: trackingContext.referralTouch.landingPath, + landingPath: redactLandingPathForLogs(trackingContext.referralTouch.landingPath), localePresent: Boolean(trackingContext.locale?.trim()), countryCode: trackingContext.countryCode ?? null, }); diff --git a/services/kiloclaw-billing/src/lifecycle.test.ts b/services/kiloclaw-billing/src/lifecycle.test.ts index 04f5283b8e..71a501bda7 100644 --- a/services/kiloclaw-billing/src/lifecycle.test.ts +++ b/services/kiloclaw-billing/src/lifecycle.test.ts @@ -1221,6 +1221,86 @@ describe('credit renewal sweep affiliate tracking', () => { }); }); + it('does not roll back or fail renewal when paid conversion side effect fails', async () => { + const renewalAt = '2026-04-09T10:00:00.000Z'; + const { db, txUpdates } = createMockDb([ + [ + { + user_id: 'user-1', + email: 'user-1@example.com', + instance_id: 'instance-1', + id: 'sub-1', + instance_row_id: 'instance-1', + organization_id: null, + instance_destroyed_at: null, + plan: 'standard', + status: 'active', + credit_renewal_at: renewalAt, + current_period_end: renewalAt, + cancel_at_period_end: false, + scheduled_plan: null, + commit_ends_at: null, + past_due_since: null, + suspended_at: null, + auto_resume_attempt_count: 0, + auto_top_up_triggered_for_period: null, + total_microdollars_acquired: 50_000_000, + microdollars_used: 0, + auto_top_up_enabled: false, + kilo_pass_threshold: null, + next_credit_expiration_at: null, + user_updated_at: '2026-04-09T09:00:00.000Z', + }, + ], + ]); + mockGetWorkerDb.mockReturnValue(db); + + vi.spyOn(globalThis, 'fetch').mockImplementation( + vi.fn(async (_request: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(typeof init?.body === 'string' ? init.body : '{}') as { + action: string; + }; + + switch (body.action) { + case 'project_pending_kilo_pass_bonus': + return new Response(JSON.stringify({ projectedBonusMicrodollars: 0 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + case 'issue_kilo_pass_bonus_from_usage_threshold': + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + case 'process_paid_conversion': + return new Response(JSON.stringify({ error: 'temporarily unavailable' }), { + status: 503, + headers: { 'content-type': 'application/json' }, + }); + default: + throw new Error(`Unexpected side effect action: ${body.action}`); + } + }) + ); + + const summary = await runSweep( + createEnv(vi.fn()), + { + runId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + sweep: 'credit_renewal', + }, + 1 + ); + + expect(summary.credit_renewals).toBe(1); + expect(summary.errors).toBe(0); + expect(txUpdates).toEqual( + expect.arrayContaining([expect.objectContaining({ current_period_start: renewalAt })]) + ); + expect(txUpdates.some(update => 'microdollars_used' in update)).toBe(true); + expect(txUpdates).not.toContainEqual(expect.objectContaining({ credit_renewal_at: renewalAt })); + }); + it('re-enqueues the existing sale dedupe key when the renewal deduction already committed', async () => { const renewalAt = '2026-04-09T10:00:00.000Z'; const { db, txInserts, txUpdates } = createMockDb( diff --git a/services/kiloclaw-billing/src/lifecycle.ts b/services/kiloclaw-billing/src/lifecycle.ts index 61a2ea902d..7a8a02accb 100644 --- a/services/kiloclaw-billing/src/lifecycle.ts +++ b/services/kiloclaw-billing/src/lifecycle.ts @@ -1025,20 +1025,22 @@ async function enqueueAffiliateEvent( ); } +type PaidConversionParams = { + userId: string; + dedupeKey: string; + eventDateIso: string; + orderId: string; + amount: number; + currencyCode: string; + itemCategory: string; + itemName: string; + itemSku?: string; +}; + async function processPaidConversion( env: BillingWorkerEnv, context: SweepExecutionContext, - params: { - userId: string; - dedupeKey: string; - eventDateIso: string; - orderId: string; - amount: number; - currencyCode: string; - itemCategory: string; - itemName: string; - itemSku?: string; - } + params: PaidConversionParams ): Promise { await callBillingSideEffect( env, @@ -1051,6 +1053,22 @@ async function processPaidConversion( ); } +async function processPaidConversionBestEffort( + env: BillingWorkerEnv, + context: SweepExecutionContext, + params: PaidConversionParams +): Promise { + try { + await processPaidConversion(env, context, params); + } catch (error) { + log('error', 'Paid conversion side effect failed after credit renewal', { + userId: params.userId, + dedupeKey: params.dedupeKey, + error: error instanceof Error ? error.message : String(error), + }); + } +} + async function autoResumeIfSuspended( env: BillingWorkerEnv, database: WorkerDb, @@ -1349,7 +1367,7 @@ async function processCreditRenewalRow( }); if (!deductionIsNew) { - await processPaidConversion(env, context, { + await processPaidConversionBestEffort(env, context, { userId, dedupeKey: `affiliate:impact:sale:${deductionCategory}`, eventDateIso: renewalAt, @@ -1402,25 +1420,17 @@ async function processCreditRenewalRow( }); } - try { - await processPaidConversion(env, context, { - userId, - dedupeKey: `affiliate:impact:sale:${deductionCategory}`, - eventDateIso: renewalAt, - orderId: deductionCategory, - amount: costMicrodollars / 1_000_000, - currencyCode: 'usd', - itemCategory: getKiloClawAffiliateItemCategory(effectivePlan), - itemName: getKiloClawAffiliateItemName(effectivePlan), - itemSku: getKiloClawAffiliateItemSku(env, effectivePlan), - }); - } catch (error) { - await database - .update(kiloclaw_subscriptions) - .set({ credit_renewal_at: renewalAt }) - .where(eq(kiloclaw_subscriptions.id, row.id)); - throw error; - } + await processPaidConversionBestEffort(env, context, { + userId, + dedupeKey: `affiliate:impact:sale:${deductionCategory}`, + eventDateIso: renewalAt, + orderId: deductionCategory, + amount: costMicrodollars / 1_000_000, + currencyCode: 'usd', + itemCategory: getKiloClawAffiliateItemCategory(effectivePlan), + itemName: getKiloClawAffiliateItemName(effectivePlan), + itemSku: getKiloClawAffiliateItemSku(env, effectivePlan), + }); summary.credit_renewals++; return; From 82af9c6b1096036471efd3b67a0370c13a6dbc43 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 7 May 2026 12:50:35 +0200 Subject: [PATCH 26/32] fix(referrals): ignore referral URL im_ref as affiliate touch --- apps/web/src/lib/user.server.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/user.server.ts b/apps/web/src/lib/user.server.ts index 91c568fddc..434c6d71a5 100644 --- a/apps/web/src/lib/user.server.ts +++ b/apps/web/src/lib/user.server.ts @@ -418,19 +418,34 @@ async function getImpactTrackingContextFromAuthFlow(requestHeaders?: Headers): P const callbackUrlCookie = cookieStore.get('__Secure-next-auth.callback-url')?.value ?? cookieStore.get('next-auth.callback-url')?.value; + const cookieTrackingId = cookieStore.get(IMPACT_CLICK_ID_COOKIE)?.value?.trim() || null; if (callbackUrlCookie) { try { const callbackUrl = new URL(callbackUrlCookie, 'http://localhost'); const referralTouch = parseImpactReferralTouchFromUrl(callbackUrl); - const affiliateTouch = parseImpactAffiliateTouchFromUrl(callbackUrl); + const urlImRefParam = callbackUrl.searchParams.get('im_ref')?.trim() || null; + const ignoreUrlImRefForReferralTouch = Boolean( + referralTouch?.opaqueTrackingValue && urlImRefParam + ); + const fallbackUrl = new URL('http://localhost/users/after-sign-in'); + const affiliateTouch = ignoreUrlImRefForReferralTouch + ? cookieTrackingId && cookieTrackingId !== urlImRefParam + ? parseImpactAffiliateTouchFromUrl(fallbackUrl, cookieTrackingId) + : null + : (parseImpactAffiliateTouchFromUrl(callbackUrl) ?? + (cookieTrackingId + ? parseImpactAffiliateTouchFromUrl(fallbackUrl, cookieTrackingId) + : null)); logImpactReferralDebug('Auth flow parsed Impact tracking context from callback URL cookie', { affiliateTouchPresent: Boolean(affiliateTouch), referralTouchPresent: Boolean(referralTouch), referralCookieValuePresent: Boolean(referralTouch?.opaqueTrackingValue), affiliateTrackingIdPresent: Boolean(affiliateTouch?.trackingId?.trim()), - urlImRefParamPresent: Boolean(callbackUrl.searchParams.get('im_ref')?.trim()), + urlImRefParamPresent: Boolean(urlImRefParam), + ignoredUrlImRefForReferralTouch: ignoreUrlImRefForReferralTouch, + affiliateCookieFallbackPresent: Boolean(cookieTrackingId?.trim()), callbackPath: callbackUrl.pathname, }); @@ -448,7 +463,6 @@ async function getImpactTrackingContextFromAuthFlow(requestHeaders?: Headers): P } } - const cookieTrackingId = cookieStore.get(IMPACT_CLICK_ID_COOKIE)?.value?.trim() || null; const fallbackUrl = new URL('http://localhost/users/after-sign-in'); const affiliateTouch = cookieTrackingId ? parseImpactAffiliateTouchFromUrl(fallbackUrl, cookieTrackingId) From 29236d43267a49670b3f3b73d63e2b9643b1bf08 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 7 May 2026 12:56:35 +0200 Subject: [PATCH 27/32] chore: cleanup old plans --- .plans/impact-affiliate-tracking.md | 448 -------------- .plans/impact-refferal-implementation.md | 712 ----------------------- .plans/impact-refferal-verification.md | 447 -------------- .plans/impact-tracking-attribution.md | 126 ---- 4 files changed, 1733 deletions(-) delete mode 100644 .plans/impact-affiliate-tracking.md delete mode 100644 .plans/impact-refferal-implementation.md delete mode 100644 .plans/impact-refferal-verification.md delete mode 100644 .plans/impact-tracking-attribution.md diff --git a/.plans/impact-affiliate-tracking.md b/.plans/impact-affiliate-tracking.md deleted file mode 100644 index 3d27c2680f..0000000000 --- a/.plans/impact-affiliate-tracking.md +++ /dev/null @@ -1,448 +0,0 @@ -# Impact.com Affiliate Tracking Integration for KiloClaw - -## Context - -Implementing Impact.com affiliate tracking for KiloClaw subscriptions. External partners drive traffic via tracking links and earn commissions on conversions. This replaces the existing Rewardful integration entirely. - -### Events to Track (per Pierluigi's spec) - -| Event | Type | Payout | -| ----------- | ------------- | -------------------------- | -| SIGNUP | Lead (parent) | Stats-only, $0 | -| TRIAL_START | Sale (child) | Stats-only or small payout | -| TRIAL_END | Sale (child) | Stats-only | -| SALE | Sale (child) | Commissionable | - -### Integration Architecture (Hybrid) - -- **UTT (JavaScript)**: Installed on app.kilo.ai for cross-domain tracking and `identify` calls. Also needed on kilo.ai (separate codebase). -- **Server-side API**: Backend POSTs conversion events to Impact.com Conversions API on signup, trial start, trial end, and subscription payment. More reliable than client-side tracking (resistant to ad blockers/ITP). - ---- - -## Implementation Steps - -### 1. Remove Rewardful Integration - -**Files to modify:** - -- `src/lib/rewardful.ts` -- delete entirely -- `src/types/rewardful.d.ts` -- delete entirely -- `src/routers/kiloclaw-router.ts` ~line 1600-1605 -- remove `getRewardfulReferral()` call and `client_reference_id` from checkout session -- `src/app/layout.tsx` or wherever Rewardful's `rw.js` script is loaded -- remove the script tag -- `package.json` -- remove any Rewardful dependencies if present - -### 2. Database Migration: Add `user_affiliate_attributions` Table - -Rather than adding an Impact-specific column to `kilocode_users`, introduce a separate `user_affiliate_attributions` table. This decouples affiliate tracking from the user schema and allows us to onboard additional affiliate/tracking programs in the future without further migrations. - -**File:** `packages/db/src/schema-types.ts` - -Define a provider enum following the existing pattern (`const` object + derived type): - -```typescript -export const AffiliateProvider = { - Impact: 'impact', -} as const; - -export type AffiliateProvider = (typeof AffiliateProvider)[keyof typeof AffiliateProvider]; -``` - -New providers are added here as additional entries. - -**File:** `packages/db/src/schema.ts` - -```typescript -export const user_affiliate_attributions = pgTable( - 'user_affiliate_attributions', - { - id: uuid().primaryKey().defaultRandom(), - user_id: text() - .notNull() - .references(() => kilocode_users.id), - provider: text().notNull().$type(), - tracking_id: text().notNull(), // provider-specific identifier (e.g. im_ref value for Impact) - created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), - }, - table => [ - // One attribution per provider per user (first-touch wins) - unique('UQ_user_affiliate_attributions_user_provider').on(table.user_id, table.provider), - index('IDX_user_affiliate_attributions_user_id').on(table.user_id), - enumCheck('user_affiliate_attributions_provider_check', table.provider, AffiliateProvider), - ] -); -``` - -Then run `pnpm drizzle generate` to create the migration. - -**Design notes:** - -- The `AffiliateProvider` enum is enforced at both the TypeScript level (`$type<>`) and the database level (`enumCheck`). -- The unique constraint on `(user_id, provider)` enforces first-touch attribution per provider. To record a tracking ID, use an upsert that no-ops on conflict. -- Querying a user's Impact tracking ID: `WHERE user_id = ? AND provider = 'impact'`. -- Adding a new provider later only requires adding a value to the `AffiliateProvider` enum and regenerating the migration. - -**GDPR note:** The tracking ID is an opaque identifier, not PII. However, since it's associated with a user, update `softDeleteUser` in `src/lib/user.ts` to delete rows from this table on user deletion, and add a corresponding test. - -### 3. Environment Variables - -Add the following env vars (values will be provided by Impact.com after contract signing): - -```env -# Impact.com API credentials -IMPACT_ACCOUNT_SID= # Account SID from Impact.com dashboard -IMPACT_AUTH_TOKEN= # Auth Token from Impact.com dashboard - -# Impact.com Program/Campaign IDs -IMPACT_CAMPAIGN_ID= # Campaign/Program ID - -# Impact.com Event Type IDs (configured in Impact.com dashboard) -IMPACT_SIGNUP_EVENT_TYPE_ID= # Lead event for user signup -IMPACT_TRIAL_START_EVENT_TYPE_ID= # Sale event for trial start -IMPACT_TRIAL_END_EVENT_TYPE_ID= # Sale event for trial end -IMPACT_SALE_EVENT_TYPE_ID= # Sale event for KiloClaw payment (initial + renewals) - -# Impact.com UTT identifier (for frontend) -NEXT_PUBLIC_IMPACT_UTT_ID= # UUID for the UTT script URL (e.g. XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXXX) -``` - -**File:** Add these to `src/env.ts` (or equivalent env validation schema) -- make them optional so the app doesn't crash in environments where Impact isn't configured. Server-side vars use `z.string().optional()`, and the UTT ID uses `NEXT_PUBLIC_` prefix for client access. - -### 4. Impact.com API Client (`src/lib/impact.ts`) - -Create a server-side API client for Impact.com's Conversions API. - -**Key functions:** - -```typescript -// SHA-1 hash email for Impact.com (they apply additional HMAC on their end) -function hashEmailForImpact(email: string): string; - -// Send a Lead conversion (SIGNUP) -async function trackSignupConversion(params: { - clickId: string | null; - customerId: string; // our user ID - customerEmail: string; // raw email (will be hashed) - eventDate: Date; -}): Promise; - -// Send a Sale conversion (TRIAL_START, TRIAL_END, SALE) -async function trackSaleConversion(params: { - eventTypeId: string; - clickId: string | null; - customerId: string; - customerEmail: string; - orderId: string; // Stripe invoice ID or subscription ID - amount: number; // in USD (decimal) - currencyCode: string; - eventDate: Date; - itemCategory: string; // e.g. "kiloclaw-standard", "kiloclaw-trial" - itemName: string; // e.g. "KiloClaw Standard Plan" - promoCode?: string; -}): Promise; -``` - -**API call format** (from Impact docs): - -``` -POST https://api.impact.com/Advertisers/{AccountSID}/Conversions -Authorization: Basic base64(AccountSID:AuthToken) -Content-Type: application/x-www-form-urlencoded - -CampaignId=...&EventTypeId=...&ClickId=...&CustomerId=...&CustomerEmail=...&OrderId=...&ItemSubTotal1=...&ItemCategory1=...&ItemName1=...&ItemQuantity1=1&CurrencyCode=USD&EventDate=... -``` - -**Error handling:** - -- Log failures but don't block the main flow (fire-and-forget with retry) -- Retry on 5xx responses (Impact.com recommends this) -- Gate all calls behind `IMPACT_ACCOUNT_SID` being set (no-op when not configured) - -### 5. Frontend: Install UTT Script - -**File:** `src/app/layout.tsx` (or the root layout) - -Add the UTT script to the ``: - -```html - -``` - -Gate behind `NEXT_PUBLIC_IMPACT_UTT_ID` being set. The UTT ID is environment-specific (different for test vs production). - -**Cross-domain note:** UTT installed on both kilo.ai (marketing site, separate codebase) and app.kilo.ai (this codebase) handles cross-domain tracking automatically. Impact.com configures the domains in their dashboard. - -### 6. Frontend: `identify` Call on Authentication - -After a user logs in or signs up, call the UTT `identify` function to bridge the user's identity for cross-device attribution. - -**File:** Create a client component `src/components/ImpactIdentify.tsx` (or add to an existing authenticated layout wrapper) - -```typescript -// Called once user is authenticated -ire('identify', { - customerId: userId, - customerEmail: sha1(userEmail), // SHA-1 hashed - customProfileId: '', // UUID cookie if we generate one, or empty string -}); -``` - -This should fire on every authenticated page load (in the root authenticated layout). Use the `ire` global injected by the UTT script. - -**Type definition:** Add `src/types/impact.d.ts`: - -```typescript -declare global { - interface Window { - ire?: (...args: unknown[]) => void; - } - function ire(...args: unknown[]): void; -} -``` - -### 7. Click ID Capture: Preserve `im_ref` Through Auth Flow - -When a user arrives at app.kilo.ai with `?im_ref=...` (passed from kilo.ai or directly from an affiliate link), we need to persist it through the OAuth flow and store it in the `user_affiliate_attributions` table. - -**Step 7a: Preserve through OAuth callback** - -**File:** `src/lib/getSignInCallbackUrl.ts` - -Add `im_ref` to the list of preserved query parameters (alongside existing `source` and `callbackPath`): - -```typescript -const imRef = searchParams.get('im_ref'); -if (imRef) url.searchParams.set('im_ref', imRef); -``` - -**Step 7b: Read im_ref after OAuth and store attribution** - -**File:** `src/app/users/after-sign-in/` (the OAuth callback handler) - -After successful authentication: - -1. Read `im_ref` from the callback URL's query params -2. Upsert a row into `user_affiliate_attributions` with `provider = 'impact'` and the click ID (no-op on conflict to preserve first-touch) - -**File:** `src/lib/user.ts` in `createOrUpdateUser()` - -Add an optional `impactClickId` parameter. After the user row is created/updated, insert the attribution: - -```typescript -if (impactClickId) { - await db - .insert(user_affiliate_attributions) - .values({ user_id: userId, provider: 'impact', tracking_id: impactClickId }) - .onConflictDoNothing(); -} -``` - -The unique constraint on `(user_id, provider)` enforces first-touch attribution automatically. - -### 8. Track SIGNUP Event (Lead) - -When a new user is created, fire a Lead conversion to Impact.com. - -**File:** `src/lib/user.ts` in `createOrUpdateUser()` - -After the user row is inserted (inside or just after the transaction), if this is a new user and they have an `impactClickId`: - -```typescript -// Fire-and-forget, don't block user creation -trackSignupConversion({ - clickId: impactClickId, - customerId: newUserId, - customerEmail: args.google_user_email, - eventDate: new Date(), -}).catch(err => console.error('Impact signup tracking failed:', err)); -``` - -**Note:** This is the "Lead" event in Impact's parent-child structure. The Impact API correlates subsequent Sale events via `CustomerId`, so we don't need to store the returned action ID. - -### 9. Track TRIAL_START Event - -When a KiloClaw trial subscription is created. - -**File:** `src/lib/kiloclaw/stripe-handlers.ts` (or wherever trial creation is handled) - -Identify the code path where a trial subscription transitions to active. Look up the user's attribution and fire: - -```typescript -const attribution = await getAffiliateAttribution(userId, 'impact'); - -trackSaleConversion({ - eventTypeId: env.IMPACT_TRIAL_START_EVENT_TYPE_ID, - clickId: attribution?.tracking_id ?? null, - customerId: user.id, - customerEmail: user.google_user_email, - orderId: stripeSubscriptionId, - amount: 0, // trial is free - currencyCode: 'usd', - eventDate: new Date(), - itemCategory: 'kiloclaw-trial', - itemName: 'KiloClaw Trial', -}); -``` - -### 10. Track TRIAL_END Event - -When a KiloClaw trial subscription ends (either by converting to paid or expiring). - -**File:** `src/lib/kiloclaw/stripe-handlers.ts` - -In the subscription status change handler, when trial → active or trial → canceled: - -```typescript -trackSaleConversion({ - eventTypeId: env.IMPACT_TRIAL_END_EVENT_TYPE_ID, - clickId: attribution?.tracking_id ?? null, - customerId: user.id, - customerEmail: user.google_user_email, - orderId: stripeSubscriptionId, - amount: 0, - currencyCode: 'usd', - eventDate: new Date(), - itemCategory: 'kiloclaw-trial-end', - itemName: 'KiloClaw Trial End', -}); -``` - -### 11. Track SALE Event (Commissionable) - -When a KiloClaw subscription invoice is paid (initial purchase or renewal). This is the primary commissionable event. - -**File:** `src/lib/kiloclaw/stripe-handlers.ts` in `handleKiloClawInvoicePaid()` - -After successful invoice settlement, look up the attribution and fire the conversion: - -```typescript -const attribution = await getAffiliateAttribution(userId, 'impact'); - -trackSaleConversion({ - eventTypeId: env.IMPACT_SALE_EVENT_TYPE_ID, - clickId: attribution?.tracking_id ?? null, - customerId: userId, - customerEmail: user?.google_user_email ?? '', - orderId: invoiceId, // Stripe invoice ID as unique order identifier - amount: amountPaidUsd, // invoice.amount_paid converted to dollars - currencyCode: invoice.currency ?? 'usd', - eventDate: new Date(), - itemCategory: `kiloclaw-${plan}`, // e.g. "kiloclaw-standard", "kiloclaw-commit" - itemName: `KiloClaw ${plan} Plan`, - promoCode: invoice.discount?.coupon?.name, -}); -``` - -**Important:** This should fire for every `invoice.paid` event, not just the first one. Impact.com handles recurring commission logic internally based on their contract configuration, so they need to see each subscription payment. - -### 12. Stripe Checkout Metadata Update - -Pass the Impact click ID through Stripe checkout so it's available in webhook handlers even if the attribution table lookup fails. - -**File:** `src/routers/kiloclaw-router.ts` in `createSubscriptionCheckout` - -Look up the attribution and include the click ID in checkout metadata: - -```typescript -const attribution = await getAffiliateAttribution(ctx.user.id, 'impact'); - -// In session creation: -subscription_data: { - metadata: { - type: 'kiloclaw', - plan: input.plan, - kiloUserId: ctx.user.id, - impactClickId: attribution?.tracking_id ?? '', - }, -}, -``` - ---- - -## File Summary - -| Action | File | Description | -| -------- | ------------------------------------- | ------------------------------------------------- | -| Delete | `src/lib/rewardful.ts` | Remove Rewardful integration | -| Delete | `src/types/rewardful.d.ts` | Remove Rewardful types | -| Edit | `src/routers/kiloclaw-router.ts` | Remove Rewardful, add Impact metadata to checkout | -| Edit | `src/app/layout.tsx` | Remove Rewardful script, add UTT script | -| Edit | `packages/db/src/schema.ts` | Add `user_affiliate_attributions` table | -| Generate | `packages/db/src/migrations/` | `pnpm drizzle generate` | -| Create | `src/lib/impact.ts` | Impact.com API client | -| Create | `src/types/impact.d.ts` | TypeScript declarations for `ire()` | -| Create | `src/components/ImpactIdentify.tsx` | Client component for identify call | -| Edit | `src/lib/getSignInCallbackUrl.ts` | Preserve `im_ref` through OAuth | -| Edit | `src/lib/user.ts` | Store attribution, track signup, GDPR cleanup | -| Edit | `src/lib/user.test.ts` | Add GDPR test for `user_affiliate_attributions` | -| Edit | `src/lib/kiloclaw/stripe-handlers.ts` | Track trial + subscription events | -| Edit | `src/env.ts` | Add Impact env var validation | - ---- - -## Testing Plan - -1. **Unit tests**: Test `hashEmailForImpact()`, test Impact API client with mocked HTTP -2. **GDPR test**: Verify `softDeleteUser` deletes `user_affiliate_attributions` rows -3. **Integration test**: End-to-end flow with test Impact.com account (provided during onboarding) -4. **Manual E2E test** (per Impact.com's testing requirements): - - Create a test partner account in Impact dashboard - - Click a test tracking link → verify `im_ref` captured - - Sign up → verify Lead conversion appears in Impact - - Start trial → verify Trial Start event - - Pay subscription → verify Sale conversion with correct amount - - Check Impact dashboard for attribution - ---- - -## Open Items (Require Input From Impact.com) - -These depend on values from your Impact.com account which aren't available until after contract + account setup: - -1. **Account SID + Auth Token** -- from Impact.com dashboard Settings > API -2. **Campaign ID** -- from Impact.com after program creation -3. **Event Type IDs** -- for each event (SIGNUP, TRIAL_START, TRIAL_END, SALE). Impact's Implementation Engineer configures these. -4. **UTT Script ID** -- the UUID in the UTT script URL, from Settings > General > Tracking -5. **Cross-domain configuration** -- Impact configures kilo.ai + app.kilo.ai in their dashboard - ---- - -## Sequencing - -The implementation can be split into these PRs: - -**PR 1: Database + API Client + Remove Rewardful** - -- Schema migration (add `user_affiliate_attributions` table) -- Create `src/lib/impact.ts` API client -- Remove Rewardful code -- Add env vars to validation schema -- GDPR update - -**PR 2: Click ID Capture + Frontend UTT** - -- Install UTT script -- Preserve `im_ref` through auth flow -- Store affiliate attribution on user creation -- `identify` call component - -**PR 3: Server-side Conversion Tracking** - -- Signup Lead event -- Trial start/end events -- Sale event (initial + renewals) -- Checkout metadata update diff --git a/.plans/impact-refferal-implementation.md b/.plans/impact-refferal-implementation.md deleted file mode 100644 index 555215faea..0000000000 --- a/.plans/impact-refferal-implementation.md +++ /dev/null @@ -1,712 +0,0 @@ -# Impact Advocate Referral Implementation Plan for KiloClaw - -## Scope - -This plan implements the KiloClaw referral program defined in `.specs/kiloclaw-referrals.md`. -That spec is authoritative for business rules, eligibility, attribution, reward semantics, reversals, and GDPR behavior. This document covers implementation shape only. - -Program scope for implementation: - -- Impact Advocate powers referral sharing, participant registration, and Impact-side reporting. -- Kilo owns the authoritative referral touch capture, affiliate/referral attribution resolution, first-paid conversion detection, reward grant idempotency, reward caps, and billing fulfillment. -- The program is double-sided: one free KiloClaw month for the referee and one free KiloClaw month for the referrer when an eligible referee reaches their first confirmed paid personal KiloClaw conversion. -- Referral rewards apply only to personal KiloClaw subscriptions. -- Rewards are fulfilled by delaying the beneficiary's next unpaid KiloClaw renewal boundary by one calendar month per reward. -- Affiliate and referral attribution are resolved together at conversion time under the spec's referral-priority rules, not generic first-touch rules. - -## Executive Recommendation - -Use a hybrid architecture with app-owned state and Impact-owned sharing UX: - -1. Use the Impact Advocate Verified Access widget `p/51699/w/referrerWidget` as the logged-in referral experience. -2. Load the Impact UTT when configured and invoke `identify` on referral-program pages for both anonymous and logged-in users. -3. Capture affiliate and referral touches in a chronological local ledger, preserve them across auth flows, and associate anonymous touches to the created user. -4. On signup with `_saasquatch`, enqueue a server-side Register Participant upsert using the captured `_saasquatch` value as opaque cookie attribution. -5. On the referee's first monetized personal KiloClaw payment period, resolve attribution using the referral-priority model from the spec: - - valid referral wins over valid affiliate, - - unless an affiliate touch had already been sale-attributed before the referral touch, - - otherwise oldest valid referral wins, then oldest valid affiliate, else none. -6. Atomically record both beneficiary reward decisions for a qualified referral conversion, including granted, cap-limited, and disqualified outcomes. -7. Fulfill granted rewards locally by delaying the next unpaid KiloClaw renewal boundary, keeping local billing state and Stripe state consistent. -8. Continue using the existing Impact Performance conversion pipeline, with `Sale (71659)` as the paid conversion event that drives Impact referral conversion reporting. -9. Keep Impact delivery, retries, and reconciliation out of the critical path for billing settlement and user access. - -The hardest implementation area remains reward fulfillment for Stripe-funded and hybrid subscriptions while preserving current KiloClaw billing invariants. - -## Current State - -### Existing Impact Affiliate Integration - -Relevant files: - -- `apps/web/src/lib/impact.ts` -- `apps/web/src/lib/affiliate-events.ts` -- `apps/web/src/lib/affiliate-attribution.ts` -- `apps/web/src/lib/impact-affiliate-utils.ts` -- `apps/web/src/app/layout.tsx` -- `apps/web/src/components/ImpactIdentify.tsx` -- `apps/web/src/app/users/after-sign-in/route.tsx` -- `apps/web/src/lib/user.ts` -- `packages/db/src/schema.ts` - -Current behavior: - -- UTT is globally loaded when `NEXT_PUBLIC_IMPACT_UTT_ID` is configured. -- Authenticated users are identified with `window.ire('identify', ...)` using `customerId` and SHA-1 hashed email. -- Affiliate touches are captured from `im_ref` or `impact_click_id` and stored as `user_affiliate_attributions`. -- Affiliate events are queued in `user_affiliate_events` and dispatched by cron. -- Existing Impact event IDs include `signup`, `trial_start`, `trial_end`, and `sale`. -- KiloClaw already emits affiliate events from trial start, trial end, and sale paths. - -Current gaps relative to the referral spec: - -- Existing affiliate attribution is not a chronological touch ledger suitable for conversion-time shared attribution resolution. -- Current attribution does not model 30-day expiration or referral-priority override. -- The schema cannot represent referral touches, participant registration state, referral relationships, reward decisions, reward states, or reward-application audit data. -- Current KiloClaw affiliate sale reporting exists, but referral rewards must be first-paid-conversion-only. -- Current flows do not record whether an affiliate touch has already been sale-attributed for later renewal protection. - -### Existing KiloClaw Billing Hooks - -Relevant files: - -- `apps/web/src/routers/kiloclaw-router.ts` -- `apps/web/src/lib/kiloclaw/credit-billing.ts` -- `apps/web/src/lib/kiloclaw/stripe-handlers.ts` -- `apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts` -- `services/kiloclaw-billing/*` -- `.specs/kiloclaw-billing.md` - -Current useful hooks: - -- Trial creation and trial-to-paid conversion paths already exist. -- Stripe invoice settlement is handled centrally. -- Pure-credit and hybrid billing paths already produce billing side effects. -- `services/kiloclaw-billing` calls the web app internal side-effect route instead of contacting Impact directly. - -Referral implementation should reuse these billing hooks to detect the referee's first confirmed paid personal KiloClaw conversion and to apply renewal-boundary extensions idempotently. - -## Authoritative Rules From the Spec - -These rules are already decided and should not be reopened in implementation: - -- Program constants: - - Impact Account `7138521` - - Performance CampaignId `50754` - - Advocate ProgramId `51699` - - UTT UUID `A7138521-9724-4b8f-95f4-1db2fbae81141` - - Widget `p/51699/w/referrerWidget` -- Verified Access identity contract: - - `id = Kilo user ID` - - `accountId = Kilo user ID` - - `email = plain user email` -- Register Participant requests are server-side and use Kilo user ID for `id` and `accountId`, with plain-text email only as the contact email. -- Referral touch validity requires non-empty `_saasquatch` and expires exactly 30 days after touch time using server UTC. -- Affiliate and referral attribution are resolved together at first paid conversion time. -- Referral has priority over affiliate unless the affiliate touch had already been sale-attributed before the referral touch occurred. -- The first paid conversion is the referee's first confirmed paid personal KiloClaw subscription payment period, including Stripe-settled, hybrid-settled, or pure-credit-funded periods. -- Trial start, trial end, signup, zero-dollar invoices, fully comped periods, admin adjustments, and non-KiloClaw purchases are not qualifying conversions. -- Referrer rewards cap at 12 total free months; referee rewards do not count toward that cap. -- Reward states must support `pending`, `earned`, `applied`, `reversed`, `expired`, `canceled`, and `review_required`. -- Reward fulfillment is app-owned and delays the next unpaid renewal boundary by one calendar month per reward. -- Impact webhooks are not a source of truth and must not be required for eligibility, granting, billing fulfillment, or reconciliation. -- Existing internal referral-code logic must not double-reward KiloClaw conversions governed by this program. - -## Impact Advocate Findings - -### Confirmed Program Values - -Impact's technical notes and the spec align on these values: - -- Account: `7138521` -- Performance CampaignId: `50754` -- Advocate ProgramId: `51699` -- UTT script: `https://utt.impactcdn.com/A7138521-9724-4b8f-95f4-1db2fbae81141.js` -- Advocate widget: `p/51699/w/referrerWidget` -- Domain: `kilo.ai` - -Performance action tracker IDs: - -| Event | ActionTrackerId | Trigger | -| ----------- | --------------- | ------------------------------------------- | -| VISIT | `71668` | Visitor lands on `kilo.ai` with `im_ref` | -| SIGNUP | `71655` | New user creation with attribution | -| TRIAL_START | `71656` | KiloClaw trial subscription becomes active | -| TRIAL_END | `71658` | KiloClaw trial subscription ends | -| SALE | `71659` | Monetized KiloClaw payment period is funded | - -### UTT, Identify, and Opaque Tracking Values - -Implementation requirements: - -- Load the UTT only when the public UTT identifier is configured. -- Invoke `identify` on pages used by the referral program. -- Anonymous `identify` calls must pass empty strings for unknown `customerId` and `customerEmail`. -- Logged-in `identify` calls must pass stable customer ID and SHA-1 hashed email. -- `identify` calls must include a stable `customProfileId` derived from the Kilo user ID for logged-in users and a stable first-party anonymous ID for anonymous users. -- Treat `_saasquatch`, `rsCode`, `rsShareMedium`, `rsEngagementMedium`, `im_ref`, and related tracking values as opaque. -- Document and enforce a maximum accepted length for opaque tracking values; values above that limit are stored only as diagnostics or ignored for attribution, and logs must redact or truncate them. - -### Advocate Widget - -Use the Verified Access widget as the launch path. - -Implementation contract: - -- Server issues a short-lived JWT. -- Client sets `window.impactToken`. -- UI renders: - -```html - -
Loading...
-
-``` - -JWT/user payload should include the Impact-required fields where available, but the identity mapping must follow the spec: Kilo user ID for `id` and `accountId`, plain email only in `email`. - -### Referred Participant Registration - -When signup occurs with `_saasquatch` attribution: - -- Associate the referral touch to the user. -- Enqueue server-side Register Participant delivery before signup is considered complete. -- External Impact delivery must not block user access. -- Pass the exact `_saasquatch` value as opaque `cookies` attribution. -- Include locale and country code when available. -- Keep failures retryable unless configuration or payload is permanently invalid. - -### Conversion Reporting - -Implementation contract: - -- Continue using the existing Performance Conversions API integration. -- Use `Sale (71659)` as the paid conversion event for first paid periods and renewals. -- Do not add client-side `trackConversion` for referrals while server-side Performance conversion is the configured mechanism. -- Use deterministic order identifiers where possible. -- Impact delivery failure must not block billing settlement, local reward decisions, or user access. -- If a referral wins attribution, ensure the first qualifying paid conversion is still reported to Impact through the existing server-side pipeline. - -## Product Rules To Encode - -### Eligibility - -Referee eligibility: - -- Must be a brand-new Kilo account. -- Existing users and previously deleted users are disqualified. -- Disqualification for previously deleted users must use the legal-approved normalized-email hash tombstone. -- Must convert on a personal KiloClaw subscription. -- Must reach a first confirmed paid monetized KiloClaw payment period. -- Trial start, trial end, signup, zero-dollar invoices, comped periods, admin adjustments, and later renewals do not qualify. -- Self-referrals are disqualified. -- Fraudulent, test, admin-created, or manually adjusted subscriptions do not qualify unless explicitly overridden through an authorized support process. - -Referrer eligibility: - -- Must be a Kilo user who is registered or registerable as an Advocate participant. -- Current subscription state does not block reward earning. -- If there is no active eligible personal KiloClaw subscription when the reward is earned, keep the reward pending until the referrer starts or reactivates an eligible paid personal KiloClaw subscription. -- If that never happens, cancel/expire the pending inactive-referrer reward 12 months after it was earned. -- Referrer rewards do not apply to trials; they apply to the next unpaid renewal boundary after paid activation/reactivation. -- Referrer can receive at most 12 total free months. - -Reward rules: - -- Qualified referral conversion grants one free-month reward to the referee. -- Qualified referral conversion grants one free-month reward to the referrer unless cap-limited or otherwise disqualified. -- Both beneficiary outcomes must be recorded atomically. -- Fulfillment is not complete until required KiloClaw billing state, and any needed Stripe state, are updated successfully. - -### Attribution - -The implementation must follow the referral-priority model from the spec, not generic first-touch attribution. - -Rules to encode: - -- Referral and affiliate share the same 30-day conversion-time window. -- Attribution is resolved at first paid KiloClaw conversion time. -- A valid referral touch wins over a valid affiliate touch unless the affiliate touch had already been sale-attributed before the referral touch. -- If multiple valid referral touches exist and no preserved sale-attributed affiliate touch blocks them, the oldest valid referral touch wins. -- If no valid referral touch exists, the oldest valid affiliate touch wins. -- If all touches are invalid or expired, no attribution wins. -- If affiliate wins, no referral rewards are granted. -- If referral wins, that first paid conversion must not generate affiliate payout attribution. -- The system must record when an affiliate touch becomes sale-attributed so later renewals can preserve affiliate attribution where required. - -Required scenario tests: - -| Scenario | Expected winner | -| ---------------------------------------------------------------------------- | --------------- | -| Affiliate first, referral second, both valid, no prior affiliate SALE | Referral | -| Affiliate first, referral second, both valid, affiliate SALE before referral | Affiliate | -| Referral first, affiliate second, both valid, no prior affiliate SALE | Referral | -| Only affiliate valid | Affiliate | -| Only referral valid | Referral | -| All touches expired or invalid | None | - -## Data Model Plan - -Add new referral-specific tables rather than overloading current affiliate tables. - -### 1. Attribution Touch Ledger - -Add a table such as `kiloclaw_attribution_touches` with fields along these lines: - -- `id` -- `anonymous_id` nullable -- `user_id` nullable until association -- `touch_type` (`affiliate` | `referral`) -- `provider` (`impact_performance` | `impact_advocate`) -- `opaque_tracking_value` -- `tracking_value_truncated` or length metadata if needed -- referral metadata fields when present: - - `rs_code` - - `rs_share_medium` - - `rs_engagement_medium` -- affiliate metadata fields when present: - - `im_ref` -- shared sanitized metadata: - - `utm_*` - - landing path -- `touched_at` -- `expires_at` -- `sale_attributed_at` nullable for affiliate touches -- `created_at` - -This is the source for KiloClaw conversion-time attribution resolution. - -### 2. Participant Registration and Referral Relationship State - -Add local tables for: - -- Advocate participant registration/upsert attempts and retry state -- local referral relationships between referrer and referee when known -- Impact-facing identifiers and statuses used only for support/reporting -- conversion reporting attempts and retry state - -Suggested separation: - -- `impact_advocate_participants` -- `impact_advocate_registration_attempts` -- `kiloclaw_referrals` -- `impact_conversion_reports` - -Keep Impact-facing fields clearly non-authoritative. - -### 3. Conversion Decision Ledger - -Add a conversion-level table to represent the result of evaluating a candidate first paid conversion, for example `kiloclaw_referral_conversions`: - -- `id` -- `referee_user_id` -- `referrer_user_id` nullable -- `source_touch_id` nullable -- `winning_touch_type` (`referral` | `affiliate` | `none`) -- `source_payment_id` / invoice / billing-period identity -- `qualified` boolean -- disqualification reason nullable -- `converted_at` -- `created_at` - -This lets the system atomically record the conversion evaluation even when no reward is granted. - -### 4. Beneficiary Decision Ledger - -Add a table such as `kiloclaw_referral_reward_decisions` to record both beneficiary outcomes atomically: - -- `id` -- `conversion_id` -- `beneficiary_user_id` -- `beneficiary_role` (`referrer` | `referee`) -- `outcome` (`granted` | `cap_limited` | `disqualified`) -- `reason` nullable -- `months_granted` -- unique key on `conversion_id + beneficiary_role` - -### 5. Reward Ledger - -Add a table such as `kiloclaw_referral_rewards` for granted rewards only: - -- `id` -- `conversion_id` -- `decision_id` -- `beneficiary_user_id` -- `beneficiary_role` -- `months_granted` -- `status` (`pending` | `earned` | `applied` | `reversed` | `expired` | `canceled` | `review_required`) -- `applies_to_subscription_id` nullable -- `earned_at` -- `applied_at` nullable -- `reversed_at` nullable -- `expires_at` nullable -- `review_reason` nullable -- unique key on conversion + beneficiary role - -### 6. Reward Application Audit - -Add a table such as `kiloclaw_referral_reward_applications`: - -- `id` -- `reward_id` -- `beneficiary_user_id` -- `subscription_id` -- previous renewal / period boundary -- new renewal / period boundary -- local billing operation identifiers -- Stripe identifiers / idempotency keys where applicable -- `applied_at` - -## Billing Design - -The free month is a renewal-boundary extension, not an account credit. - -### General Rules - -- Each reward delays the next unpaid renewal boundary by exactly one calendar month. -- Rewards must not modify finalized invoices or already-funded periods. -- Rewards apply only to KiloClaw billing, not inference usage, Kilo Pass, team plans, or non-KiloClaw purchases. -- Multiple rewards may stack. -- Reward application must be idempotent and auditable. -- If the beneficiary is canceled or canceling before application, keep the reward pending until they again have an active eligible personal KiloClaw subscription. - -### Month-to-Month - -- One reward delays the next monthly renewal by one calendar month. -- Stacking delays by one calendar month per reward. - -### Six-Month Commitment - -- One reward delays the next six-month renewal by one calendar month. -- Rewards do not change commitment shape and do not prorate the next invoice. - -### Pure-Credit KiloClaw - -- Update local renewal state so the credit-renewal sweep does not deduct hosting credits until the extended renewal time. -- Keep this entirely in local billing state. - -### Stripe-Funded or Hybrid KiloClaw - -- Reward application must keep local billing state and Stripe billing state consistent. -- Do not allow a local-only renewal delay while Stripe still charges on the original schedule. -- Use deterministic idempotency keys for Stripe operations. -- Design choice for the Stripe mechanism remains an implementation task, but the outcome is fixed by the spec: one calendar-month delay at the next unpaid renewal boundary without breaking current billing invariants. - -## Attribution and Conversion Flow - -### Landing / Touch Capture - -1. Visitor lands from an affiliate or referral link. -2. Capture the touch with `touched_at` and `expires_at = touched_at + 30 days`. -3. Preserve the touch across auth redirects and callback URLs. -4. Associate anonymous touches to the user during signup or first authenticated request after signup. -5. Treat tracking identifiers as opaque; enforce max length and redact logs. -6. Do not grant anything at capture time. - -### Signup - -1. Create the Kilo user. -2. Associate captured touches with the user. -3. If `_saasquatch` is present, enqueue Register Participant delivery before signup completes. -4. Persist registration retry state. -5. Do not block user access on external Impact delivery. -6. Do not grant free months at signup. - -### First Paid KiloClaw Conversion - -1. Detect the referee's first confirmed paid personal KiloClaw payment period. -2. Verify referee eligibility, including brand-new-account checks and previously deleted-user disqualification. -3. Resolve attribution using the referral-priority model. -4. If affiliate wins: - - record the affiliate touch as sale-attributed for future protection, - - emit existing affiliate Performance conversion behavior, - - do not grant referral rewards. -5. If referral wins: - - ensure the qualifying `Sale (71659)` conversion is reported through the existing server-side Performance pipeline, - - create the local conversion record, - - atomically record both beneficiary outcomes, - - create reward ledger rows for granted outcomes, - - leave reward application to the next unpaid renewal boundary. -6. If no touch wins: - - record the evaluation result, - - do not grant referral rewards, - - do not create affiliate payout attribution. - -### Reward Application - -1. A billing job or side-effect handler processes earned/pending rewards. -2. When the beneficiary has an eligible unpaid renewal boundary, extend that boundary by one calendar month. -3. Update reward status and write audit rows. -4. Keep retryable failures pending unless they are permanent and auditable. - -### Refunds, Chargebacks, and Fraud - -1. If the qualifying Stripe payment is charged back, cancel pending or earned-but-unapplied rewards. -2. If a qualifying payment is refunded or fraud-marked before application, cancel the unapplied rewards. -3. If a reward was already applied, move it to `review_required` instead of automatically clawing it back. -4. Reverse Impact actions with Impact's reverse-action mechanism when needed. -5. Make reversal handling idempotent. - -## Impact Integration Details - -### Environment Variables - -Likely required env vars: - -- `IMPACT_ADVOCATE_TENANT_ALIAS` -- `IMPACT_ADVOCATE_PROGRAM_ID=51699` -- `IMPACT_ADVOCATE_ACCOUNT_SID` -- `IMPACT_ADVOCATE_AUTH_TOKEN` -- `IMPACT_ADVOCATE_WIDGET_ID=p/51699/w/referrerWidget` -- `NEXT_PUBLIC_IMPACT_UTT_ID=A7138521-9724-4b8f-95f4-1db2fbae81141` - -Existing Performance values remain in use: - -- `IMPACT_CAMPAIGN_ID=50754` -- `IMPACT_ACTION_TRACKER_*` for `71655`, `71656`, `71658`, and `71659` - -If reward-bearing referral configuration is absent in an environment where the referral program is enabled, fail closed for reward issuance and log the configuration failure. - -### Server-Only Advocate Client - -Add a server-only module such as `apps/web/src/lib/impact-advocate.ts`. - -Responsibilities: - -- build Register Participant requests -- sign Verified Access JWTs -- manage retryable registration state -- optionally fetch support/reconciliation data from Impact APIs -- reverse Impact actions when required -- redact sensitive data in logs - -### Verified Access JWT Issuing - -Add a server route or tRPC procedure to issue short-lived widget JWTs. - -Requirements: - -- include Account SID as `kid` header -- sign with server-side credentials only -- set the `user` payload using the spec's identity contract -- do not let the client alter the identity payload - -### Reconciliation - -Do not make webhooks part of the core design. - -Instead: - -- keep local state authoritative -- use dashboard exports or Impact API reads for manual reconciliation and support investigation -- optionally store Impact-facing status fields only for comparison and support -- never let Impact-facing status override local eligibility, cap, attribution, or billing fulfillment rules - -## Spec Alignment Work - -`.specs/kiloclaw-referrals.md` already exists and is authoritative. - -Implementation follow-up should update sibling specs only where cross-domain behavior now needs explicit references: - -- `.specs/impact-affiliate-tracking.md` - - document that KiloClaw referral-program conversions use the referral-priority override from the referral spec -- `.specs/kiloclaw-billing.md` - - document the billing-extension fulfillment behavior and any new invariants needed for reward application - -Do not restate or redefine referral business rules outside the referral spec. - -## GDPR and PII - -Any new tables storing user IDs, emails, referral relationships, IPs, cookies, or Impact identifiers must be included in GDPR deletion/anonymization flows. - -Required code updates: - -- `apps/web/src/lib/user.ts` -- `apps/web/src/lib/user.test.ts` - -Implementation requirements: - -- anonymize or delete Advocate participant records, touches, referral relationships, reconciliation payloads containing PII, and reward records as required by policy -- delete or anonymize plain email retained for Advocate compatibility -- retain only the legal-approved non-PII tombstone / irreversible hash needed for previously deleted-user disqualification -- never log referral tracking values or sensitive headers in raw form - -## Operational Considerations - -### Configuration Safety - -- Add explicit checks for missing Advocate credentials and required reward-bearing configuration. -- Do not silently mark registration, conversion delivery, or reward application as complete when configuration is missing. -- Expose operator-visible retryable versus terminal failure states. - -### Observability - -Track at minimum: - -- referral touch captured -- affiliate touch captured -- touch associated to user -- participant registration enqueued / succeeded / retrying / terminal failure -- attribution winner at conversion time -- conversion report queued / delivered / failed -- reward decision recorded -- referrer cap limited -- reward applied -- reward canceled -- reward moved to `review_required` - -### Internal Referral System Isolation - -Before launch, ensure the existing internal referral-code system cannot grant additional KiloClaw rewards for conversions governed by this program. - -## Implementation Phases - -### Phase 0 - Confirm External Contract and Launch Inputs - -- Confirm tenant alias, credentials, Verified Access setup, and required dashboard configuration with Impact. -- Confirm the launch uses widget `p/51699/w/referrerWidget`. -- Confirm manual reconciliation path via exports/API reads. -- Confirm launch feature flag and environment gating. -- Confirm operator process for explicit support overrides on otherwise ineligible conversions. - -### Phase 1 - Schema and Spec Cross-References - -- Add referral tables for touches, participant registration state, referrals, conversion decisions, beneficiary decisions, rewards, and reward application audit. -- Update sibling specs to reference the referral spec where needed. -- Generate migrations with `pnpm drizzle generate`. -- Update GDPR deletion flow and tests. - -### Phase 2 - Touch Capture and Identity - -- Capture referral and affiliate touches with exact 30-day expiry. -- Preserve touches across auth flows. -- Associate anonymous touches to users on signup / first authenticated request. -- Update `ImpactIdentify` behavior for anonymous empty strings, logged-in SHA-1 email, and stable `customProfileId`. -- Add max-length enforcement and log redaction for opaque values. - -### Phase 3 - Advocate Widget and Participant Registration - -- Add the server-only Advocate client. -- Add Verified Access JWT issuance. -- Add the referral UI entry point that renders the widget. -- Add Register Participant enqueueing and retry handling for `_saasquatch` signups. -- Add tests for JWT payload/header and registration payload construction. - -### Phase 4 - Conversion-Time Attribution and Reward Decisions - -- Implement first paid personal KiloClaw conversion detection. -- Implement the referral-priority attribution resolver. -- Record sale-attributed affiliate touches. -- Atomically persist the conversion record and both beneficiary decisions. -- Queue or dispatch the corresponding Impact Performance conversion. -- Add tests for all required attribution scenarios and ineligibility paths. - -### Phase 5 - Billing Fulfillment - -- Implement the reward ledger state machine. -- Apply rewards to the next unpaid renewal boundary. -- Handle inactive/canceling beneficiaries by keeping rewards pending. -- Enforce the 12-month referrer cap atomically. -- Implement month-to-month, six-month, pure-credit, Stripe-funded, and hybrid fulfillment paths. -- Add audit trails and idempotency protections. - -### Phase 6 - Reversals and Support Workflows - -- Implement chargeback/refund/fraud handling. -- Move already-applied rewards to `review_required`. -- Add operator-visible support state and reason capture. -- Implement Impact reverse-action support where needed. - -### Phase 7 - Verification and Launch - -- Run `pnpm typecheck` at minimum. -- Run targeted tests for referral, affiliate, billing, and GDPR changes. -- Run `pnpm format`. -- Run `pnpm validate` before launch if the scope warrants it. -- Execute the Impact E2E checklist: - - load widget, - - copy share link, - - open share link in incognito, - - verify `_saasquatch` capture, - - sign up referee, - - confirm participant registration, - - complete first paid personal KiloClaw conversion, - - confirm local conversion decision, - - confirm local reward decisions and billing application, - - confirm Impact reporting landed. - -## Test Plan - -Add tests for: - -- referral parameter capture with `_saasquatch` treated as opaque -- affiliate parameter capture with 30-day expiry -- cross-auth touch preservation -- anonymous-to-user touch association -- `ImpactIdentify` anonymous empty-string behavior -- `ImpactIdentify` logged-in SHA-1 email behavior -- stable anonymous and logged-in `customProfileId` -- Verified Access JWT payload/header generation -- Register Participant payload and retry behavior -- conversion-time attribution resolver -- the six required attribution scenarios from the spec -- brand-new referee eligibility -- previously deleted-user disqualification via tombstone hash -- self-referral disqualification -- personal subscription only -- first paid monetized conversion only -- no reward on trial start, signup, comped periods, or renewals -- atomic dual-beneficiary decision recording -- referrer 12-month cap enforcement under concurrency -- month-to-month reward application -- six-month commitment reward application -- pure-credit reward application -- Stripe-funded / hybrid reward application -- cancellation / pending reward behavior -- chargeback / refund / fraud handling -- `review_required` transitions for already-applied rewards -- GDPR deletion / anonymization of referral data -- internal referral system isolation for KiloClaw conversions - -Regression tests: - -- existing affiliate dispatch flows -- existing KiloClaw trial/start/sale behavior -- existing credit billing lifecycle -- existing Stripe handlers - -## Open Implementation Questions - -These are implementation questions only; business rules remain fixed by the spec. - -- What exact maximum length should be enforced for opaque tracking values? -- Which existing user/session identifier is the best source for the stable anonymous `customProfileId`? -- What is the safest Stripe mechanism for delaying the next unpaid renewal boundary for Stripe-funded and hybrid subscriptions? -- Which worker/job boundary should own reward application retries versus conversion-report retries? -- Do we want a dedicated conversion-decision table plus decision table, or can the same atomic guarantees be achieved cleanly with a narrower schema? -- Which admin surface should expose retryable registration/reporting failures and `review_required` rewards? - -## Rollout Plan - -1. Ship behind a feature flag. -2. Enable in staging/test Impact environment first. -3. Run end-to-end referral and affiliate-vs-referral attribution tests. -4. Enable for internal users. -5. Enable for a small production cohort. -6. Monitor attribution outcomes, reward decision counts, reward application correctness, retry queues, and support volume. -7. Roll out broadly once local state and Impact reporting reconcile cleanly. - -## Final Notes - -The implementation should keep local state authoritative at every critical decision point: - -- touch capture -- user association -- conversion-time attribution -- referee/referrer eligibility -- cap enforcement -- reward decision recording -- billing fulfillment -- reversal handling - -Impact Advocate remains a valuable integration for sharing UX, participant registration, and reporting, but it should not own the product logic or billing effects governed by `.specs/kiloclaw-referrals.md`. diff --git a/.plans/impact-refferal-verification.md b/.plans/impact-refferal-verification.md deleted file mode 100644 index 1680754df0..0000000000 --- a/.plans/impact-refferal-verification.md +++ /dev/null @@ -1,447 +0,0 @@ -# Referral happy path — human verification script - -## Goal - -Prove that: - -1. an eligible **referrer** can get a referral link, -2. a brand-new **referee** signs up through that link, -3. the referral touch is recorded, -4. the referee’s **first paid personal KiloClaw conversion** is attributed to the referral, -5. both referral rewards are granted, -6. both rewards are applied in the happy path. - -> Use this in staging or any environment with Impact Advocate configured. -> A fully local end-to-end happy path is not sufficient right now because local verification showed `/api/impact-advocate/token` returning `503` when Advocate is unconfigured. - -## Preconditions - -Before starting, confirm all of these are true: - -- Environment has: - - Impact Advocate configured - - Impact conversion reporting configured - - test payments available -- You have read-only DB access for verification -- You can log in as two different test users -- The **referrer already has an active eligible personal KiloClaw subscription** - - this matters so the referrer reward can be **applied immediately** instead of staying `pending` - -## Running locally via ngrok - -If you are testing against a locally running app instead of staging, use an HTTPS ngrok URL rather than `http://localhost:3000`, because the Impact Advocate widget may require an allowlisted non-localhost origin. - -Basic setup: - -1. Start the app locally: - -```bash -pnpm dev:start -``` - -2. Start ngrok in a separate terminal: - -```bash -ngrok http 3000 -``` - -3. Copy the HTTPS forwarding URL from ngrok, then set it as the app base URL and restart the app: - -```bash -export APP_URL_OVERRIDE=https://.ngrok-free.app -pnpm dev:stop -pnpm dev:start -``` - -4. Open the site through the ngrok URL, not localhost. - -Notes: - -- Ask the Impact / SaaSquatch admin to allowlist the exact ngrok origin if the referral widget is blocked by CORS. -- If the ngrok hostname changes, update `APP_URL_OVERRIDE`, restart the app, and re-allowlist the new origin if needed. -- For payment verification, keep the entire flow on the same ngrok origin so auth and Stripe redirects stay consistent. - -## Test accounts - -Use two fresh accounts: - -- **Referrer**: `qa-referrer-@example.com` -- **Referee**: `qa-referee-@example.com` - -Use unique emails each run. - -## Step 1: Prepare the referrer - -1. Sign in as the **referrer** -2. Go to **Profile** -3. Confirm the **Referral Program** section is visible -4. Open the referral widget / referral sharing UI -5. Copy the generated referral link - -### Expected - -- The widget loads successfully -- No error banner is shown -- You can copy a referral link -- The link contains referral params, typically including: - - `_saasquatch` - - `rsCode` - - optionally medium params like: - - `rsShareMedium` - - `rsEngagementMedium` - -### Capture - -- Screenshot of Profile page with Referral Program visible -- Screenshot of the widget or copied link UI -- The copied referral URL - -## Step 2: Sign up the referee through the referral link - -1. Open a fresh incognito/private window -2. Paste the copied referral link -3. Complete signup as the **referee** -4. Complete any required onboarding -5. Land in the signed-in app - -### Expected - -- Signup succeeds normally -- Referral params survive auth/onboarding redirects -- The user reaches the app successfully -- No auth-flow breakage - -### Capture - -- Final app URL after signup -- Screenshot of successful logged-in state - -## Step 3: Verify referral touch capture in the DB - -Look up both users: - -```sql -select id, google_user_email, created_at -from kilocode_users -where google_user_email in ( - 'qa-referrer-@example.com', - 'qa-referee-@example.com' -) -order by google_user_email; -``` - -Save: - -- `` -- `` - -Now verify the referee touch: - -```sql -select - id, - user_id, - touch_type, - provider, - rs_code, - rs_share_medium, - rs_engagement_medium, - touched_at, - expires_at -from kiloclaw_attribution_touches -where user_id = '' -order by touched_at desc; -``` - -### Expected - -At least one row exists with: - -- `touch_type = 'referral'` -- `provider = 'impact_advocate'` -- `rs_code` populated -- `expires_at` populated -- the touch is attached to the referee user - -Optional relationship check: - -```sql -select - referee_user_id, - referrer_user_id, - source_touch_id, - created_at -from kiloclaw_referrals -where referee_user_id = ''; -``` - -### Expected - -- One row linking referee to referrer - -## Step 4: Verify referrer participant exists - -```sql -select - user_id, - advocate_id, - advocate_account_id, - opaque_referral_identifier, - registration_state, - registered_at, - last_error_code -from impact_advocate_participants -where user_id = ''; -``` - -### Expected - -- row exists -- `registration_state = 'registered'` -- `opaque_referral_identifier` is populated - -## Step 5: Purchase the referee’s first paid personal KiloClaw subscription - -1. Stay signed in as the **referee** -2. Go through the normal personal KiloClaw purchase flow -3. Complete the first real/test payment -4. Wait for billing side effects / webhook processing to complete - -### Expected - -- Purchase succeeds -- This is the referee’s **first monetized personal** KiloClaw paid period -- No support override is needed -- No affiliate flow should win over the referral in this happy path - -### Capture - -- Screenshot of successful purchase / active subscription UI -- Any order/payment ID shown in the UI or logs - -## Step 6: Verify the referral conversion in the DB - -```sql -select - id, - referee_user_id, - referrer_user_id, - winning_touch_type, - qualified, - disqualification_reason, - source_payment_id, - converted_at, - created_at -from kiloclaw_referral_conversions -where referee_user_id = '' -order by created_at desc -limit 1; -``` - -Save ``. - -### Expected - -- row exists -- `winning_touch_type = 'referral'` -- `qualified = true` -- `disqualification_reason is null` - -## Step 7: Verify both beneficiary decisions were granted - -```sql -select - beneficiary_role, - outcome, - reason, - months_granted -from kiloclaw_referral_reward_decisions -where conversion_id = '' -order by beneficiary_role; -``` - -### Expected - -Exactly two rows: - -- `referee` -> `outcome = 'granted'` -- `referrer` -> `outcome = 'granted'` - -And: - -- `months_granted = 1` for both -- `reason is null` - -## Step 8: Verify both rewards were created and applied - -```sql -select - id, - beneficiary_user_id, - beneficiary_role, - status, - months_granted, - applied_at, - expires_at -from kiloclaw_referral_rewards -where conversion_id = '' -order by beneficiary_role; -``` - -### Expected - -Exactly two rows: - -- one for the referee -- one for the referrer - -And in the happy path: - -- both have `status = 'applied'` -- both have `months_granted = 1` -- both have `applied_at` populated - -Now verify reward application records: - -```sql -select - reward_id, - subscription_id, - applied_at, - created_at -from kiloclaw_referral_reward_applications -where reward_id in ( - select id - from kiloclaw_referral_rewards - where conversion_id = '' -) -order by created_at; -``` - -### Expected - -- application rows exist for both rewards - -## Step 9: Verify billing moved the renewal boundary forward - -Referee subscription: - -```sql -select - id, - user_id, - status, - plan, - current_period_end, - credit_renewal_at -from kiloclaw_subscriptions -where user_id = '' -order by created_at desc -limit 1; -``` - -Referrer subscription: - -```sql -select - id, - user_id, - status, - plan, - current_period_end, - credit_renewal_at -from kiloclaw_subscriptions -where user_id = '' -order by created_at desc -limit 1; -``` - -### Expected - -- both subscriptions are eligible personal subscriptions -- the next unpaid renewal boundary is delayed by roughly **1 month** -- in practice, you should see `current_period_end` and/or `credit_renewal_at` advanced compared with the pre-reward state - -Optional log check: - -```sql -select - subscription_id, - action, - reason, - created_at -from kiloclaw_subscription_change_log -where reason = 'referral_reward:applied' - and subscription_id in ( - select id from kiloclaw_subscriptions - where user_id in ('', '') - ) -order by created_at desc; -``` - -### Expected - -- entries exist showing reward application side effects - -## Step 10: Verify Impact conversion reporting succeeded - -```sql -select - conversion_id, - state, - response_status_code, - delivered_at, - error -from impact_conversion_reports -where conversion_id = ''; -``` - -### Expected - -- row exists -- `state = 'delivered'` -- `error is null` - -## UI sanity check after reward application - -1. Refresh the **referee** billing/subscription UI -2. Refresh the **referrer** billing/subscription UI - -### Expected - -- both accounts still load normally -- no broken billing state -- next renewal/billing date reflects the added month, if surfaced in UI - -## Pass criteria - -The happy path passes if all of the following are true: - -- referrer can open referral sharing UI and copy a link -- referee can sign up via that link successfully -- referee has a stored referral touch -- conversion row exists for the referee’s first paid personal conversion -- conversion is: - - `winning_touch_type = referral` - - `qualified = true` -- two decision rows exist and both are `granted` -- two reward rows exist and both are `applied` -- reward application rows exist -- billing renewal boundary moved forward by 1 month -- Impact conversion report is `delivered` - -## Fail examples - -Treat the run as failed if any of these happen: - -- referral widget does not load -- signup loses referral attribution through redirects -- no `impact_advocate` referral touch is stored -- conversion is recorded with: - - `winning_touch_type = affiliate` - - `winning_touch_type = none` - - `qualified = false` -- either beneficiary decision is not `granted` -- either reward stays `pending` in this happy-path setup -- no reward application rows are created -- Impact report is `failed` or `retrying` diff --git a/.plans/impact-tracking-attribution.md b/.plans/impact-tracking-attribution.md deleted file mode 100644 index 9d913d76ef..0000000000 --- a/.plans/impact-tracking-attribution.md +++ /dev/null @@ -1,126 +0,0 @@ -# Affiliate Event Ledger With Cron-Only Dispatch - -## Summary - -- Use a lean `user_affiliate_events` table as the durable ledger/outbox for affiliate conversions. -- Make the web-app cron route the only dispatcher to Impact. Auth, checkout, Stripe webhook, and billing paths only enqueue rows. -- Enforce parent-before-child delivery by requiring a delivered parent event before `trial_start`, `trial_end`, or `sale` can move out of `blocked`. -- Use generic internal naming: `trackingId`, `affiliateTrackingId`, and `findOrCreateParentEvent`. Only the final Impact API adapter uses `ClickId`. - -## Spec Updates - -Update `.specs/impact-affiliate-tracking.md`: - -- Replace `SIGNUP only for new user creation` with `SIGNUP once per user/provider on first attributed association.` -- Add an invariant that child events must not be sent before the parent `SIGNUP` event has been successfully delivered. - -## Schema And Naming - -- Add `user_affiliate_events` with only these fields: - - `id` - - `user_id` - - `provider` - - `event_type` - - `dedupe_key` - - `parent_event_id` nullable - - `delivery_state` - - `payload_json` - - `attempt_count` - - `next_retry_at` nullable - - `claimed_at` nullable - - `created_at` -- Use `delivery_state` values: `queued | blocked | sending | delivered | failed`. -- Add indexes for: - - unique `dedupe_key` - - claim path on `(delivery_state, coalesce(next_retry_at, '-infinity'), created_at, id)` - - `parent_event_id` -- Do not add `attribution_id` in v1. -- Keep `payload_json` normalized around generic fields such as `trackingId`, `customerId`, `customerEmailHash`, `orderId`, `eventDate`, amount/currency/item fields where relevant. The dispatcher maps this to the provider API payload. -- Rename internal helper `findOrCreateSignupParentEvent` to `findOrCreateParentEvent`. -- Resolve the parent event type from provider config in code. For Impact in v1, the parent event type is `signup`. -- Rename internal `clickId` parameters and fields to `trackingId`. -- Rename Stripe checkout metadata from `impactClickId` to `affiliateTrackingId`. -- Read Stripe metadata compatibly during rollout: prefer `affiliateTrackingId`, fall back to legacy `impactClickId` for existing subscriptions and webhook events. -- Keep external Impact-specific names unchanged where required: - - query param remains `im_ref` - - cookie contract remains unchanged for compatibility unless a later coordinated change updates `kilo.ai` - - outbound Impact payload still uses `ClickId` - -## Dispatch And Logging - -- Enqueue rules: - - New user with first Impact attribution: create attribution row, then `findOrCreateParentEvent`, then enqueue only the parent event. - - Existing user who first gains Impact attribution on login: same flow. - - `trial_start`, `trial_end`, and `sale` enqueue child rows only after ensuring the parent row exists. - - If the parent row is not yet `delivered`, create the child row as `blocked`. -- Cron route: - - Add `/api/cron/dispatch-affiliate-events` on `* * * * *`. - - Each run claims up to 100 eligible `queued` rows with `FOR UPDATE SKIP LOCKED`, sets `delivery_state = 'sending'`, and stamps `claimed_at`. - - On success, mark row `delivered`. - - On `5xx` or network error, increment `attempt_count`, set `delivery_state = 'queued'`, clear `claimed_at`, and compute `next_retry_at`. - - On `4xx`, increment `attempt_count`, mark row `failed`, and clear `claimed_at`. - - Before claiming new work, return stale `sending` rows with old `claimed_at` back to `queued`. - - After a parent row becomes `delivered`, promote its `blocked` children to `queued`. -- Replace direct Impact dispatch in: - - auth user creation - - after-sign-in attribution recovery - - KiloClaw trial start - - Stripe-driven KiloClaw trial end - - Stripe `invoice.paid` sale tracking - - billing-worker trial-expiry side effect -- Replace the billing side-effect action with a generic enqueue action rather than direct tracking. -- Structured logs: - - Use a dedicated logger source such as `affiliate-events`. - - Every enqueue, claim, dispatch success, retry, unblock, and permanent failure log must include: - - `affiliate_event_id` - - `affiliate_parent_event_id` - - `affiliate_provider` - - `affiliate_event_type` - - `affiliate_dedupe_key` - - `user_id` - - `delivery_state` - - `attempt_count` - - Dispatch logs should also include: - - `dispatch_source` (`cron`) - - `action_tracker_id` when provider is Impact - - `order_id` when present - - `tracking_id_present` - - Failure logs should include: - - `failure_kind` (`http_4xx`, `http_5xx`, `network`) - - `status_code` when available - - The DB row id is the primary join key between logs and the event ledger. - -## Test Plan - -- Schema/service tests: - - dedupe keys prevent duplicate parent and child rows - - blocked children do not dispatch before the parent is delivered - - delivered parents promote blocked children to queued - - stale `sending` rows are reclaimed from `claimed_at` - - retries respect `next_retry_at` -- Naming/compat tests: - - internal enqueue payloads use `trackingId` - - Stripe checkout writes `affiliateTrackingId` - - webhook readers accept both `affiliateTrackingId` and legacy `impactClickId` -- Auth-flow tests: - - new attributed users enqueue exactly one parent event - - existing users gaining attribution enqueue exactly one parent event - - repeat attributed logins do not duplicate the parent event -- KiloClaw tests: - - `trial_start`, `trial_end`, and `sale` enqueue rows instead of calling Impact directly - - non-attributed users do not enqueue affiliate events - - attributed users enqueue child rows linked to the correct parent row -- Cron-route tests: - - unauthorized requests are rejected - - success marks rows delivered - - `5xx` requeues with backoff - - `4xx` marks rows failed - - success/failure logs include `affiliate_event_id` and `affiliate_dedupe_key` -- Keep and extend `impact.test.ts` so the final Impact adapter still emits `ClickId` correctly from internal `trackingId`. - -## Assumptions - -- `signup` now means the provider-specific parent event for the user's first attributed association, not only account creation. -- Logs, not extra DB columns, are the source of truth for per-attempt delivery history. -- `created_at` plus the structured logs are sufficient audit history for v1; `delivered_at`, `last_attempt_at`, `last_status_code`, `last_error`, `sending_started_at`, and generic extra timestamps are intentionally omitted. -- `softDeleteUser` must delete `user_affiliate_events` rows in addition to `user_affiliate_attributions`. From b211d6e30b2e5770d403ffad79317427db3da054 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 7 May 2026 13:19:09 +0200 Subject: [PATCH 28/32] chore: optimize spec files --- .specs/impact-affiliate-tracking.md | 266 -------------------------- .specs/kiloclaw-affiliates.md | 260 +++++++++++++++++++++++++ .specs/kiloclaw-referrals.md | 287 ++++++++++++++-------------- 3 files changed, 403 insertions(+), 410 deletions(-) delete mode 100644 .specs/impact-affiliate-tracking.md create mode 100644 .specs/kiloclaw-affiliates.md diff --git a/.specs/impact-affiliate-tracking.md b/.specs/impact-affiliate-tracking.md deleted file mode 100644 index 75080de756..0000000000 --- a/.specs/impact-affiliate-tracking.md +++ /dev/null @@ -1,266 +0,0 @@ -# Impact.com Affiliate Tracking - -## Role of This Document - -This spec defines the business rules and invariants for affiliate conversion tracking via Impact.com for KiloClaw -subscriptions. It is the source of truth for _what_ the system must guarantee — which events are tracked, how -attribution is captured, what data is sent to Impact.com, and how the system behaves when tracking infrastructure is -unavailable. It deliberately does not prescribe _how_ to implement those guarantees: handler names, column layouts, -retry strategies, and other implementation choices belong in plan documents and code, not here. - -## Status - -Draft -- created 2026-03-31. -Updated 2026-04-01 -- aligned with revised Impact integration document and implementation review. -Updated 2026-04-06 -- clarify that conversion events require an affiliate attribution record. -Updated 2026-04-09 -- treat pure-credit KiloClaw periods as sale events and exclude admin/org flows. -Updated 2026-04-09 -- require a 5-minute delay after SIGNUP delivery before child dispatch. -Updated 2026-04-17 -- define dispute-triggered sale reversals. - -## Conventions - -The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", -"NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC 2119] -[RFC 8174] when, and only when, they appear in all capitals, as shown here. - -## Definitions - -- **Impact.com**: The third-party affiliate tracking platform used to attribute conversions to affiliate partners. -- **UTT (Universal Tracking Tag)**: A JavaScript snippet provided by Impact.com that enables client-side tracking and - cross-domain identity bridging. -- **Click ID**: An opaque tracking identifier (`im_ref` query parameter) appended to landing page URLs by Impact.com - when a visitor arrives via an affiliate tracking link. -- **Conversion**: An event reported to Impact.com's Conversions API representing a meaningful step in the customer - lifecycle (visit, signup, trial, or subscription payment). -- **Lead event**: A conversion representing a visit or user signup. In Impact.com's parent-child model, the SIGNUP - event is the parent action. -- **Sale event**: A conversion representing a trial or subscription payment. In Impact.com's parent-child model, these - are child actions linked to the lead via the customer identifier. -- **Affiliate attribution**: A record associating a user with the affiliate tracking identifier that brought them to - the platform. -- **First-touch attribution**: The attribution model used: only the first affiliate interaction per provider is recorded - for a given user. -- **Affiliate provider**: A named affiliate tracking platform (e.g. `impact`). The system supports multiple providers, - each storing one attribution per user. - -## Overview - -Affiliate tracking enables Impact.com to attribute KiloClaw conversions to the affiliate partners that referred them. -When a visitor arrives via an affiliate tracking link, the system captures and persists the tracking identifier. As the -visitor progresses through the customer lifecycle — signup, trial, subscription — the system reports each stage to -Impact.com as a conversion event, including the tracking identifier and customer details needed for attribution. - -The system uses a hybrid tracking architecture: a client-side JavaScript tag (UTT) for cross-domain identity bridging, -and server-side API calls for reliable conversion reporting that is resistant to ad blockers and browser tracking -prevention. - -This integration applies only to personal KiloClaw subscriptions. Organization-scoped KiloClaw instances are not -eligible for affiliate tracking. - -For KiloClaw conversions that are also governed by `.specs/kiloclaw-referrals.md`, that referral spec's -conversion-time referral-priority rules override this document's default first-touch affiliate behavior for the initial -paid conversion decision. This document remains authoritative for Impact Performance event shapes, delivery sequencing, -and affiliate renewal reporting after the winning attribution has been established. - -## Rules - -### Affiliate Attribution - -1. The system MUST support multiple affiliate providers, identified by a provider enum. The initial provider is - `impact`. - -2. The system MUST store at most one attribution per user per provider. - -3. When a user arrives with an affiliate tracking identifier (`im_ref` query parameter for Impact.com), the system MUST - persist the identifier before or during user creation. - -4. The system MUST preserve the tracking identifier across the authentication flow (e.g. through OAuth redirects) so it - is available after the user is authenticated. - -5. Attribution MUST use first-touch semantics: if a user already has an attribution record for a given provider, - subsequent tracking identifiers for that provider MUST NOT overwrite it. - -6. The tracking identifier MUST be treated as opaque. The system MUST NOT parse, validate the format of, or assign - meaning to its contents. - -7. When a user record is deleted (e.g. GDPR soft-delete), the system MUST delete all affiliated attribution records for - that user. - -### Conversion Events - -8. The system MUST report the following conversion events to Impact.com, in order of the customer lifecycle: - - | Event | ActionTrackerId | Impact.com Type | Trigger | - | ----------- | --------------- | --------------- | --------------------------------------------- | - | VISIT | 71668 | Lead | Visitor lands on `kilo.ai` with `im_ref` | - | SIGNUP | 71655 | Lead | New user creation (with attribution) | - | TRIAL_START | 71656 | Sale | KiloClaw trial subscription becomes active | - | TRIAL_END | 71658 | Sale | KiloClaw trial subscription ends (any reason) | - | SALE | 71659 | Sale | Monetized KiloClaw payment period is funded | - -9. Each conversion event sent to Impact.com MUST include: - - An event timestamp - - An order identifier - - The user's affiliate tracking identifier, when available for that event - - A stable customer identifier, when available for that event - - The customer's email address, SHA-1 hashed, when available for that event - -10. VISIT events MUST only include `EventDate`, `ClickId`, and `OrderId`. VISIT events MUST NOT include `CustomerId`, - `CustomerEmail`, `IpAddress`, or `CustomerStatus`. - -11. VISIT events MUST fire on the marketing site (`kilo.ai`) before a user account exists. VISIT events MUST NOT create - a `user_affiliate_attributions` row. - -12. When a meaningful internal order identifier is not available, the system MUST send `IR_AN_64_TS` as `OrderId`. - Impact.com generates a unique alphanumeric order identifier from this macro. This applies to VISIT, SIGNUP, - TRIAL_START, and TRIAL_END events. These generated identifiers MUST NOT be relied on for internal reconciliation. - -13. SIGNUP and TRIAL_START events MUST include `ClickId` alongside `CustomerId` as an attribution fallback. This covers - the case where a child event is processed before the parent SIGNUP event finishes processing. For later sale events, - including `ClickId` is RECOMMENDED but not REQUIRED. - -14. VISIT events MUST NOT include `CustomerId` because the user does not yet exist. - -15. SALE events MUST include the monetized amount and currency for the funded KiloClaw period. - -16. SALE events MUST include the subscription plan identifier (e.g. `kiloclaw-standard`, - `kiloclaw-commit`) as the item category. - -17. SALE events MUST be reported for every monetized KiloClaw payment period (both initial and renewal), including - Stripe invoice settlements and pure-credit deductions. - -18. Conversion events SHOULD include a promo code when one was applied to the transaction. - -19. The SIGNUP event MUST be sent at most once per user per provider, on that user's first attributed association for - the provider. This MAY occur during new user creation or during a later sign-in when an existing user first gains - affiliate attribution. - -20. Child conversion events (TRIAL_START, TRIAL_END, SALE) MUST NOT be sent before the parent SIGNUP event has been - successfully delivered. For Impact.com, child conversion events MUST NOT be dispatched until at least 5 minutes - after the SIGNUP event has been delivered. - -21. Admin-only subscription interventions (for example admin trial resets, admin cancellations, or manual trial-date - edits) MUST NOT emit affiliate conversion events. These are internal overrides, not customer lifecycle events. - -22. When a Stripe-backed personal KiloClaw SALE later receives a `charge.dispute.created` event, the system MUST - submit a reversal for the full associated Impact.com commission. - -23. Partial Stripe disputes MUST still reverse the full associated Impact.com commission. - -24. The system MUST trigger the reversal on `charge.dispute.created`. The system MUST NOT automatically restore the - commission later if the dispute is resolved in the brand's favor. - -25. Automatic reversal is only guaranteed for SALE events created after rollout that persisted an Impact action mapping - for the disputed Stripe charge. Earlier SALE events without stored mapping are out of scope for automatic reversal - and require manual follow-up. - -### Client-Side Tracking (UTT) - -22. The system MUST load the Impact.com UTT script on all pages when the UTT identifier is configured. - -23. The system MUST NOT load the UTT script when the UTT identifier is not configured. - -24. After a user authenticates, the system MUST call the UTT `identify` function with the user's internal ID and SHA-1 - hashed email to enable cross-device attribution. - -### Reliability and Isolation - -25. Conversion reporting MUST NOT block or delay the primary operation it is attached to (user creation, subscription - settlement, etc.). Failures in conversion reporting MUST be handled asynchronously. - -26. If Impact.com credentials are not configured, all tracking operations MUST be no-ops. The application MUST function - normally without Impact.com configuration. - -27. The system SHOULD retry conversion API calls that receive a server error (5xx) response. - -28. The system MUST log conversion reporting failures for observability. - -### Rewardful Removal - -29. The existing Rewardful integration MUST be fully removed. This includes the client-side script, server-side cookie - reading, and any checkout session metadata populated by Rewardful. - -### Checkout Metadata - -30. The KiloClaw checkout session MUST include the user's affiliate tracking identifier (if any) in Stripe subscription - metadata, so it is available to webhook handlers independently of a database lookup. - -### API Contract - -31. Conversion API requests MUST use JSON request bodies, not form-encoded bodies. - -32. Conversion API requests MUST use `ActionTrackerId` to identify the configured event, not `EventTypeId`. - -### Reference Values - -33. The implementation MUST treat the following program identifiers as configuration constants for this integration: - - CampaignId: `50754` - - UTT UUID: `A7138521-9724-4b8f-95f4-1db2fbae81141` - - ActionTrackerIds: `71655`, `71656`, `71658`, `71659`, `71668` - -## Error Handling - -1. When a conversion API call fails with a client error (4xx), the system MUST log the error and MUST NOT retry. - -2. When a conversion API call fails with a server error (5xx), the system SHOULD retry with backoff. - -3. When a conversion API call fails for any reason, the primary operation (user creation, invoice settlement, etc.) MUST - NOT be affected. - -4. Conversion events (SIGNUP, TRIAL_START, TRIAL_END, SALE) MUST only be sent for users who have an affiliate - attribution record. Users who did not arrive via an affiliate link MUST NOT generate conversion events. When an - attribution record exists but the click ID stored in it is empty or null, the event MUST still be sent with an - empty or null click ID. - -## Changelog - -### 2026-03-31 -- Initial spec - -### 2026-03-31 -- Rename SUBSCRIPTION_START to SALE - -Renamed the SUBSCRIPTION_START event to SALE to reflect that it covers all KiloClaw payments (initial purchase and -renewals), not just subscription creation. Clarified that SALE events fire for every paid invoice. - -### 2026-04-01 -- Align spec with revised Impact integration guide - -Added the VISIT and RE_SUBSCRIPTION events, switched API terminology to `ActionTrackerId`, documented JSON request -bodies, clarified `IR_AN_64_TS` order ID usage, required `ClickId` fallback on early events, added `Numeric1` month -tracking for renewals, and recorded the concrete Campaign/UTT/ActionTracker identifiers from the latest implementation -guide. - -### 2026-04-02 -- Remove RE_SUBSCRIPTION event, use SALE for all paid invoices - -The RE_SUBSCRIPTION action tracker (71660) no longer exists in Impact.com. Removed the RE_SUBSCRIPTION event and -consolidated all paid KiloClaw invoice tracking under the SALE event (71659). The `Numeric1` month number field is no -longer sent. Both initial and renewal invoices now fire the same SALE conversion. - -### 2026-04-06 -- Clarify attribution-gated conversion events - -Error-handling rule 4 previously required sending conversion events for all users, even those without an affiliate -attribution record. Updated to clarify that conversion events MUST only be sent for users with an attribution record -(i.e., users who arrived via an affiliate link). Sending events for non-affiliate users inflates Impact conversion -volume with unattributable data. The click ID within the attribution record may still be empty/null — the attribution -record itself is the gate, not the click ID value. - -### 2026-04-09 -- Queue parent-child delivery by attributed association - -Updated the SIGNUP rule to trigger once per user/provider on the first attributed association rather than only on new -account creation. Added an invariant that child conversion events must not be sent before the parent SIGNUP event has -been successfully delivered. - -### 2026-04-09 -- Count pure-credit periods as sale events and exclude admin/org flows - -Clarified that SALE covers every monetized KiloClaw payment period, including pure-credit funding in addition to Stripe -invoice settlements. Explicitly excluded organization-scoped KiloClaw instances and admin-only subscription -interventions from affiliate tracking. - -### 2026-04-09 -- Delay child dispatch after SIGNUP delivery - -Added a required 5-minute gap between Impact SIGNUP delivery and dispatch of child conversion events. This gives -Impact.com time to process the parent event before TRIAL_START, TRIAL_END, or SALE requests arrive. - -### 2026-04-17 -- Reverse disputed Stripe-backed sales - -Added rules requiring full SALE reversals for Stripe disputes on personal KiloClaw subscriptions. Clarified that -reversals happen when `charge.dispute.created` arrives, won disputes do not auto-restore commission, and legacy sales -without stored Impact action mapping remain manual follow-up. diff --git a/.specs/kiloclaw-affiliates.md b/.specs/kiloclaw-affiliates.md new file mode 100644 index 0000000000..ef05a392af --- /dev/null +++ b/.specs/kiloclaw-affiliates.md @@ -0,0 +1,260 @@ +# Impact.com Affiliate Tracking + +## Role of This Document + +This spec defines business rules and invariants for Impact.com affiliate conversion tracking for KiloClaw +subscriptions. It is the source of truth for what the system must guarantee: tracked events, attribution capture, data +sent to Impact.com, and behavior when tracking infrastructure is unavailable. It does not prescribe implementation: +handler names, column layouts, retry strategies, and other implementation choices belong in plans and code. + +## Status + +Draft -- created 2026-03-31. +Updated 2026-04-01 -- aligned with revised Impact integration document and implementation review. +Updated 2026-04-06 -- clarify that conversion events require an affiliate attribution record. +Updated 2026-04-09 -- treat pure-credit KiloClaw periods as sale events and exclude admin/org flows. +Updated 2026-04-09 -- require a 5-minute delay after SIGNUP delivery before child dispatch. +Updated 2026-04-17 -- define dispute-triggered sale reversals. + +## Conventions + +BCP 14 [RFC 2119] [RFC 8174] keywords apply only when they appear in all capitals: "MUST", "MUST NOT", +"REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and +"OPTIONAL". + +## Definitions + +- **Impact.com**: Third-party affiliate tracking platform that attributes conversions to affiliate partners. +- **UTT (Universal Tracking Tag)**: Impact.com JavaScript snippet for client-side tracking and cross-domain identity + bridging. +- **Click ID**: Opaque tracking identifier (`im_ref` query parameter) that Impact.com appends to landing page URLs + when a visitor arrives via an affiliate tracking link. +- **Conversion**: Event reported to Impact.com's Conversions API for a meaningful customer lifecycle step: visit, + signup, trial, or subscription payment. +- **Lead event**: Conversion representing a visit or user signup. In Impact.com's parent-child model, SIGNUP is the + parent action. +- **Sale event**: Conversion representing a trial or subscription payment. In Impact.com's parent-child model, these + child actions link to the lead via the customer identifier. +- **Affiliate attribution**: Record associating a user with the affiliate tracking identifier that brought them to the + platform. +- **First-touch attribution**: Attribution model where only the first affiliate interaction per provider is recorded for + a user. +- **Affiliate provider**: Named affiliate tracking platform (e.g. `impact`). The system supports multiple providers, + each storing one attribution per user. + +## Overview + +Affiliate tracking lets Impact.com attribute KiloClaw conversions to referring partners. When a visitor arrives via an +affiliate tracking link, the system captures and persists the tracking identifier. As the visitor progresses through the +customer lifecycle -- signup, trial, subscription -- the system reports each stage to Impact.com as a conversion event, +including the tracking identifier and customer details needed for attribution. + +Architecture is hybrid: client-side UTT for cross-domain identity bridging, plus server-side API calls for reliable +conversion reporting resistant to ad blockers and browser tracking prevention. + +This integration applies only to personal KiloClaw subscriptions. Organization-scoped KiloClaw instances are not +eligible for affiliate tracking. + +For KiloClaw conversions also governed by `.specs/kiloclaw-referrals.md`, that referral spec's conversion-time +referral-priority rules override this document's default first-touch affiliate behavior for the initial paid conversion +decision. This document remains authoritative for Impact Performance event shapes, delivery sequencing, and affiliate +renewal reporting after the winning attribution is established. + +## Rules + +### Affiliate Attribution + +1. The system MUST support multiple affiliate providers identified by a provider enum. The initial provider is `impact`. + +2. The system MUST store at most one attribution per user per provider. + +3. When a user arrives with an affiliate tracking identifier (`im_ref` query parameter for Impact.com), the system MUST + persist the identifier before or during user creation. + +4. The system MUST preserve the tracking identifier across the authentication flow (e.g. through OAuth redirects) so it + is available after authentication. + +5. Attribution MUST use first-touch semantics: if a user already has an attribution record for a provider, subsequent + tracking identifiers for that provider MUST NOT overwrite it. + +6. The tracking identifier MUST be opaque. The system MUST NOT parse it, validate its format, or assign meaning to its + contents. + +7. When a user record is deleted (e.g. GDPR soft-delete), the system MUST delete all affiliate attribution records for + that user. + +### Conversion Events + +8. The system MUST report these conversion events to Impact.com, in customer lifecycle order: + + | Event | ActionTrackerId | Impact.com Type | Trigger | + | ----------- | --------------- | --------------- | --------------------------------------------- | + | VISIT | 71668 | Lead | Visitor lands on `kilo.ai` with `im_ref` | + | SIGNUP | 71655 | Lead | New user creation (with attribution) | + | TRIAL_START | 71656 | Sale | KiloClaw trial subscription becomes active | + | TRIAL_END | 71658 | Sale | KiloClaw trial subscription ends (any reason) | + | SALE | 71659 | Sale | Monetized KiloClaw payment period is funded | + +9. Each conversion event sent to Impact.com MUST include: + - Event timestamp + - Order identifier + - User affiliate tracking identifier, when available + - Stable customer identifier, when available + - Customer email address, SHA-1 hashed, when available + +10. VISIT events MUST include only `EventDate`, `ClickId`, and `OrderId`. VISIT events MUST NOT include `CustomerId`, + `CustomerEmail`, `IpAddress`, or `CustomerStatus`. + +11. VISIT events MUST fire on the marketing site (`kilo.ai`) before a user account exists. VISIT events MUST NOT create + a `user_affiliate_attributions` row. + +12. When no meaningful internal order identifier is available, the system MUST send `IR_AN_64_TS` as `OrderId`. + Impact.com generates a unique alphanumeric order identifier from this macro. This applies to VISIT, SIGNUP, + TRIAL_START, and TRIAL_END events. These generated identifiers MUST NOT be used for internal reconciliation. + +13. SIGNUP and TRIAL_START events MUST include `ClickId` alongside `CustomerId` as an attribution fallback. This covers + child events processed before the parent SIGNUP event finishes processing. For later sale events, including + `ClickId` is RECOMMENDED but not REQUIRED. + +14. VISIT events MUST NOT include `CustomerId` because the user does not yet exist. + +15. SALE events MUST include the monetized amount and currency for the funded KiloClaw period. + +16. SALE events MUST include the subscription plan identifier (e.g. `kiloclaw-standard`, `kiloclaw-commit`) as the item + category. + +17. SALE events MUST be reported for every monetized KiloClaw payment period (initial and renewal), including Stripe + invoice settlements and pure-credit deductions. + +18. Conversion events SHOULD include a promo code when one was applied to the transaction. + +19. The SIGNUP event MUST be sent at most once per user per provider, on that user's first attributed association for + the provider. This MAY occur during new user creation or a later sign-in when an existing user first gains affiliate + attribution. + +20. Child conversion events (TRIAL_START, TRIAL_END, SALE) MUST NOT be sent before the parent SIGNUP event has been + successfully delivered. For Impact.com, child conversion events MUST NOT be dispatched until at least 5 minutes + after SIGNUP delivery. + +21. Admin-only subscription interventions (for example admin trial resets, admin cancellations, or manual trial-date + edits) MUST NOT emit affiliate conversion events. These are internal overrides, not customer lifecycle events. + +22. When a Stripe-backed personal KiloClaw SALE later receives a `charge.dispute.created` event, the system MUST submit + a reversal for the full associated Impact.com commission. + +23. Partial Stripe disputes MUST still reverse the full associated Impact.com commission. + +24. The system MUST trigger the reversal on `charge.dispute.created`. The system MUST NOT automatically restore the + commission later if the dispute is resolved in the brand's favor. + +25. Automatic reversal is only guaranteed for SALE events created after rollout that persisted an Impact action mapping + for the disputed Stripe charge. Earlier SALE events without stored mapping are out of scope for automatic reversal + and require manual follow-up. + +### Client-Side Tracking (UTT) + +22. The system MUST load the Impact.com UTT script on all pages when the UTT identifier is configured. + +23. The system MUST NOT load the UTT script when the UTT identifier is not configured. + +24. After a user authenticates, the system MUST call the UTT `identify` function with the user's internal ID and SHA-1 + hashed email to enable cross-device attribution. + +### Reliability and Isolation + +25. Conversion reporting MUST NOT block or delay the primary operation it is attached to (user creation, subscription + settlement, etc.). Failures in conversion reporting MUST be handled asynchronously. + +26. If Impact.com credentials are not configured, all tracking operations MUST be no-ops. The application MUST function + normally without Impact.com configuration. + +27. The system SHOULD retry conversion API calls that receive a server error (5xx) response. + +28. The system MUST log conversion reporting failures for observability. + +### Rewardful Removal + +29. The existing Rewardful integration MUST be fully removed, including the client-side script, server-side cookie + reading, and checkout session metadata populated by Rewardful. + +### Checkout Metadata + +30. The KiloClaw checkout session MUST include the user's affiliate tracking identifier (if any) in Stripe subscription + metadata, so webhook handlers can access it without a database lookup. + +### API Contract + +31. Conversion API requests MUST use JSON request bodies, not form-encoded bodies. + +32. Conversion API requests MUST use `ActionTrackerId` to identify the configured event, not `EventTypeId`. + +### Reference Values + +33. The implementation MUST treat these program identifiers as configuration constants for this integration: + - CampaignId: `50754` + - UTT UUID: `A7138521-9724-4b8f-95f4-1db2fbae81141` + - ActionTrackerIds: `71655`, `71656`, `71658`, `71659`, `71668` + +## Error Handling + +1. When a conversion API call fails with a client error (4xx), the system MUST log the error and MUST NOT retry. + +2. When a conversion API call fails with a server error (5xx), the system SHOULD retry with backoff. + +3. When a conversion API call fails for any reason, the primary operation (user creation, invoice settlement, etc.) MUST + NOT be affected. + +4. Conversion events (SIGNUP, TRIAL_START, TRIAL_END, SALE) MUST only be sent for users with an affiliate attribution + record. Users who did not arrive via an affiliate link MUST NOT generate conversion events. When an attribution + record exists but its stored click ID is empty or null, the event MUST still be sent with an empty or null click ID. + +## Changelog + +### 2026-03-31 -- Initial spec + +### 2026-03-31 -- Rename SUBSCRIPTION_START to SALE + +Renamed SUBSCRIPTION_START to SALE because it covers all KiloClaw payments (initial purchase and renewals), not just +subscription creation. Clarified that SALE events fire for every paid invoice. + +### 2026-04-01 -- Align spec with revised Impact integration guide + +Added VISIT and RE_SUBSCRIPTION events; switched API terminology to `ActionTrackerId`; documented JSON request bodies; +clarified `IR_AN_64_TS` order ID usage; required `ClickId` fallback on early events; added `Numeric1` month tracking +for renewals; recorded the concrete Campaign/UTT/ActionTracker identifiers from the latest implementation guide. + +### 2026-04-02 -- Remove RE_SUBSCRIPTION event, use SALE for all paid invoices + +The RE_SUBSCRIPTION action tracker (71660) no longer exists in Impact.com. Removed RE_SUBSCRIPTION and consolidated all +paid KiloClaw invoice tracking under SALE (71659). The `Numeric1` month number field is no longer sent. Initial and +renewal invoices now fire the same SALE conversion. + +### 2026-04-06 -- Clarify attribution-gated conversion events + +Error-handling rule 4 previously required sending conversion events for all users, including those without an affiliate +attribution record. Updated it to require conversion events only for users with an attribution record (i.e. users who +arrived via an affiliate link). Sending events for non-affiliate users inflates Impact conversion volume with +unattributable data. The click ID within the attribution record may still be empty/null; the attribution record itself +is the gate, not the click ID value. + +### 2026-04-09 -- Queue parent-child delivery by attributed association + +Updated the SIGNUP rule to trigger once per user/provider on the first attributed association, not only on new account +creation. Added an invariant that child conversion events must not be sent before successful parent SIGNUP delivery. + +### 2026-04-09 -- Count pure-credit periods as sale events and exclude admin/org flows + +Clarified that SALE covers every monetized KiloClaw payment period, including pure-credit funding and Stripe invoice +settlements. Explicitly excluded organization-scoped KiloClaw instances and admin-only subscription interventions from +affiliate tracking. + +### 2026-04-09 -- Delay child dispatch after SIGNUP delivery + +Added a required 5-minute gap between Impact SIGNUP delivery and child conversion event dispatch, giving Impact.com +time to process the parent event before TRIAL_START, TRIAL_END, or SALE requests arrive. + +### 2026-04-17 -- Reverse disputed Stripe-backed sales + +Added rules requiring full SALE reversals for Stripe disputes on personal KiloClaw subscriptions. Clarified that +reversals happen when `charge.dispute.created` arrives, won disputes do not auto-restore commission, and legacy sales +without stored Impact action mapping require manual follow-up. diff --git a/.specs/kiloclaw-referrals.md b/.specs/kiloclaw-referrals.md index ae855ec829..5f7ce44c6d 100644 --- a/.specs/kiloclaw-referrals.md +++ b/.specs/kiloclaw-referrals.md @@ -2,78 +2,77 @@ ## Role of This Document -This spec defines the business rules and invariants for the KiloClaw referral program powered by Impact Advocate. It is -the source of truth for _what_ the system must guarantee -- who is eligible, how referral attribution competes with -affiliate attribution, when referral conversions occur, how rewards are granted and fulfilled, and how the system behaves -when Impact Advocate or billing integrations are unavailable. It deliberately does not prescribe _how_ to implement those -guarantees: handler names, column layouts, retry strategies, and other implementation choices belong in plan documents -and code, not here. +This spec defines KiloClaw referral program business rules and invariants powered by Impact Advocate. It is the source +of truth for _what_ the system must guarantee: eligibility, referral/affiliate attribution conflict resolution, referral +conversion timing, reward granting/fulfillment, and behavior when Impact Advocate or billing integrations are +unavailable. It does not prescribe _how_ to implement those guarantees; handler names, column layouts, retry strategies, +and other implementation details belong in plans and code. ## Status -Draft -- created 2026-04-21. -Updated 2026-05-06 -- require Impact Advocate reward redemption after local reward application. +Draft -- created 2026-04-21. Updated 2026-05-06 -- require Impact Advocate reward redemption after local reward +application. ## Conventions -The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", -"NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC 2119] -[RFC 8174] when, and only when, they appear in all capitals, as shown here. +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT +RECOMMENDED", "MAY", and "OPTIONAL" are interpreted as described in BCP 14 [RFC 2119] [RFC 8174] only when they appear +in all capitals, as shown here. ## Definitions -- **Impact Advocate**: The Impact.com referral product used to generate share links, register referral participants, +- **Impact Advocate**: Impact.com referral product used to generate share links, register referral participants, attribute referred users, and report referral lifecycle and reward events. -- **Impact Performance Program**: The existing Impact.com affiliate/conversion program for KiloClaw, CampaignId `50754`. -- **Advocate Program**: The Impact Advocate referral program for KiloClaw, ProgramId `51699`. -- **UTT (Universal Tracking Tag)**: A JavaScript snippet provided by Impact.com that enables client-side tracking, - first-party cookies, and identity bridging. -- **Advocate widget**: The Impact Verified Access in-app widget `p/51699/w/referrerWidget` used by logged-in users to - access referral share links and referral status. -- **Referrer**: An existing user who shares a referral link and may earn a referral reward when an eligible referee +- **Impact Performance Program**: Existing Impact.com affiliate/conversion program for KiloClaw, CampaignId `50754`. +- **Advocate Program**: Impact Advocate referral program for KiloClaw, ProgramId `51699`. +- **UTT (Universal Tracking Tag)**: Impact.com JavaScript snippet that enables client-side tracking, first-party cookies, + and identity bridging. +- **Advocate widget**: Impact Verified Access in-app widget `p/51699/w/referrerWidget` for logged-in users to access + referral share links and referral status. +- **Referrer**: Existing user who shares a referral link and may earn a referral reward when an eligible referee converts. -- **Referee**: A referred user who arrives through a referral link, creates a Kilo account, and may earn a referral - reward after their first eligible paid KiloClaw conversion. -- **Referral touch**: A captured Impact Advocate attribution interaction, including `_saasquatch` and related referral +- **Referee**: Referred user who arrives through a referral link, creates a Kilo account, and may earn a referral reward + after their first eligible paid KiloClaw conversion. +- **Referral touch**: Captured Impact Advocate attribution interaction, including `_saasquatch` and related referral parameters or cookies. The value is opaque to Kilo. -- **Valid referral touch**: A referral touch with a non-empty `_saasquatch` value, associated with the converting user's +- **Valid referral touch**: Referral touch with a non-empty `_saasquatch` value, associated with the converting user's pre-signup session or user record, where `conversion_time < touched_at + 30 * 24 hours` using server UTC timestamps. -- **Affiliate touch**: A captured Impact affiliate interaction, including the `im_ref` click identifier. The value is +- **Affiliate touch**: Captured Impact affiliate interaction, including the `im_ref` click identifier. The value is opaque to Kilo. -- **Sale-attributed affiliate touch**: An affiliate touch already used to report a SALE conversion to Impact. This - protects the initial SALE and subsequent KiloClaw renewals from referral override, so an affiliate who already earned - SALE attribution continues to receive affiliate renewal attribution under the affiliate tracking spec. -- **Attribution touch**: Either a referral touch or affiliate touch considered by KiloClaw conversion-time attribution +- **Sale-attributed affiliate touch**: Affiliate touch already used to report a SALE conversion to Impact. This protects + the initial SALE and subsequent KiloClaw renewals from referral override, so an affiliate who already earned SALE + attribution continues receiving affiliate renewal attribution under the affiliate tracking spec. +- **Attribution touch**: Referral touch or affiliate touch considered by KiloClaw conversion-time attribution resolution. -- **Valid touch**: An attribution touch that has not expired, belongs to the converting user or their pre-signup session, +- **Valid touch**: Attribution touch that has not expired, belongs to the converting user or their pre-signup session, and is eligible for the conversion being evaluated. -- **Referral-priority attribution**: The attribution model for KiloClaw referral/affiliate conflict resolution: at - conversion time, a valid referral touch wins over an affiliate touch unless that affiliate touch has already been - sale-attributed. -- **First paid KiloClaw conversion**: The referee's first confirmed paid personal KiloClaw subscription payment period, - whether funded by Stripe settlement, hybrid settlement, or pure-credit deduction. Trial start does not qualify, nor does a purchase of inference / credits. -- **Monetized KiloClaw payment period**: A KiloClaw billing period with positive Stripe-settled value, positive hybrid +- **Referral-priority attribution**: KiloClaw referral/affiliate conflict-resolution model: at conversion time, a valid + referral touch wins over an affiliate touch unless that affiliate touch has already been sale-attributed. +- **First paid KiloClaw conversion**: Referee's first confirmed paid personal KiloClaw subscription payment period, + whether funded by Stripe settlement, hybrid settlement, or pure-credit deduction. Trial start does not qualify, nor + does a purchase of inference / credits. +- **Monetized KiloClaw payment period**: KiloClaw billing period with positive Stripe-settled value, positive hybrid settled value, or positive credit deduction. Zero-dollar invoices, fully comped periods, and admin adjustments are not monetized payment periods. -- **Free-month reward**: A local KiloClaw billing reward that delays the beneficiary's next KiloClaw renewal by one +- **Free-month reward**: Local KiloClaw billing reward that delays the beneficiary's next KiloClaw renewal by one calendar month. It is not a general account credit. -- **Calendar month**: A billing-period extension that preserves the day-of-month semantics of the current KiloClaw billing +- **Calendar month**: Billing-period extension that preserves day-of-month semantics of the current KiloClaw billing calendar, clamping to the last valid day of the target month when necessary. -- **Reward beneficiary**: A user who may receive a free-month reward. Beneficiary roles are `referrer` and `referee`. -- **Reward state**: A durable lifecycle state for a reward. Required states are `pending`, `earned`, `applied`, +- **Reward beneficiary**: User who may receive a free-month reward. Beneficiary roles are `referrer` and `referee`. +- **Reward state**: Durable lifecycle state for a reward. Required states are `pending`, `earned`, `applied`, `reversed`, `expired`, `canceled`, and `review_required`. -- **Active eligible personal KiloClaw subscription**: A personal KiloClaw subscription that is active, not canceling at +- **Active eligible personal KiloClaw subscription**: Personal KiloClaw subscription that is active, not canceling at period end, not suspended, and not past due. -- **Personal KiloClaw subscription**: A KiloClaw subscription owned by an individual user. Organization/team-scoped +- **Personal KiloClaw subscription**: KiloClaw subscription owned by an individual user. Organization/team-scoped KiloClaw subscriptions are not eligible. -- **Brand-new Kilo account**: A user identity with no current or historical Kilo user identity under the configured +- **Brand-new Kilo account**: User identity with no current or historical Kilo user identity under the configured identity key before the referral touch. Adding an auth provider to an existing user is not brand-new. -- **Reward-bearing referral configuration**: The environment configuration required to create referral touches, register +- **Reward-bearing referral configuration**: Environment configuration required to create referral touches, register Advocate participants, report Impact conversions, grant local rewards, and apply KiloClaw billing extensions. -- **Chargeback**: A Stripe dispute event for the qualifying Stripe payment. -- **Fraud-marked payment**: A qualifying payment marked fraudulent by Stripe, an internal fraud process, or an authorized +- **Chargeback**: Stripe dispute event for the qualifying Stripe payment. +- **Fraud-marked payment**: Qualifying payment marked fraudulent by Stripe, an internal fraud process, or an authorized operator. -- **Support review**: A durable `review_required` reward state with the triggering reason, affected billing period, and +- **Support review**: Durable `review_required` reward state with the triggering reason, affected billing period, and source payment or dispute recorded. Kilo team review is required before an already-applied reward can be canceled, clawed back, or otherwise adjusted. - **Impact-facing status field**: Local status retained only to compare Kilo state with Impact dashboard exports or API @@ -81,18 +80,18 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S ## Overview -The KiloClaw referral program is a double-sided program: when an eligible existing user refers an eligible new KiloClaw -paying subscriber, the referrer and referee each earn one free KiloClaw month. A reward is earned only after the referee's first -confirmed paid personal KiloClaw subscription payment. The reward is fulfilled by delaying the beneficiary's next -KiloClaw renewal by one calendar month. +The KiloClaw referral program is double-sided: when an eligible existing user refers an eligible new KiloClaw paying +subscriber, referrer and referee each earn one free KiloClaw month. A reward is earned only after the referee's first +confirmed paid personal KiloClaw subscription payment and is fulfilled by delaying the beneficiary's next KiloClaw +renewal by one calendar month. -Impact Advocate owns the referral sharing experience, share links, referral cookies, participant registration, and -Advocate program reporting. Impact may mirror referral priority and reward settings for reporting, but Kilo owns the -authoritative product eligibility, affiliate/referral attribution conflict resolution, first-paid-conversion detection, -reward grant idempotency, reward caps, and billing fulfillment. +Impact Advocate owns referral sharing, share links, referral cookies, participant registration, and Advocate program +reporting. Impact may mirror referral priority and reward settings for reporting, but Kilo owns authoritative product +eligibility, affiliate/referral attribution conflict resolution, first-paid-conversion detection, reward grant +idempotency, reward caps, and billing fulfillment. -Impact Advocate conversion state is driven through the existing Impact Performance Program conversion events. The system -uses `Sale (71659)` as the paid-conversion event for paid KiloClaw periods, including renewals. +Existing Impact Performance Program conversion events drive Impact Advocate conversion state. The system uses +`Sale (71659)` as the paid-conversion event for paid KiloClaw periods, including renewals. This program applies only to personal KiloClaw subscriptions. Organization-scoped KiloClaw instances, team plans, admin interventions, and non-KiloClaw purchases are out of scope. @@ -101,14 +100,14 @@ interventions, and non-KiloClaw purchases are out of scope. ### Program Configuration -1. The system MUST treat the following identifiers as configuration constants for this integration: +1. The system MUST treat these identifiers as integration configuration constants: - Impact Account: `7138521` - Impact Performance CampaignId: `50754` - Impact Advocate ProgramId: `51699` - UTT UUID: `A7138521-9724-4b8f-95f4-1db2fbae81141` - Advocate widget ID: `p/51699/w/referrerWidget` -2. The system MUST use the existing Impact Performance conversion action tracker IDs for KiloClaw lifecycle reporting: +2. The system MUST use existing Impact Performance conversion action tracker IDs for KiloClaw lifecycle reporting: | Event | ActionTrackerId | Trigger | | ----------- | --------------- | --------------------------------------------- | @@ -118,14 +117,15 @@ interventions, and non-KiloClaw purchases are out of scope. | TRIAL_END | 71658 | KiloClaw trial subscription ends (any reason) | | SALE | 71659 | Monetized KiloClaw payment period is funded | -3. The system MUST keep Impact Advocate API credentials server-side. Credentials MUST NOT be exposed to the browser. +3. Impact Advocate API credentials MUST remain server-side and MUST NOT be exposed to the browser. 4. If Impact Advocate configuration is absent, referral sharing, participant registration, and Impact reconciliation MAY be disabled, but the application MUST continue to function normally. -5. If reward-bearing referral configuration is absent in an environment where the referral program is enabled, the system - MUST fail closed for reward issuance and MUST log the configuration failure. It MUST NOT silently mark rewards or - Impact work as completed. +5. If reward-bearing referral configuration is absent in an environment where the referral program is enabled: + - the system MUST fail closed for reward issuance; + - the system MUST log the configuration failure; and + - the system MUST NOT silently mark rewards or Impact work as completed. 6. Referral UTT loading is controlled by the application's public Impact UTT configuration for the active environment. @@ -142,16 +142,16 @@ interventions, and non-KiloClaw purchases are out of scope. 10. The system MUST NOT allow users to alter the identity payload used to establish Advocate identity. -11. The system MUST register every Kilo user who is issued an Impact Advocate Verified Access token as a participant in - the Advocate program server-side, even when the user has no inbound referral attribution. This MUST happen no later - than the first issuance of the token for that user. Registration MUST be idempotent across repeat issuances and MUST - persist the SaaSquatch-issued referral code per rule 51, so the user becomes resolvable as the referrer when their - referees later convert. +11. The system MUST register every Kilo user issued an Impact Advocate Verified Access token as a participant in the + Advocate program server-side, even when the user has no inbound referral attribution. This MUST happen no later than + the first token issuance for that user. Registration MUST be idempotent across repeat issuances and MUST persist the + SaaSquatch-issued referral code per rule 51, so the user becomes resolvable as the referrer when their referees + later convert. ### Client-Side Tracking and Identity 12. The system MUST load the Impact UTT script on pages used by the referral program when the UTT identifier is - configured, and MUST NOT load it when the UTT identifier is not configured. + configured and MUST NOT load it otherwise. 13. The system MUST invoke Impact `identify` on pages used by the referral program. @@ -183,9 +183,8 @@ interventions, and non-KiloClaw purchases are out of scope. 22. Referral touch capture MUST preserve attribution across the authentication flow, including OAuth redirects and callback URLs. -23. Referral touches MUST expire 30 days after the touch time. A touch is valid only when - `conversion_time < touched_at + 30 * 24 hours`, using server UTC timestamps. A touch at or after that instant is - expired. +23. Referral touches MUST expire 30 days after the touch time. A touch is valid only when `conversion_time < touched_at + - 30 \* 24 hours`, using server UTC timestamps. A touch at or after that instant is expired. 24. The system MUST associate pre-signup referral touches with the created user during signup or first authenticated request after signup. @@ -201,19 +200,19 @@ interventions, and non-KiloClaw purchases are out of scope. 28. At first paid KiloClaw conversion time, the system MUST evaluate valid affiliate and referral touches together. -29. For KiloClaw conversions governed by this referral spec, this spec's referral-priority attribution overrides the - permanent first-touch affiliate attribution rules in `.specs/impact-affiliate-tracking.md`. +29. For KiloClaw conversions governed by this referral spec, referral-priority attribution overrides the permanent + first-touch affiliate attribution rules in `.specs/impact-affiliate-tracking.md`. 30. A valid referral touch MUST win over a valid affiliate touch unless the affiliate touch has already been - sale-attributed before the referral touch occurred. Initial attribution for a not-yet-attributed SALE MUST prefer the - valid referral touch. + sale-attributed before the referral touch occurred. Initial attribution for a not-yet-attributed SALE MUST prefer + the valid referral touch. -31. A sale-attributed affiliate touch MUST keep affiliate attribution for the initial SALE and subsequent KiloClaw renewals - only when that initial SALE occurred before the referral touch. Referral touches MUST NOT retroactively override those - affiliate-attributed SALE events. +31. A sale-attributed affiliate touch MUST keep affiliate attribution for the initial SALE and subsequent KiloClaw + renewals only when that initial SALE occurred before the referral touch. Referral touches MUST NOT retroactively + override those affiliate-attributed SALE events. -32. If multiple valid referral touches exist and no sale-attributed affiliate touch is present, the oldest valid referral - touch MUST win. +32. If multiple valid referral touches exist and no sale-attributed affiliate touch is present, the oldest valid + referral touch MUST win. 33. If no valid referral touch exists, the oldest valid affiliate touch MUST win. @@ -221,13 +220,13 @@ interventions, and non-KiloClaw purchases are out of scope. 35. If an affiliate touch wins, the system MUST NOT grant referral rewards for that conversion. -36. If a referral touch wins, the system MUST NOT attribute that first paid KiloClaw conversion to an affiliate for reward - or payout purposes. +36. If a referral touch wins, the system MUST NOT attribute that first paid KiloClaw conversion to an affiliate for + reward or payout purposes. -37. The system MUST record when an affiliate touch has been attributed to a SALE conversion so affiliate attribution can be - preserved for that initial sale and subsequent KiloClaw renewals. +37. The system MUST record when an affiliate touch has been attributed to a SALE conversion to preserve affiliate + attribution for that initial sale and subsequent KiloClaw renewals. -38. The system MUST implement at least the following attribution outcomes. +38. The system MUST implement at least these attribution outcomes. | Scenario | Expected winner | | ---------------------------------------------------------------------------- | --------------- | @@ -268,14 +267,12 @@ interventions, and non-KiloClaw purchases are out of scope. 50. Register Participant requests MUST include plain-text email only as the Advocate contact email. -51. On a successful Register Participant response, the system MUST persist the program-scoped - referral code returned in `referralCodes[]` against the participant record so - inbound referral touches can resolve the originating Advocate user. Persistence MUST be - idempotent: re-running registration for the same participant MUST NOT corrupt or duplicate the - code. If another participant already holds the same code (vanishingly unlikely under - SaaSquatch's per-tenant uniqueness guarantee, but constraint-protected on the Kilo side), the - new participant's code MUST NOT be persisted; the rest of the registration success state MUST - still be recorded. +51. On a successful Register Participant response, the system MUST persist the program-scoped referral code returned in + `referralCodes[]` against the participant record so inbound referral touches can resolve the originating + Advocate user. Persistence MUST be idempotent: re-running registration for the same participant MUST NOT corrupt or + duplicate the code. If another participant already holds the same code (vanishingly unlikely under SaaSquatch's + per-tenant uniqueness guarantee, but constraint-protected on the Kilo side), the new participant's code MUST NOT be + persisted; the rest of the registration success state MUST still be recorded. ### Referee Eligibility @@ -285,8 +282,8 @@ interventions, and non-KiloClaw purchases are out of scope. 54. Adding an auth provider to an existing Kilo user MUST NOT qualify as a brand-new Kilo account. -55. Previously deleted users MUST NOT qualify as referees. Previously deleted user disqualification MUST use a - legal-approved normalized-email hash tombstone. +55. Previously deleted users MUST NOT qualify as referees. Disqualification MUST use a legal-approved normalized-email + hash tombstone. 56. A referee MUST convert on a personal KiloClaw subscription. Team plans, organization-scoped KiloClaw subscriptions, and non-KiloClaw subscriptions MUST NOT qualify. @@ -312,22 +309,22 @@ interventions, and non-KiloClaw purchases are out of scope. 64. A referrer's current KiloClaw subscription state MUST NOT prevent reward earning. -65. If a referrer has no active eligible personal KiloClaw subscription when the reward is earned, the system MUST keep the - reward pending so it can be applied when the referrer starts or reactivates an eligible personal KiloClaw +65. If a referrer has no active eligible personal KiloClaw subscription when the reward is earned, the system MUST keep + the reward pending so it can be applied when the referrer starts or reactivates an eligible personal KiloClaw subscription. 66. A pending inactive-referrer reward MUST expire and be canceled 12 months after it is earned if the referrer has not started or reactivated an eligible paid personal KiloClaw subscription. -67. A pending referrer reward MUST NOT apply to a KiloClaw trial. It MUST apply to the next unpaid renewal boundary after - the referrer starts or reactivates a paid personal KiloClaw subscription. +67. A pending referrer reward MUST NOT apply to a KiloClaw trial. It MUST apply to the next unpaid renewal boundary + after the referrer starts or reactivates a paid personal KiloClaw subscription. 68. A referrer MUST NOT receive more than 12 total free-month rewards from the referral program. 69. The referrer cap MUST be enforced before granting a referrer reward. -70. The 12-month referrer cap MUST be enforced atomically across concurrent reward grants. Concurrent processing MUST NOT - produce more than 12 granted referrer reward months. +70. The 12-month referrer cap MUST be enforced atomically across concurrent reward grants. Concurrent processing MUST + NOT produce more than 12 granted referrer reward months. 71. When a qualified referral occurs after the referrer has reached the 12-month cap, the system MUST record that the referrer reward was cap-limited and MUST NOT grant another referrer free month. @@ -342,8 +339,8 @@ interventions, and non-KiloClaw purchases are out of scope. cap-limited instead of granted when the referrer cap has been reached or another referrer eligibility rule prevents it. -75. Referral reward granting MUST be idempotent. Processing the same qualifying conversion multiple times MUST NOT create - duplicate rewards for the same beneficiary role. +75. Referral reward granting MUST be idempotent. Processing the same qualifying conversion multiple times MUST NOT + create duplicate rewards for the same beneficiary role. 76. For a qualified referral, reward grant processing MUST be atomic across both beneficiary reward decisions. Both beneficiary outcomes MUST be recorded together, including granted, cap-limited, and disqualified outcomes. @@ -354,7 +351,7 @@ interventions, and non-KiloClaw purchases are out of scope. 78. Reward records MUST support the reward states defined in this spec. 79. A reward MUST NOT be considered fulfilled until KiloClaw billing state and any required Stripe state have been - successfully updated so the corresponding KiloClaw renewal is delayed. + updated to delay the corresponding KiloClaw renewal. 80. Impact Advocate reward state MAY be used for reconciliation, support, or reporting. It MUST NOT be the source of truth for local free-month fulfillment. @@ -368,8 +365,8 @@ interventions, and non-KiloClaw purchases are out of scope. 83. Free-month rewards MUST NOT be fulfilled as general account credits. -84. Free-month rewards MUST apply to KiloClaw billing only. They MUST NOT apply to inference usage, Kilo Pass, team plans, - or non-KiloClaw purchases. +84. Free-month rewards MUST apply to KiloClaw billing only. They MUST NOT apply to inference usage, Kilo Pass, team + plans, or non-KiloClaw purchases. 85. Multiple free-month rewards MAY stack. Each applied reward MUST delay renewal by exactly one calendar month. @@ -379,8 +376,8 @@ interventions, and non-KiloClaw purchases are out of scope. month. The reward MUST NOT convert the subscription to month-to-month and MUST NOT reduce the next invoice by one sixth. -88. For pure-credit KiloClaw subscriptions, reward application MUST update local renewal state so the credit renewal sweep - does not deduct KiloClaw hosting credits until the extended renewal time. +88. For pure-credit KiloClaw subscriptions, reward application MUST update local renewal state so the credit renewal + sweep does not deduct KiloClaw hosting credits until the extended renewal time. 89. For Stripe-funded or hybrid KiloClaw subscriptions, reward application MUST keep local billing state and Stripe billing state consistent. The system MUST NOT create a local-only renewal delay for a Stripe-funded subscription @@ -392,8 +389,9 @@ interventions, and non-KiloClaw purchases are out of scope. 91. Reward application MUST record an audit trail containing the reward, beneficiary, affected subscription, previous renewal or period boundary, new renewal or period boundary, and any external billing operation identifiers. -92. Reward application MUST NOT break existing KiloClaw billing invariants for trials, pure-credit renewal, hybrid invoice - settlement, commit plans, plan switching, cancellation, reactivation, past-due recovery, suspension, or destruction. +92. Reward application MUST NOT break existing KiloClaw billing invariants for trials, pure-credit renewal, hybrid + invoice settlement, commit plans, plan switching, cancellation, reactivation, past-due recovery, suspension, or + destruction. 93. Reward application MUST respect cancellation state. If a subscription is canceled or canceling before reward application, the reward MUST remain pending until the beneficiary has an active eligible personal KiloClaw @@ -401,7 +399,7 @@ interventions, and non-KiloClaw purchases are out of scope. ### Impact Conversion Reporting -94. Impact Advocate referral conversion MUST be driven by the existing Impact Performance conversion events. +94. Impact Advocate referral conversion MUST be driven by existing Impact Performance conversion events. 95. `Sale (71659)` MUST be the paid KiloClaw conversion event used for referral conversion and renewal reporting. @@ -411,17 +409,17 @@ interventions, and non-KiloClaw purchases are out of scope. 97. When a referral wins attribution and the first paid conversion qualifies, the system MUST ensure Impact receives the required Performance conversion data for Advocate conversion reporting. -98. Conversion reporting MUST use deterministic order identifiers where possible so retries do not create duplicate Impact - actions. +98. Conversion reporting MUST use deterministic order identifiers where possible so retries do not create duplicate + Impact actions. -99. Conversion reporting failures MUST NOT block billing settlement, reward ledger creation, or user access. Failures MUST - leave the conversion report in a retryable state until it succeeds, is superseded by a corrected payload, or is marked - permanently failed by an operator-visible terminal state. +99. Conversion reporting failures MUST NOT block billing settlement, reward ledger creation, or user access. Failures + MUST leave the conversion report in a retryable state until it succeeds, is superseded by a corrected payload, or is + marked permanently failed by an operator-visible terminal state. ### Impact Reconciliation -100. The system MUST NOT rely on Impact Advocate webhooks for referral eligibility, reward granting, billing fulfillment, - or reconciliation. +100. The system MUST NOT rely on Impact Advocate webhooks for referral eligibility, reward granting, billing + fulfillment, or reconciliation. 101. The system MAY use Impact dashboard exports or API reads for manual reconciliation and support investigations. @@ -438,8 +436,8 @@ interventions, and non-KiloClaw purchases are out of scope. automatically canceled or clawed back. 106. Rewards from refunded or fraud-marked payments MUST be canceled before application. Already-applied rewards from - refunded or fraud-marked payments MUST be marked for support review and MUST NOT be automatically canceled or clawed - back. + refunded or fraud-marked payments MUST be marked for support review and MUST NOT be automatically canceled or + clawed back. 107. If a qualifying Impact action must be reversed, the system SHOULD use Impact's reverse-action mechanism instead of creating an unrelated negative conversion. @@ -456,10 +454,11 @@ interventions, and non-KiloClaw purchases are out of scope. 111. Plain email stored for Impact Advocate compatibility MUST be deleted or anonymized during GDPR deletion. -112. Previously deleted user disqualification MUST use a legal-approved non-PII tombstone or irreversible hash. The system - MUST NOT retain PII solely for this purpose. +112. Previously deleted user disqualification MUST use a legal-approved non-PII tombstone or irreversible hash. The + system MUST NOT retain PII solely for this purpose. -113. Referral tracking values MUST NOT be logged in a way that exposes secrets, auth headers, cookies, or unnecessary PII. +113. Referral tracking values MUST NOT be logged in a way that exposes secrets, auth headers, cookies, or unnecessary + PII. ### Reliability and Isolation @@ -469,28 +468,28 @@ interventions, and non-KiloClaw purchases are out of scope. 115. Reward ledger operations MUST be transactional where needed to prevent duplicate grants, partial grants, or missing audit records. -116. Reward fulfillment failures MUST leave rewards in a retryable state unless the failure is a permanent eligibility or - configuration failure. +116. Reward fulfillment failures MUST leave rewards in a retryable state unless the failure is a permanent eligibility + or configuration failure. -117. The system MUST expose enough operational state to distinguish pending Impact registration, pending Impact conversion - reporting, pending local reward application, applied rewards, reversed rewards, canceled rewards, review-required - rewards, and disqualified referrals. +117. The system MUST expose enough operational state to distinguish pending Impact registration, pending Impact + conversion reporting, pending local reward application, applied rewards, reversed rewards, canceled rewards, + review-required rewards, and disqualified referrals. 118. Admin-only subscription interventions, internal test conversions, and support adjustments MUST NOT emit referral rewards or Impact referral conversions unless explicitly marked as eligible by an authorized operator. ### Existing Internal Referral System -119. The existing internal referral-code system MUST NOT grant additional KiloClaw referral rewards for conversions already - governed by this spec. +119. The existing internal referral-code system MUST NOT grant additional KiloClaw referral rewards for conversions + already governed by this spec. 120. Before launch, the existing internal referral system MUST be scoped away from KiloClaw, disabled for KiloClaw, or migrated into this program's rules to prevent double rewards. ### Impact Reward Redemption -121. When a local free-month reward is applied to KiloClaw billing, the system MUST mark the corresponding Impact Advocate - credit reward as redeemed so Impact reporting matches Kilo's fulfillment state. +121. When a local free-month reward is applied to KiloClaw billing, the system MUST mark the corresponding Impact + Advocate credit reward as redeemed so Impact reporting matches Kilo's fulfillment state. 122. Impact Advocate reward redemption MUST happen asynchronously and MUST NOT block reward application, billing settlement, or user access. @@ -503,11 +502,11 @@ interventions, and non-KiloClaw purchases are out of scope. 125. Impact Advocate reward lookup and redemption attempts MUST be idempotently queued per local reward. -126. If the Impact reward is not yet visible when redemption is attempted, the system MUST leave the redemption work in a - retryable state. +126. If the Impact reward is not yet visible when redemption is attempted, the system MUST leave the redemption work in + a retryable state. -127. Impact reward redemption state is for reporting and reconciliation only. It MUST NOT be the source of truth for local - reward eligibility, application, cancellation, or reversal. +127. Impact reward redemption state is for reporting and reconciliation only. It MUST NOT be the source of truth for + local reward eligibility, application, cancellation, or reversal. ## Error Handling @@ -516,8 +515,8 @@ interventions, and non-KiloClaw purchases are out of scope. 2. If Register Participant delivery fails with a server error or timeout, the system MUST leave the registration in a retryable state. -3. If Register Participant delivery fails with a client error, the system MUST log the error and MUST NOT retry unchanged - payloads. +3. If Register Participant delivery fails with a client error, the system MUST log the error and MUST NOT retry + unchanged payloads. 4. If Impact conversion reporting fails with a server error or timeout, the system MUST leave the report in a retryable state. @@ -529,8 +528,8 @@ interventions, and non-KiloClaw purchases are out of scope. exceeded cap, or non-personal subscription, the system MUST record the disqualification reason when a referral record exists. -7. If reward application fails after a reward is earned, the reward MUST remain retryable unless the failure is permanent - and auditable. +7. If reward application fails after a reward is earned, the reward MUST remain retryable unless the failure is + permanent and auditable. 8. If required billing state is ambiguous, the system MUST NOT apply a reward. It MUST leave the reward pending and log the ambiguity for investigation. @@ -554,7 +553,7 @@ redeemed rewards. Created source-of-truth rules for the KiloClaw referral program using Impact Advocate. Defined program identifiers, Advocate widget and participant registration requirements, referral-priority attribution over affiliate attribution, exact 30-day UTC expiration semantics, brand-new and previously deleted user boundaries, first-paid monetized KiloClaw -conversion, double-sided free-month rewards, referrer 12-month cap, atomic reward decisions, pending rewards for inactive -referrers, next-unpaid-renewal reward application, app-owned billing fulfillment, Impact reconciliation behavior, no -Advocate webhook reliance, retryable failure states, tracking-value limits, support-review state, GDPR handling, Impact -identity mapping, and Stripe chargeback reward cancellation. +conversion, double-sided free-month rewards, referrer 12-month cap, atomic reward decisions, pending rewards for +inactive referrers, next-unpaid-renewal reward application, app-owned billing fulfillment, Impact reconciliation +behavior, no Advocate webhook reliance, retryable failure states, tracking-value limits, support-review state, GDPR +handling, Impact identity mapping, and Stripe chargeback reward cancellation. From d889acf5493fff3e89350ae37f700a1952530e8a Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 7 May 2026 13:33:43 +0200 Subject: [PATCH 29/32] fix(referrals): address review feedback --- .specs/kiloclaw-referrals.md | 2 +- .../billing/ReferralRewardStatusCard.test.ts | 77 +------- .../billing/ReferralRewardsSummary.test.ts | 34 +--- .../KiloclawReferralsInvestigation.test.ts | 178 ++++++++---------- .../billing-side-effects/route.test.ts | 49 ++++- 5 files changed, 149 insertions(+), 191 deletions(-) diff --git a/.specs/kiloclaw-referrals.md b/.specs/kiloclaw-referrals.md index 5f7ce44c6d..8a96f62482 100644 --- a/.specs/kiloclaw-referrals.md +++ b/.specs/kiloclaw-referrals.md @@ -201,7 +201,7 @@ interventions, and non-KiloClaw purchases are out of scope. 28. At first paid KiloClaw conversion time, the system MUST evaluate valid affiliate and referral touches together. 29. For KiloClaw conversions governed by this referral spec, referral-priority attribution overrides the permanent - first-touch affiliate attribution rules in `.specs/impact-affiliate-tracking.md`. + first-touch affiliate attribution rules in `.specs/kiloclaw-affiliates.md`. 30. A valid referral touch MUST win over a valid affiliate touch unless the affiliate touch has already been sale-attributed before the referral touch occurred. Initial attribution for a not-yet-attributed SALE MUST prefer diff --git a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.test.ts b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.test.ts index 1f63d53438..caff4dcb7e 100644 --- a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.test.ts +++ b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardStatusCard.test.ts @@ -19,31 +19,19 @@ const emptySummary = { }; describe('ReferralRewardStatusCard', () => { - it('renders program guidance and an empty my rewards state', () => { + it('renders the empty state with a referral-share CTA and no warning state', () => { const html = renderToStaticMarkup( React.createElement(ReferralRewardStatusCard, { summary: emptySummary }) ); - expect(html).toContain('Earn a free month of KiloClaw hosting'); - expect(html).toContain('Earned rewards'); - expect(html).toContain('Total rewards earned'); - expect(html).toContain('Rewards on hold'); - expect(html).toContain('Rewards applied'); - expect(html).toContain('Share your referral link'); + expect(html).toContain('No referral rewards yet.'); expect(html).toContain('href="#referral-share"'); + expect(html).not.toContain('data-testid="summary-indicator-warning"'); expect(html).not.toContain('credits'); expect(html).not.toContain('awards'); }); - it('does not render the warning indicator dot when no rewards are on hold', () => { - const html = renderToStaticMarkup( - React.createElement(ReferralRewardStatusCard, { summary: emptySummary }) - ); - - expect(html).not.toContain('data-testid="summary-indicator-warning"'); - }); - - it('renders the share widget slot when provided', () => { + it('renders the Impact share widget slot when provided', () => { const html = renderToStaticMarkup( React.createElement(ReferralRewardStatusCard, { summary: emptySummary, @@ -56,12 +44,12 @@ describe('ReferralRewardStatusCard', () => { expect(html).toContain('widget body'); }); - it('renders reward status labels, referred people, and a top-of-card reactivate banner', () => { + it('surfaces on-hold rewards, applied renewal dates, and customer-safe referee status', () => { const html = renderToStaticMarkup( React.createElement(ReferralRewardStatusCard, { summary: { totals: { - totalRewards: 6, + totalRewards: 2, pendingRewards: 1, totalAppliedMonths: 1, }, @@ -107,67 +95,22 @@ describe('ReferralRewardStatusCard', () => { reviewReason: null, application: null, }, - { - role: 'referrer', - status: 'expired', - monthsGranted: 1, - earnedAt: '2026-04-12T00:00:00.000Z', - appliedAt: null, - expiresAt: '2027-04-12T00:00:00.000Z', - reviewReason: null, - application: null, - }, - { - role: 'referrer', - status: 'canceled', - monthsGranted: 1, - earnedAt: '2026-04-13T00:00:00.000Z', - appliedAt: null, - expiresAt: null, - reviewReason: null, - application: null, - }, - { - role: 'referrer', - status: 'reversed', - monthsGranted: 1, - earnedAt: '2026-04-14T00:00:00.000Z', - appliedAt: null, - expiresAt: null, - reviewReason: null, - application: null, - }, - { - role: 'referrer', - status: 'review_required', - monthsGranted: 1, - earnedAt: '2026-04-15T00:00:00.000Z', - appliedAt: null, - expiresAt: null, - reviewReason: 'referral_payment_chargeback', - application: null, - }, ], }, }) ); + expect(html).toContain('1 reward on hold'); + expect(html).toContain('Start or reactivate KiloClaw'); + expect(html).toContain('data-testid="summary-indicator-warning"'); expect(html).toContain('Applied'); - expect(html).toContain('Waiting for an eligible KiloClaw subscription'); - expect(html).toContain('Expired'); - expect(html).toContain('Canceled'); - expect(html).toContain('Reversed'); - expect(html).toContain('Needs review'); expect(html).toContain('May 1, 2026'); expect(html).toContain('June 1, 2026'); - expect(html).toContain('Your referees'); + expect(html).toContain('Waiting for an eligible KiloClaw subscription'); expect(html).toContain('q***@example.com'); expect(html).toContain('Reward granted'); expect(html).toContain('s***@example.com'); expect(html).toContain('Signed up, waiting for paid KiloClaw conversion'); - expect(html).toContain('1 reward on hold'); - expect(html).toContain('Start or reactivate KiloClaw'); - expect(html).toContain('data-testid="summary-indicator-warning"'); }); it('pluralizes the reactivate banner copy when more than one reward is pending', () => { diff --git a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.test.ts b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.test.ts index ad1f323689..866785d436 100644 --- a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.test.ts +++ b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.test.ts @@ -16,17 +16,15 @@ describe('ReferralRewardsSummary', () => { React.createElement(ReferralRewardsSummary, { rewards: emptyRewards }) ); - expect(html).toContain('Referral rewards'); expect(html).toContain('No rewards yet. Refer a friend to earn a free month.'); - expect(html).toContain('Refer a friend'); expect(html).toContain('href="/claw/refer"'); }); - it('renders applied reward rows with renewal boundaries and Kilo-voice role label', () => { + it('renders applied referrer and referee rewards with renewal boundaries', () => { const html = renderToStaticMarkup( React.createElement(ReferralRewardsSummary, { rewards: { - totalAppliedMonths: 1, + totalAppliedMonths: 2, applications: [ { role: 'referrer', @@ -35,37 +33,23 @@ describe('ReferralRewardsSummary', () => { previousRenewalBoundary: '2026-05-01T00:00:00.000Z', newRenewalBoundary: '2026-06-01T00:00:00.000Z', }, - ], - }, - }) - ); - - expect(html).toContain('1 free month applied'); - expect(html).toContain('Reward for referring'); - expect(html).toContain('Applied April 10, 2026'); - expect(html).toContain('May 1, 2026'); - expect(html).toContain('June 1, 2026'); - }); - - it('uses the welcome-reward label for referee rewards', () => { - const html = renderToStaticMarkup( - React.createElement(ReferralRewardsSummary, { - rewards: { - totalAppliedMonths: 1, - applications: [ { role: 'referee', - appliedAt: '2026-04-10T00:05:00.000Z', + appliedAt: '2026-04-11T00:05:00.000Z', monthsGranted: 1, - previousRenewalBoundary: '2026-05-01T00:00:00.000Z', - newRenewalBoundary: '2026-06-01T00:00:00.000Z', + previousRenewalBoundary: '2026-06-01T00:00:00.000Z', + newRenewalBoundary: '2026-07-01T00:00:00.000Z', }, ], }, }) ); + expect(html).toContain('2 free months applied'); + expect(html).toContain('Reward for referring'); expect(html).toContain('Welcome reward'); + expect(html).toContain('May 1, 2026'); + expect(html).toContain('July 1, 2026'); }); it('drops its own border when rendered as a section variant', () => { diff --git a/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts index 4f106d37a9..c4ce6d9b60 100644 --- a/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts +++ b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.test.ts @@ -4,119 +4,103 @@ import { describe, expect, it } from '@jest/globals'; import { KiloclawReferralsInvestigationResults } from './KiloclawReferralsInvestigation'; -const result = { - referrer: { id: 'referrer-1', email: 'referrer@example.com', name: 'Referrer' }, - referrals: [ - { - referral: { - id: '11111111-1111-4111-8111-111111111111', - impactReferralId: 'RS-SUPPORT', - createdAt: '2026-04-01T00:00:00.000Z', - }, - referee: { id: 'referee-1', email: 'qualified@example.com', name: null }, - sourceTouch: null, - conversion: { - id: '22222222-2222-4222-8222-222222222222', - winningTouchType: 'referral', - sourcePaymentId: 'qualified-payment', - qualified: true, - disqualificationReason: null, - convertedAt: '2026-04-10T00:00:00.000Z', - }, - rewardDecisions: [ - { - id: '33333333-3333-4333-8333-333333333333', - beneficiaryUserId: 'referrer-1', - beneficiaryRole: 'referrer', - outcome: 'granted', - reason: null, - monthsGranted: 1, - createdAt: '2026-04-10T00:00:00.000Z', - }, - ], - rewards: [], - rewardApplications: [ - { - id: '44444444-4444-4444-8444-444444444444', - beneficiaryUserId: 'referrer-1', - subscriptionId: '55555555-5555-4555-8555-555555555555', - previousRenewalBoundary: '2026-05-01T00:00:00.000Z', - newRenewalBoundary: '2026-06-01T00:00:00.000Z', - appliedAt: '2026-04-10T00:05:00.000Z', - }, - ], - impactReports: [ - { - id: '66666666-6666-4666-8666-666666666666', - state: 'delivered', - actionTrackerId: 71659, - orderId: 'qualified-payment', - deliveredAt: '2026-04-10T00:06:00.000Z', - nextRetryAt: null, - responseStatusCode: null, - }, - ], +function referralRow(params: { + referralId: string; + refereeEmail: string; + paymentId: string; + qualified: boolean; + disqualificationReason: string | null; + impactReportState: string; +}) { + return { + referral: { + id: params.referralId, + impactReferralId: 'RS-SUPPORT', + createdAt: '2026-04-01T00:00:00.000Z', + }, + referee: { id: `${params.referralId}-referee`, email: params.refereeEmail, name: null }, + sourceTouch: null, + conversion: { + id: `${params.referralId}-conversion`, + winningTouchType: 'referral', + sourcePaymentId: params.paymentId, + qualified: params.qualified, + disqualificationReason: params.disqualificationReason, + convertedAt: '2026-04-10T00:00:00.000Z', }, - { - referral: { - id: '77777777-7777-4777-8777-777777777777', - impactReferralId: 'RS-SUPPORT', - createdAt: '2026-04-02T00:00:00.000Z', + rewardDecisions: [ + { + id: `${params.referralId}-decision`, + beneficiaryUserId: 'referrer-1', + beneficiaryRole: 'referrer', + outcome: params.qualified ? 'granted' : 'disqualified', + reason: params.disqualificationReason, + monthsGranted: params.qualified ? 1 : 0, + createdAt: '2026-04-10T00:00:00.000Z', }, - referee: { id: 'referee-2', email: 'disqualified@example.com', name: null }, - sourceTouch: null, - conversion: { - id: '88888888-8888-4888-8888-888888888888', - winningTouchType: 'referral', - sourcePaymentId: 'disqualified-payment', - qualified: false, - disqualificationReason: 'referral_self_referral', - convertedAt: '2026-04-10T00:00:00.000Z', + ], + rewards: [], + rewardApplications: params.qualified + ? [ + { + id: `${params.referralId}-application`, + beneficiaryUserId: 'referrer-1', + subscriptionId: '55555555-5555-4555-8555-555555555555', + previousRenewalBoundary: '2026-05-01T00:00:00.000Z', + newRenewalBoundary: '2026-06-01T00:00:00.000Z', + appliedAt: '2026-04-10T00:05:00.000Z', + }, + ] + : [], + impactReports: [ + { + id: `${params.referralId}-report`, + state: params.impactReportState, + actionTrackerId: 71659, + orderId: params.paymentId, + deliveredAt: params.impactReportState === 'delivered' ? '2026-04-10T00:06:00.000Z' : null, + nextRetryAt: null, + responseStatusCode: params.impactReportState === 'failed' ? 400 : null, }, - rewardDecisions: [ - { - id: '99999999-9999-4999-8999-999999999999', - beneficiaryUserId: 'referrer-1', - beneficiaryRole: 'referrer', - outcome: 'disqualified', - reason: 'referral_self_referral', - monthsGranted: 0, - createdAt: '2026-04-10T00:00:00.000Z', - }, - ], - rewards: [], - rewardApplications: [], - impactReports: [ - { - id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', - state: 'failed', - actionTrackerId: 71659, - orderId: 'disqualified-payment', - deliveredAt: null, - nextRetryAt: null, - responseStatusCode: 400, - }, - ], - }, + ], + }; +} + +const result = { + referrer: { id: 'referrer-1', email: 'referrer@example.com', name: 'Referrer' }, + referrals: [ + referralRow({ + referralId: 'qualified-referral', + refereeEmail: 'qualified@example.com', + paymentId: 'qualified-payment', + qualified: true, + disqualificationReason: null, + impactReportState: 'delivered', + }), + referralRow({ + referralId: 'disqualified-referral', + refereeEmail: 'disqualified@example.com', + paymentId: 'disqualified-payment', + qualified: false, + disqualificationReason: 'referral_self_referral', + impactReportState: 'failed', + }), ], }; describe('KiloclawReferralsInvestigationResults', () => { - it('renders qualified and disqualified referee diagnostics', () => { + it('renders qualified and disqualified referee diagnostics with reward and Impact state', () => { const html = renderToStaticMarkup( React.createElement(KiloclawReferralsInvestigationResults, { result }) ); expect(html).toContain('referrer@example.com'); - expect(html).toContain('qualified@example.com'); - expect(html).toContain('disqualified@example.com'); expect(html).toContain('Qualified'); expect(html).toContain('Disqualified'); expect(html).toContain('referral_self_referral'); expect(html).toContain('granted'); - expect(html).toContain('disqualified'); - expect(html).toContain('delivered'); - expect(html).toContain('failed'); + expect(html).toContain('delivered, tracker 71659, order qualified-payment'); + expect(html).toContain('failed, tracker 71659, order disqualified-payment, HTTP 400'); expect(html).toContain('May 1, 2026 to'); expect(html).toContain('June 1, 2026'); }); diff --git a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts index 4796f1f4bc..3c54525a55 100644 --- a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts +++ b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts @@ -240,7 +240,7 @@ describe('POST /api/internal/kiloclaw/billing-side-effects', () => { }); }); - it('processes paid conversions and only enqueues affiliate sales when they win attribution', async () => { + it('processes paid conversions without enqueueing affiliate sales when referrals win attribution', async () => { mockProcessPersonalKiloClawPaidConversion.mockResolvedValueOnce({ shouldEnqueueAffiliateSale: false, winningTouchType: 'referral', @@ -285,4 +285,51 @@ describe('POST /api/internal/kiloclaw/billing-side-effects', () => { disqualificationReason: null, }); }); + + it('enqueues affiliate sales when paid conversion attribution returns an affiliate winner', async () => { + mockProcessPersonalKiloClawPaidConversion.mockResolvedValueOnce({ + shouldEnqueueAffiliateSale: true, + winningTouchType: 'affiliate', + conversionId: 'conversion_affiliate', + disqualificationReason: 'referral_affiliate_won', + }); + + const response = await POST( + createRequest({ + action: 'process_paid_conversion', + input: { + userId: 'user-123', + dedupeKey: 'affiliate:impact:sale:period-123', + eventDateIso: '2026-04-09T10:00:00.000Z', + orderId: 'period-123', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + }, + }) + ); + + expect(response.status).toBe(200); + expect(mockEnqueueAffiliateEventForUser).toHaveBeenCalledWith({ + userId: 'user-123', + provider: 'impact', + eventType: 'sale', + dedupeKey: 'affiliate:impact:sale:period-123', + eventDate: new Date('2026-04-09T10:00:00.000Z'), + orderId: 'period-123', + amount: 9, + currencyCode: 'usd', + itemCategory: 'kiloclaw-standard', + itemName: 'KiloClaw Standard Plan', + itemSku: 'price_standard', + }); + await expect(response.json()).resolves.toEqual({ + affiliateSaleEnqueued: true, + winningTouchType: 'affiliate', + conversionId: 'conversion_affiliate', + disqualificationReason: 'referral_affiliate_won', + }); + }); }); From a18b6c09c4d74fea300cb7366d7e64c69282596c Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 7 May 2026 13:57:39 +0200 Subject: [PATCH 30/32] fix(referrals): address /review findings Logic & data: - softDeleteUser falls back to google_user_email when normalized_email is NULL so pre-0090 users still get a deletion tombstone before anonymization destroys the email (added regression test). - applyReferralRewardById back-fills expires_at = earned_at + 12 months when a Referrer reward transitions Earned -> Pending, mirroring the conversion-time invariant in spec rule 66. Type safety: - dispatchImpactConversionReportById validates report.request_payload with a new isImpactConversionPayload predicate instead of an unchecked cast; invalid_request_payload is reported separately from missing_request_payload. - kiloclaw-referral-eligibility route.test.ts replaces 'as never' casts with typed adminUserFixture / subscriptionFixture helpers using Partial -> T (structural, not double-cast through unknown). Style: - Remove logImpactAdvocateDebug thin wrapper; call sites use logImpactReferralDebug directly. - Consolidate hand-written tracking-param checks in getSignInCallbackUrl.ts into the existing UTM loop via a single ordered trackingParams array (test order preserved). - Drop redundant params.result.ok && ... conjuncts inside an already-narrowed branch in persistImpactConversionReportResult. - Remove getNestedObjectProperty one-line alias. - ImpactIdentify.tsx uses logImpactReferralDebug helper instead of reimplementing the prefix and dev gate inline. - Drop unused default React imports from KiloclawReferralsInvestigation.tsx and ReferralRewardsSummary.tsx. --- .../billing/ReferralRewardsSummary.tsx | 1 - .../route.test.ts | 36 +++++++++--- .../KiloclawReferralsInvestigation.tsx | 2 +- apps/web/src/components/ImpactIdentify.tsx | 27 ++++----- apps/web/src/lib/getSignInCallbackUrl.ts | 40 ++++++------- apps/web/src/lib/impact-advocate.ts | 30 ++++------ apps/web/src/lib/kiloclaw-referrals.ts | 56 +++++++++++++------ apps/web/src/lib/user.test.ts | 23 ++++++++ apps/web/src/lib/user.ts | 6 +- 9 files changed, 136 insertions(+), 85 deletions(-) diff --git a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx index d17fa21e78..ca05f7bdc3 100644 --- a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx +++ b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import Link from 'next/link'; import { ArrowRight, CalendarDays, Gift } from 'lucide-react'; diff --git a/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.test.ts b/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.test.ts index 03258699cf..e6505ed512 100644 --- a/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.test.ts +++ b/apps/web/src/app/admin/api/users/[id]/kiloclaw-referral-eligibility/route.test.ts @@ -1,10 +1,29 @@ import { NextRequest, NextResponse } from 'next/server'; -import { insertKiloClawSubscriptionChangeLog } from '@kilocode/db'; +import { + insertKiloClawSubscriptionChangeLog, + type KiloClawSubscription, + type User, +} from '@kilocode/db'; import { processPersonalKiloClawPaidConversion } from '@/lib/kiloclaw-referrals'; import { resolveCurrentPersonalSubscriptionRow } from '@/lib/kiloclaw/current-personal-subscription'; import { getUserFromAuth } from '@/lib/user.server'; +// Test-fixture boundary: only the fields the route actually reads are +// populated. Casting via Partial -> T (rather than `as never` or +// `as unknown as T`) keeps the structural type relationship intact, so any +// required field the route starts to read in the future will surface as a +// concrete TS error here instead of silently `undefined`. +function adminUserFixture(overrides: Partial & Pick): User { + return overrides as Partial as User; +} + +function subscriptionFixture( + overrides: Partial & Pick +): KiloClawSubscription { + return overrides as Partial as KiloClawSubscription; +} + jest.mock('@/lib/user.server', () => ({ getUserFromAuth: jest.fn(), })); @@ -49,17 +68,17 @@ describe('POST /admin/api/users/[id]/kiloclaw-referral-eligibility', () => { beforeEach(() => { jest.clearAllMocks(); mockGetUserFromAuth.mockResolvedValue({ - user: { id: 'admin_123' } as never, + user: adminUserFixture({ id: 'admin_123' }), authFailedResponse: null, }); mockResolveCurrentPersonalSubscriptionRow.mockResolvedValue({ - subscription: { + subscription: subscriptionFixture({ id: 'subscription_123', user_id: 'user_123', plan: 'standard', status: 'active', - }, - } as never); + }), + } as Awaited>); mockInsertKiloClawSubscriptionChangeLog.mockResolvedValue(undefined); mockProcessPersonalKiloClawPaidConversion.mockResolvedValue({ shouldEnqueueAffiliateSale: false, @@ -72,13 +91,16 @@ describe('POST /admin/api/users/[id]/kiloclaw-referral-eligibility', () => { it('returns authFailedResponse for unauthorized operators', async () => { mockGetUserFromAuth.mockResolvedValue({ user: null, - authFailedResponse: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) as never, + authFailedResponse: NextResponse.json( + { success: false as const, error: 'Unauthorized' }, + { status: 401 } + ), }); const response = await POST(createRequest({}), { params: Promise.resolve({ id: 'user_123' }) }); expect(response.status).toBe(401); - await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }); + await expect(response.json()).resolves.toEqual({ success: false, error: 'Unauthorized' }); }); it('records an admin override and processes the conversion with overrideEligible=true', async () => { diff --git a/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx index 6abb2b5521..410a1c48a4 100644 --- a/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx +++ b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { Search } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; diff --git a/apps/web/src/components/ImpactIdentify.tsx b/apps/web/src/components/ImpactIdentify.tsx index 2ac0467b21..4f5f71c6b3 100644 --- a/apps/web/src/components/ImpactIdentify.tsx +++ b/apps/web/src/components/ImpactIdentify.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useUser } from '@/hooks/useUser'; +import { logImpactReferralDebug } from '@/lib/impact-debug'; import { IMPACT_CUSTOM_PROFILE_ID_STORAGE_KEY } from '@/lib/impact-referral-utils'; async function sha1Hex(value: string): Promise { @@ -36,15 +37,9 @@ export function ImpactIdentify() { if (typeof window.ire !== 'function') { if (retriesRemaining <= 0) { - if (process.env.NODE_ENV === 'development') { - console.log( - '[impact-referral-debug]', - 'Impact UTT identify skipped; window.ire unavailable', - { - userId: user?.id ?? null, - } - ); - } + logImpactReferralDebug('Impact UTT identify skipped; window.ire unavailable', { + userId: user?.id ?? null, + }); return; } @@ -60,14 +55,12 @@ export function ImpactIdentify() { if (cancelled || typeof window.ire !== 'function') return; - if (process.env.NODE_ENV === 'development') { - console.log('[impact-referral-debug]', 'Calling Impact UTT identify', { - userId: user?.id ?? null, - customerIdPresent: Boolean(customerId), - customerEmailHashPresent: Boolean(customerEmail), - customProfileIdPresent: Boolean(customProfileId), - }); - } + logImpactReferralDebug('Calling Impact UTT identify', { + userId: user?.id ?? null, + customerIdPresent: Boolean(customerId), + customerEmailHashPresent: Boolean(customerEmail), + customProfileIdPresent: Boolean(customProfileId), + }); window.ire('identify', { customerId, diff --git a/apps/web/src/lib/getSignInCallbackUrl.ts b/apps/web/src/lib/getSignInCallbackUrl.ts index 52934512d3..0f2a0b41a4 100644 --- a/apps/web/src/lib/getSignInCallbackUrl.ts +++ b/apps/web/src/lib/getSignInCallbackUrl.ts @@ -38,30 +38,24 @@ export default function getSignInCallbackUrl(searchParams?: NextAppSearchParams) callbackParams.set('source', searchParams?.source); } - if (typeof searchParams?.im_ref === 'string' && searchParams?.im_ref) { - callbackParams.set('im_ref', searchParams.im_ref); - } - - if (typeof searchParams?._saasquatch === 'string' && searchParams?._saasquatch) { - callbackParams.set('_saasquatch', searchParams._saasquatch); - } - - if (typeof searchParams?.rsCode === 'string' && searchParams?.rsCode) { - callbackParams.set('rsCode', searchParams.rsCode); - } - - if (typeof searchParams?.rsShareMedium === 'string' && searchParams?.rsShareMedium) { - callbackParams.set('rsShareMedium', searchParams.rsShareMedium); - } - - if (typeof searchParams?.rsEngagementMedium === 'string' && searchParams?.rsEngagementMedium) { - callbackParams.set('rsEngagementMedium', searchParams.rsEngagementMedium); - } - - for (const utmParam of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']) { - const value = searchParams?.[utmParam]; + // Order matters: tests assert this exact emission order through the + // sign-in callback redirect (see getSignInCallbackUrl.test.ts). + const trackingParams = [ + 'im_ref', + '_saasquatch', + 'rsCode', + 'rsShareMedium', + 'rsEngagementMedium', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + ] as const; + for (const trackingParam of trackingParams) { + const value = searchParams?.[trackingParam]; if (typeof value === 'string' && value) { - callbackParams.set(utmParam, value); + callbackParams.set(trackingParam, value); } } diff --git a/apps/web/src/lib/impact-advocate.ts b/apps/web/src/lib/impact-advocate.ts index 070b84f3ad..b4f0b4b619 100644 --- a/apps/web/src/lib/impact-advocate.ts +++ b/apps/web/src/lib/impact-advocate.ts @@ -179,14 +179,6 @@ function getDebuggableVerifiedAccessTokenPayload( }; } -function logImpactAdvocateDebug(message: string, details: Record): void { - // Delegates to the unified Impact debug logger so a single env - // (IMPACT_REFERRAL_DEBUG=true, or NODE_ENV=development) lights up every - // outbound Impact call site. IMPACT_ADVOCATE_DEBUG_LOGGING is still - // honored as a legacy alias inside the unified gate. - logImpactReferralDebug(message, details); -} - function getImpactAdvocateWidgetPath(widgetId: string, programId: string): string { const trimmedWidgetId = widgetId.trim(); if (!trimmedWidgetId) return `p/${programId}/w/${IMPACT_ADVOCATE_WIDGET_NAME}`; @@ -283,7 +275,7 @@ export function buildImpactAdvocateRegisterParticipantPayload(params: { ...(params.countryCode ? { countryCode: params.countryCode } : {}), }; - logImpactAdvocateDebug('[impact-advocate] built register participant payload', { + logImpactReferralDebug('[impact-advocate] built register participant payload', { payload: getDebuggableRegisterParticipantPayload(payload), }); @@ -424,7 +416,7 @@ export async function sendImpactAdvocateRegisterParticipantPayload( const sanitizedPayload = sanitizeRegisterParticipantPayloadForWire( payload as unknown as Record ); - logImpactAdvocateDebug('[impact-advocate] sending register participant request', { + logImpactReferralDebug('[impact-advocate] sending register participant request', { url: getDebuggableImpactAdvocateRegisterParticipantUrl(config), method: 'PUT', headers: { @@ -448,7 +440,7 @@ export async function sendImpactAdvocateRegisterParticipantPayload( }); const responseBody = await response.text(); - logImpactAdvocateDebug('[impact-advocate] register participant response', { + logImpactReferralDebug('[impact-advocate] register participant response', { url: getDebuggableImpactAdvocateRegisterParticipantUrl(config), ok: response.ok, statusCode: response.status, @@ -470,7 +462,7 @@ export async function sendImpactAdvocateRegisterParticipantPayload( responseBody, }; } catch (error) { - logImpactAdvocateDebug('[impact-advocate] register participant network error', { + logImpactReferralDebug('[impact-advocate] register participant network error', { error: error instanceof Error ? error.message : String(error), }); return { @@ -495,7 +487,7 @@ export async function sendImpactAdvocateRewardLookupPayload( try { const url = getImpactAdvocateRewardsUrl(config, payload); - logImpactAdvocateDebug('[impact-advocate] sending reward lookup request', { + logImpactReferralDebug('[impact-advocate] sending reward lookup request', { url: getDebuggableImpactAdvocateRewardsUrl(config, payload), method: 'GET', accountIdPresent: Boolean(payload.accountId.trim()), @@ -512,7 +504,7 @@ export async function sendImpactAdvocateRewardLookupPayload( }); const responseBody = await response.text(); - logImpactAdvocateDebug('[impact-advocate] reward lookup response', { + logImpactReferralDebug('[impact-advocate] reward lookup response', { url: getDebuggableImpactAdvocateRewardsUrl(config, payload), ok: response.ok, statusCode: response.status, @@ -535,7 +527,7 @@ export async function sendImpactAdvocateRewardLookupPayload( responseBody, }; } catch (error) { - logImpactAdvocateDebug('[impact-advocate] reward lookup network error', { + logImpactReferralDebug('[impact-advocate] reward lookup network error', { error: error instanceof Error ? error.message : String(error), }); return { @@ -564,7 +556,7 @@ export async function sendImpactAdvocateRewardRedemptionPayload( amount: payload.amount, unit: payload.unit, }; - logImpactAdvocateDebug('[impact-advocate] sending reward redemption request', { + logImpactReferralDebug('[impact-advocate] sending reward redemption request', { url, method: 'POST', rewardIdPresent: Boolean(payload.rewardId.trim()), @@ -583,7 +575,7 @@ export async function sendImpactAdvocateRewardRedemptionPayload( }); const responseBody = await response.text(); - logImpactAdvocateDebug('[impact-advocate] reward redemption response', { + logImpactReferralDebug('[impact-advocate] reward redemption response', { url, ok: response.ok, statusCode: response.status, @@ -605,7 +597,7 @@ export async function sendImpactAdvocateRewardRedemptionPayload( responseBody, }; } catch (error) { - logImpactAdvocateDebug('[impact-advocate] reward redemption network error', { + logImpactReferralDebug('[impact-advocate] reward redemption network error', { error: error instanceof Error ? error.message : String(error), }); return { @@ -638,7 +630,7 @@ export function issueImpactAdvocateVerifiedAccessToken( }; const token = jwt.sign(payload, config.authToken, options); - logImpactAdvocateDebug('[impact-advocate] issued verified access token', { + logImpactReferralDebug('[impact-advocate] issued verified access token', { jwtHeader: header, jwtPayload: getDebuggableVerifiedAccessTokenPayload(payload), signOptions: { diff --git a/apps/web/src/lib/kiloclaw-referrals.ts b/apps/web/src/lib/kiloclaw-referrals.ts index 6a2821246a..5442eac0eb 100644 --- a/apps/web/src/lib/kiloclaw-referrals.ts +++ b/apps/web/src/lib/kiloclaw-referrals.ts @@ -529,23 +529,19 @@ function getNumberProperty(record: unknown, keys: string[]): number | null { return null; } -function getNestedObjectProperty(record: unknown, key: string): unknown { - return getCaseInsensitiveObjectProperty(record, key); -} - function rewardHasUnit(reward: unknown, unit: string): boolean { const unitValue = getStringProperty(reward, ['unit', 'Unit', 'currency']) ?? - getStringProperty(getNestedObjectProperty(reward, 'credit'), ['unit', 'Unit']) ?? - getStringProperty(getNestedObjectProperty(reward, 'value'), ['unit', 'Unit']); + getStringProperty(getCaseInsensitiveObjectProperty(reward, 'credit'), ['unit', 'Unit']) ?? + getStringProperty(getCaseInsensitiveObjectProperty(reward, 'value'), ['unit', 'Unit']); return !unitValue || unitValue.toLowerCase() === unit.toLowerCase(); } function rewardHasAmount(reward: unknown, amount: number): boolean { const amountValue = getNumberProperty(reward, ['amount', 'Amount', 'remainingAmount', 'RemainingAmount']) ?? - getNumberProperty(getNestedObjectProperty(reward, 'credit'), ['amount', 'Amount']) ?? - getNumberProperty(getNestedObjectProperty(reward, 'value'), ['amount', 'Amount']); + getNumberProperty(getCaseInsensitiveObjectProperty(reward, 'credit'), ['amount', 'Amount']) ?? + getNumberProperty(getCaseInsensitiveObjectProperty(reward, 'value'), ['amount', 'Amount']); return amountValue === null || amountValue >= amount; } @@ -740,9 +736,25 @@ async function applyReferralRewardById( if (!subscription || !hasActiveEligibleSubscriptionRow(subscription)) { if (reward.status === KiloClawReferralRewardStatus.Earned) { + // Mirror the conversion-time invariant: a Referrer reward that lands + // in Pending because the referrer is no longer on an eligible paid + // personal subscription MUST carry the 12-month expiry from earned_at + // (see .specs/kiloclaw-referrals.md rule 66). Without this back-fill, + // a reward earned during a brief eligible window and then orphaned + // when the referrer churns would have expires_at = NULL forever. + const shouldBackfillExpiresAt = + reward.beneficiary_role === KiloClawReferralBeneficiaryRole.Referrer && + reward.expires_at === null; await tx .update(kiloclaw_referral_rewards) - .set({ status: KiloClawReferralRewardStatus.Pending }) + .set({ + status: KiloClawReferralRewardStatus.Pending, + ...(shouldBackfillExpiresAt + ? { + expires_at: addMonths(new Date(reward.earned_at), 12).toISOString(), + } + : {}), + }) .where(eq(kiloclaw_referral_rewards.id, reward.id)); } return 'pending'; @@ -1017,6 +1029,17 @@ type ImpactAdvocateRewardRedemptionRequestPayload = { }; }; +function isImpactConversionPayload(payload: unknown): payload is ImpactConversionPayload { + return ( + typeof payload === 'object' && + payload !== null && + typeof getObjectProperty(payload, 'CampaignId') === 'string' && + typeof getObjectProperty(payload, 'ActionTrackerId') === 'number' && + typeof getObjectProperty(payload, 'EventDate') === 'string' && + typeof getObjectProperty(payload, 'OrderId') === 'string' + ); +} + function isRewardRedemptionRequestPayload( payload: unknown ): payload is ImpactAdvocateRewardRedemptionRequestPayload { @@ -1484,10 +1507,8 @@ async function persistImpactConversionReportResult(params: { response_payload: { delivery: params.result.delivery ?? null, responseBody: params.result.responseBody ?? null, - ...(params.result.ok && 'actionId' in params.result - ? { actionId: params.result.actionId } - : {}), - ...(params.result.ok && 'submissionUri' in params.result + ...('actionId' in params.result ? { actionId: params.result.actionId } : {}), + ...('submissionUri' in params.result ? { submissionUri: params.result.submissionUri } : {}), } satisfies Record, @@ -1539,17 +1560,20 @@ async function dispatchImpactConversionReportById( return 'failed'; } - const payload = report.request_payload as ImpactConversionPayload | null; - if (!payload) { + if (!isImpactConversionPayload(report.request_payload)) { await db .update(impact_conversion_reports) .set({ state: ImpactConversionReportState.Failed, - response_payload: { error: 'missing_request_payload' } satisfies Record, + response_payload: { + error: + report.request_payload === null ? 'missing_request_payload' : 'invalid_request_payload', + } satisfies Record, }) .where(eq(impact_conversion_reports.id, report.id)); return 'failed'; } + const payload = report.request_payload; const result = await sendImpactConversionPayload(payload); await persistImpactConversionReportResult({ reportId: report.id, result }); diff --git a/apps/web/src/lib/user.test.ts b/apps/web/src/lib/user.test.ts index 2167c5e1b1..071ab1212e 100644 --- a/apps/web/src/lib/user.test.ts +++ b/apps/web/src/lib/user.test.ts @@ -548,6 +548,29 @@ describe('User', () => { expect(conversionCount.count).toBe(0); }); + it('falls back to google_user_email when normalized_email is null', async () => { + // Pre-0090 users can have NULL normalized_email but a real google_user_email. + // Soft-delete must still record a tombstone so a re-registration of the + // same email cannot bypass the previously-deleted-referee guard. + const legacyUser = await insertTestUser({ + google_user_email: 'legacy-no-normalized@example.com', + normalized_email: null, + }); + + await softDeleteUser(legacyUser.id); + + const [tombstone] = await db + .select() + .from(deleted_user_email_tombstones) + .where( + eq( + deleted_user_email_tombstones.normalized_email_hash, + hashNormalizedEmailForDeletionTombstone('legacy-no-normalized@example.com') + ) + ); + expect(tombstone).toBeDefined(); + }); + it('should delete auth providers', async () => { const user = await insertTestUser(); await db.insert(user_auth_provider).values({ diff --git a/apps/web/src/lib/user.ts b/apps/web/src/lib/user.ts index fcf55cdecf..a5e576451f 100644 --- a/apps/web/src/lib/user.ts +++ b/apps/web/src/lib/user.ts @@ -796,9 +796,13 @@ export async function softDeleteUser(userId: string) { ); } + // Pre-0090 users can have NULL normalized_email but a real google_user_email. + // Fall back to google_user_email so the tombstone hash still gets recorded + // before the row below anonymizes both columns; otherwise a previously + // deleted user could re-register and qualify as a referee. await createDeletedUserEmailTombstone({ database: tx, - normalizedEmail: user.normalized_email, + normalizedEmail: user.normalized_email ?? user.google_user_email ?? null, }); // ── 1. Anonymize the user row ──────────────────────────────────────── From 599c9d7d61299722ee7451fc4968d2f0e896fafe Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 7 May 2026 14:05:19 +0200 Subject: [PATCH 31/32] fix(referrals): import React for server-rendered tests --- .../(app)/claw/components/billing/ReferralRewardsSummary.tsx | 1 + .../src/app/admin/components/KiloclawReferralsInvestigation.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx index ca05f7bdc3..d17fa21e78 100644 --- a/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx +++ b/apps/web/src/app/(app)/claw/components/billing/ReferralRewardsSummary.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import Link from 'next/link'; import { ArrowRight, CalendarDays, Gift } from 'lucide-react'; diff --git a/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx index 410a1c48a4..6abb2b5521 100644 --- a/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx +++ b/apps/web/src/app/admin/components/KiloclawReferralsInvestigation.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import React, { useState } from 'react'; import { Search } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; From adf42b4d656e814b1987769364180aa0046a7500 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Thu, 7 May 2026 20:15:18 +0200 Subject: [PATCH 32/32] chore(db): regenerate migrations after rebase --- .../src/migrations/0119_sad_katie_power.sql | 234 + .../db/src/migrations/meta/0119_snapshot.json | 20895 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + 3 files changed, 21136 insertions(+) create mode 100644 packages/db/src/migrations/0119_sad_katie_power.sql create mode 100644 packages/db/src/migrations/meta/0119_snapshot.json diff --git a/packages/db/src/migrations/0119_sad_katie_power.sql b/packages/db/src/migrations/0119_sad_katie_power.sql new file mode 100644 index 0000000000..c11e2a80b8 --- /dev/null +++ b/packages/db/src/migrations/0119_sad_katie_power.sql @@ -0,0 +1,234 @@ +CREATE TABLE "deleted_user_email_tombstones" ( + "normalized_email_hash" text PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "impact_advocate_participants" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "advocate_id" text NOT NULL, + "advocate_account_id" text NOT NULL, + "opaque_referral_identifier" text, + "contact_email" text, + "locale" text, + "country_code" text, + "registration_state" text DEFAULT 'pending' NOT NULL, + "registered_at" timestamp with time zone, + "last_registration_attempt_at" timestamp with time zone, + "last_error_code" text, + "last_error_message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_impact_advocate_participants_user_id" UNIQUE("user_id"), + CONSTRAINT "UQ_impact_advocate_participants_opaque_referral_identifier" UNIQUE("opaque_referral_identifier"), + CONSTRAINT "impact_advocate_participants_registration_state_check" CHECK ("impact_advocate_participants"."registration_state" IN ('pending', 'retrying', 'registered', 'failed')) +); +--> statement-breakpoint +CREATE TABLE "impact_advocate_registration_attempts" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "participant_id" uuid NOT NULL, + "dedupe_key" text NOT NULL, + "opaque_cookie_value" text, + "cookie_value_length" integer NOT NULL, + "delivery_state" text DEFAULT 'queued' NOT NULL, + "request_payload" jsonb, + "response_payload" jsonb, + "response_status_code" integer, + "attempt_count" integer DEFAULT 0 NOT NULL, + "next_retry_at" timestamp with time zone, + "claimed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_impact_advocate_registration_attempts_dedupe_key" UNIQUE("dedupe_key"), + CONSTRAINT "impact_advocate_registration_attempts_delivery_state_check" CHECK ("impact_advocate_registration_attempts"."delivery_state" IN ('queued', 'sending', 'succeeded', 'failed')), + CONSTRAINT "impact_advocate_registration_attempts_cookie_value_length_non_negative_check" CHECK ("impact_advocate_registration_attempts"."cookie_value_length" >= 0), + CONSTRAINT "impact_advocate_registration_attempts_attempt_count_non_negative_check" CHECK ("impact_advocate_registration_attempts"."attempt_count" >= 0) +); +--> statement-breakpoint +CREATE TABLE "impact_advocate_reward_redemptions" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "reward_id" uuid NOT NULL, + "dedupe_key" text NOT NULL, + "beneficiary_user_id" text NOT NULL, + "state" text DEFAULT 'queued' NOT NULL, + "impact_reward_id" text, + "request_payload" jsonb, + "lookup_response_payload" jsonb, + "redeem_response_payload" jsonb, + "response_status_code" integer, + "attempt_count" integer DEFAULT 0 NOT NULL, + "next_retry_at" timestamp with time zone, + "redeemed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_impact_advocate_reward_redemptions_reward_id" UNIQUE("reward_id"), + CONSTRAINT "UQ_impact_advocate_reward_redemptions_dedupe_key" UNIQUE("dedupe_key"), + CONSTRAINT "impact_advocate_reward_redemptions_state_check" CHECK ("impact_advocate_reward_redemptions"."state" IN ('queued', 'retrying', 'redeemed', 'failed')), + CONSTRAINT "impact_advocate_reward_redemptions_attempt_count_non_negative_check" CHECK ("impact_advocate_reward_redemptions"."attempt_count" >= 0) +); +--> statement-breakpoint +CREATE TABLE "impact_conversion_reports" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "conversion_id" uuid, + "dedupe_key" text NOT NULL, + "action_tracker_id" integer NOT NULL, + "order_id" text NOT NULL, + "state" text DEFAULT 'queued' NOT NULL, + "request_payload" jsonb, + "response_payload" jsonb, + "response_status_code" integer, + "attempt_count" integer DEFAULT 0 NOT NULL, + "next_retry_at" timestamp with time zone, + "delivered_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_impact_conversion_reports_dedupe_key" UNIQUE("dedupe_key"), + CONSTRAINT "impact_conversion_reports_state_check" CHECK ("impact_conversion_reports"."state" IN ('queued', 'retrying', 'delivered', 'failed')), + CONSTRAINT "impact_conversion_reports_attempt_count_non_negative_check" CHECK ("impact_conversion_reports"."attempt_count" >= 0) +); +--> statement-breakpoint +CREATE TABLE "kiloclaw_attribution_touches" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "dedupe_key" text NOT NULL, + "anonymous_id" text, + "user_id" text, + "touch_type" text NOT NULL, + "provider" text NOT NULL, + "opaque_tracking_value" text, + "tracking_value_length" integer NOT NULL, + "is_tracking_value_accepted" boolean DEFAULT true NOT NULL, + "rs_code" text, + "rs_share_medium" text, + "rs_engagement_medium" text, + "im_ref" text, + "landing_path" text, + "utm_source" text, + "utm_medium" text, + "utm_campaign" text, + "utm_term" text, + "utm_content" text, + "touched_at" timestamp with time zone NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "sale_attributed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_kiloclaw_attribution_touches_dedupe_key" UNIQUE("dedupe_key"), + CONSTRAINT "kiloclaw_attribution_touches_touch_type_check" CHECK ("kiloclaw_attribution_touches"."touch_type" IN ('affiliate', 'referral')), + CONSTRAINT "kiloclaw_attribution_touches_provider_check" CHECK ("kiloclaw_attribution_touches"."provider" IN ('impact_performance', 'impact_advocate')), + CONSTRAINT "kiloclaw_attribution_touches_tracking_value_length_non_negative_check" CHECK ("kiloclaw_attribution_touches"."tracking_value_length" >= 0) +); +--> statement-breakpoint +CREATE TABLE "kiloclaw_referral_conversions" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "referee_user_id" text NOT NULL, + "referrer_user_id" text, + "source_touch_id" uuid, + "winning_touch_type" text NOT NULL, + "source_payment_id" text NOT NULL, + "qualified" boolean DEFAULT false NOT NULL, + "disqualification_reason" text, + "converted_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_kiloclaw_referral_conversions_source_payment_id" UNIQUE("source_payment_id"), + CONSTRAINT "kiloclaw_referral_conversions_winning_touch_type_check" CHECK ("kiloclaw_referral_conversions"."winning_touch_type" IN ('referral', 'affiliate', 'none')) +); +--> statement-breakpoint +CREATE TABLE "kiloclaw_referral_reward_applications" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "reward_id" uuid NOT NULL, + "beneficiary_user_id" text NOT NULL, + "subscription_id" uuid, + "previous_renewal_boundary" timestamp with time zone NOT NULL, + "new_renewal_boundary" timestamp with time zone NOT NULL, + "local_operation_id" text, + "stripe_operation_id" text, + "stripe_idempotency_key" text, + "applied_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "kiloclaw_referral_reward_decisions" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "conversion_id" uuid NOT NULL, + "beneficiary_user_id" text NOT NULL, + "beneficiary_role" text NOT NULL, + "outcome" text NOT NULL, + "reason" text, + "months_granted" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_kiloclaw_referral_reward_decisions_conversion_role" UNIQUE("conversion_id","beneficiary_role"), + CONSTRAINT "kiloclaw_referral_reward_decisions_beneficiary_role_check" CHECK ("kiloclaw_referral_reward_decisions"."beneficiary_role" IN ('referrer', 'referee')), + CONSTRAINT "kiloclaw_referral_reward_decisions_outcome_check" CHECK ("kiloclaw_referral_reward_decisions"."outcome" IN ('granted', 'cap_limited', 'disqualified')), + CONSTRAINT "kiloclaw_referral_reward_decisions_months_granted_non_negative_check" CHECK ("kiloclaw_referral_reward_decisions"."months_granted" >= 0) +); +--> statement-breakpoint +CREATE TABLE "kiloclaw_referral_rewards" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "conversion_id" uuid NOT NULL, + "decision_id" uuid NOT NULL, + "beneficiary_user_id" text NOT NULL, + "beneficiary_role" text NOT NULL, + "months_granted" integer DEFAULT 1 NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "applies_to_subscription_id" uuid, + "earned_at" timestamp with time zone NOT NULL, + "applied_at" timestamp with time zone, + "reversed_at" timestamp with time zone, + "expires_at" timestamp with time zone, + "review_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_kiloclaw_referral_rewards_conversion_role" UNIQUE("conversion_id","beneficiary_role"), + CONSTRAINT "UQ_kiloclaw_referral_rewards_decision_id" UNIQUE("decision_id"), + CONSTRAINT "kiloclaw_referral_rewards_beneficiary_role_check" CHECK ("kiloclaw_referral_rewards"."beneficiary_role" IN ('referrer', 'referee')), + CONSTRAINT "kiloclaw_referral_rewards_status_check" CHECK ("kiloclaw_referral_rewards"."status" IN ('pending', 'earned', 'applied', 'reversed', 'expired', 'canceled', 'review_required')), + CONSTRAINT "kiloclaw_referral_rewards_months_granted_positive_check" CHECK ("kiloclaw_referral_rewards"."months_granted" > 0) +); +--> statement-breakpoint +CREATE TABLE "kiloclaw_referrals" ( + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "referee_user_id" text NOT NULL, + "referrer_user_id" text, + "source_touch_id" uuid, + "impact_referral_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "UQ_kiloclaw_referrals_referee_user_id" UNIQUE("referee_user_id") +); +--> statement-breakpoint +ALTER TABLE "impact_advocate_participants" ADD CONSTRAINT "impact_advocate_participants_user_id_kilocode_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "impact_advocate_registration_attempts" ADD CONSTRAINT "impact_advocate_registration_attempts_participant_id_impact_advocate_participants_id_fk" FOREIGN KEY ("participant_id") REFERENCES "public"."impact_advocate_participants"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "impact_advocate_reward_redemptions" ADD CONSTRAINT "impact_advocate_reward_redemptions_reward_id_kiloclaw_referral_rewards_id_fk" FOREIGN KEY ("reward_id") REFERENCES "public"."kiloclaw_referral_rewards"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "impact_advocate_reward_redemptions" ADD CONSTRAINT "impact_advocate_reward_redemptions_beneficiary_user_id_kilocode_users_id_fk" FOREIGN KEY ("beneficiary_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "impact_conversion_reports" ADD CONSTRAINT "impact_conversion_reports_conversion_id_kiloclaw_referral_conversions_id_fk" FOREIGN KEY ("conversion_id") REFERENCES "public"."kiloclaw_referral_conversions"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_attribution_touches" ADD CONSTRAINT "kiloclaw_attribution_touches_user_id_kilocode_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_conversions" ADD CONSTRAINT "kiloclaw_referral_conversions_referee_user_id_kilocode_users_id_fk" FOREIGN KEY ("referee_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_conversions" ADD CONSTRAINT "kiloclaw_referral_conversions_referrer_user_id_kilocode_users_id_fk" FOREIGN KEY ("referrer_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_conversions" ADD CONSTRAINT "kiloclaw_referral_conversions_source_touch_id_kiloclaw_attribution_touches_id_fk" FOREIGN KEY ("source_touch_id") REFERENCES "public"."kiloclaw_attribution_touches"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_reward_applications" ADD CONSTRAINT "kiloclaw_referral_reward_applications_reward_id_kiloclaw_referral_rewards_id_fk" FOREIGN KEY ("reward_id") REFERENCES "public"."kiloclaw_referral_rewards"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_reward_applications" ADD CONSTRAINT "kiloclaw_referral_reward_applications_beneficiary_user_id_kilocode_users_id_fk" FOREIGN KEY ("beneficiary_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_reward_decisions" ADD CONSTRAINT "kiloclaw_referral_reward_decisions_conversion_id_kiloclaw_referral_conversions_id_fk" FOREIGN KEY ("conversion_id") REFERENCES "public"."kiloclaw_referral_conversions"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_reward_decisions" ADD CONSTRAINT "kiloclaw_referral_reward_decisions_beneficiary_user_id_kilocode_users_id_fk" FOREIGN KEY ("beneficiary_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_rewards" ADD CONSTRAINT "kiloclaw_referral_rewards_conversion_id_kiloclaw_referral_conversions_id_fk" FOREIGN KEY ("conversion_id") REFERENCES "public"."kiloclaw_referral_conversions"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_rewards" ADD CONSTRAINT "kiloclaw_referral_rewards_decision_id_kiloclaw_referral_reward_decisions_id_fk" FOREIGN KEY ("decision_id") REFERENCES "public"."kiloclaw_referral_reward_decisions"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referral_rewards" ADD CONSTRAINT "kiloclaw_referral_rewards_beneficiary_user_id_kilocode_users_id_fk" FOREIGN KEY ("beneficiary_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referrals" ADD CONSTRAINT "kiloclaw_referrals_referee_user_id_kilocode_users_id_fk" FOREIGN KEY ("referee_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referrals" ADD CONSTRAINT "kiloclaw_referrals_referrer_user_id_kilocode_users_id_fk" FOREIGN KEY ("referrer_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "kiloclaw_referrals" ADD CONSTRAINT "kiloclaw_referrals_source_touch_id_kiloclaw_attribution_touches_id_fk" FOREIGN KEY ("source_touch_id") REFERENCES "public"."kiloclaw_attribution_touches"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +CREATE INDEX "IDX_impact_advocate_participants_registration_state" ON "impact_advocate_participants" USING btree ("registration_state");--> statement-breakpoint +CREATE INDEX "IDX_impact_advocate_registration_attempts_participant_id" ON "impact_advocate_registration_attempts" USING btree ("participant_id");--> statement-breakpoint +CREATE INDEX "IDX_impact_advocate_registration_attempts_delivery_state" ON "impact_advocate_registration_attempts" USING btree ("delivery_state");--> statement-breakpoint +CREATE INDEX "IDX_impact_advocate_reward_redemptions_beneficiary_user_id" ON "impact_advocate_reward_redemptions" USING btree ("beneficiary_user_id");--> statement-breakpoint +CREATE INDEX "IDX_impact_advocate_reward_redemptions_state" ON "impact_advocate_reward_redemptions" USING btree ("state");--> statement-breakpoint +CREATE INDEX "IDX_impact_conversion_reports_conversion_id" ON "impact_conversion_reports" USING btree ("conversion_id");--> statement-breakpoint +CREATE INDEX "IDX_impact_conversion_reports_state" ON "impact_conversion_reports" USING btree ("state");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_attribution_touches_user_id" ON "kiloclaw_attribution_touches" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_attribution_touches_anonymous_id" ON "kiloclaw_attribution_touches" USING btree ("anonymous_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_attribution_touches_expires_at" ON "kiloclaw_attribution_touches" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_attribution_touches_sale_attributed_at" ON "kiloclaw_attribution_touches" USING btree ("sale_attributed_at");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referral_conversions_referee_user_id" ON "kiloclaw_referral_conversions" USING btree ("referee_user_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referral_conversions_referrer_user_id" ON "kiloclaw_referral_conversions" USING btree ("referrer_user_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referral_reward_applications_reward_id" ON "kiloclaw_referral_reward_applications" USING btree ("reward_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referral_reward_applications_beneficiary_user_id" ON "kiloclaw_referral_reward_applications" USING btree ("beneficiary_user_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referral_reward_decisions_beneficiary_user_id" ON "kiloclaw_referral_reward_decisions" USING btree ("beneficiary_user_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referral_rewards_beneficiary_user_id" ON "kiloclaw_referral_rewards" USING btree ("beneficiary_user_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referral_rewards_status" ON "kiloclaw_referral_rewards" USING btree ("status");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referrals_referrer_user_id" ON "kiloclaw_referrals" USING btree ("referrer_user_id");--> statement-breakpoint +CREATE INDEX "IDX_kiloclaw_referrals_source_touch_id" ON "kiloclaw_referrals" USING btree ("source_touch_id"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0119_snapshot.json b/packages/db/src/migrations/meta/0119_snapshot.json new file mode 100644 index 0000000000..6cd5fe1215 --- /dev/null +++ b/packages/db/src/migrations/meta/0119_snapshot.json @@ -0,0 +1,20895 @@ +{ + "id": "baa49fb9-8dcd-47b5-a0b6-e05aae7250b2", + "prevId": "6099d909-8168-469c-a73f-f7cdbf127379", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent_configs": { + "name": "agent_configs", + "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 + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "runtime_state": { + "name": "runtime_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "IDX_agent_configs_org_id": { + "name": "IDX_agent_configs_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_configs_owned_by_user_id": { + "name": "IDX_agent_configs_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_configs_agent_type": { + "name": "IDX_agent_configs_agent_type", + "columns": [ + { + "expression": "agent_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_configs_platform": { + "name": "IDX_agent_configs_platform", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_configs_owned_by_organization_id_organizations_id_fk": { + "name": "agent_configs_owned_by_organization_id_organizations_id_fk", + "tableFrom": "agent_configs", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_configs_owned_by_user_id_kilocode_users_id_fk": { + "name": "agent_configs_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "agent_configs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_configs_org_agent_platform": { + "name": "UQ_agent_configs_org_agent_platform", + "nullsNotDistinct": false, + "columns": [ + "owned_by_organization_id", + "agent_type", + "platform" + ] + }, + "UQ_agent_configs_user_agent_platform": { + "name": "UQ_agent_configs_user_agent_platform", + "nullsNotDistinct": false, + "columns": [ + "owned_by_user_id", + "agent_type", + "platform" + ] + } + }, + "policies": {}, + "checkConstraints": { + "agent_configs_owner_check": { + "name": "agent_configs_owner_check", + "value": "(\n (\"agent_configs\".\"owned_by_user_id\" IS NOT NULL AND \"agent_configs\".\"owned_by_organization_id\" IS NULL) OR\n (\"agent_configs\".\"owned_by_user_id\" IS NULL AND \"agent_configs\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "agent_configs_agent_type_check": { + "name": "agent_configs_agent_type_check", + "value": "\"agent_configs\".\"agent_type\" IN ('code_review', 'auto_triage', 'auto_fix', 'security_scan')" + } + }, + "isRLSEnabled": false + }, + "public.agent_environment_profile_agents": { + "name": "agent_environment_profile_agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "IDX_agent_env_profile_agents_profile_id": { + "name": "IDX_agent_env_profile_agents_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_agents_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_agents_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_agents", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_agents_profile_slug": { + "name": "UQ_agent_env_profile_agents_profile_slug", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_commands": { + "name": "agent_environment_profile_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_commands_profile_id": { + "name": "IDX_agent_env_profile_commands_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_commands_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_commands_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_commands", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_commands_profile_sequence": { + "name": "UQ_agent_env_profile_commands_profile_sequence", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "sequence" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_mcp_servers": { + "name": "agent_environment_profile_mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "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": { + "IDX_agent_env_profile_mcp_servers_profile_id": { + "name": "IDX_agent_env_profile_mcp_servers_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_mcp_servers_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_mcp_servers_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_mcp_servers", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_mcp_servers_profile_name": { + "name": "UQ_agent_env_profile_mcp_servers_profile_name", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_repo_bindings": { + "name": "agent_environment_profile_repo_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_agent_env_profile_repo_bindings_user": { + "name": "UQ_agent_env_profile_repo_bindings_user", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profile_repo_bindings\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profile_repo_bindings_org": { + "name": "UQ_agent_env_profile_repo_bindings_org", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profile_repo_bindings\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_repo_bindings_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_repo_bindings_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_repo_bindings", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_environment_profile_repo_bindings_owned_by_organization_id_organizations_id_fk": { + "name": "agent_environment_profile_repo_bindings_owned_by_organization_id_organizations_id_fk", + "tableFrom": "agent_environment_profile_repo_bindings", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_environment_profile_repo_bindings_owned_by_user_id_kilocode_users_id_fk": { + "name": "agent_environment_profile_repo_bindings_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "agent_environment_profile_repo_bindings", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_env_profile_repo_bindings_owner_check": { + "name": "agent_env_profile_repo_bindings_owner_check", + "value": "(\n (\"agent_environment_profile_repo_bindings\".\"owned_by_user_id\" IS NOT NULL AND \"agent_environment_profile_repo_bindings\".\"owned_by_organization_id\" IS NULL) OR\n (\"agent_environment_profile_repo_bindings\".\"owned_by_user_id\" IS NULL AND \"agent_environment_profile_repo_bindings\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.agent_environment_profile_skills": { + "name": "agent_environment_profile_skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_markdown": { + "name": "raw_markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "IDX_agent_env_profile_skills_profile_id": { + "name": "IDX_agent_env_profile_skills_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_skills_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_skills_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_skills", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_skills_profile_name": { + "name": "UQ_agent_env_profile_skills_profile_name", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_vars": { + "name": "agent_environment_profile_vars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "IDX_agent_env_profile_vars_profile_id": { + "name": "IDX_agent_env_profile_vars_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_vars_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_vars_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_vars", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_vars_profile_key": { + "name": "UQ_agent_env_profile_vars_profile_key", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profiles": { + "name": "agent_environment_profiles", + "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 + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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_agent_env_profiles_org_name": { + "name": "UQ_agent_env_profiles_org_name", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profiles_user_name": { + "name": "UQ_agent_env_profiles_user_name", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profiles_org_default": { + "name": "UQ_agent_env_profiles_org_default", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"is_default\" = true AND \"agent_environment_profiles\".\"owned_by_organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profiles_user_default": { + "name": "UQ_agent_env_profiles_user_default", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"is_default\" = true AND \"agent_environment_profiles\".\"owned_by_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_env_profiles_org_id": { + "name": "IDX_agent_env_profiles_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_env_profiles_user_id": { + "name": "IDX_agent_env_profiles_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_env_profiles_created_by_user_id": { + "name": "IDX_agent_env_profiles_created_by_user_id", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profiles_owned_by_organization_id_organizations_id_fk": { + "name": "agent_environment_profiles_owned_by_organization_id_organizations_id_fk", + "tableFrom": "agent_environment_profiles", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_environment_profiles_owned_by_user_id_kilocode_users_id_fk": { + "name": "agent_environment_profiles_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "agent_environment_profiles", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_env_profiles_owner_check": { + "name": "agent_env_profiles_owner_check", + "value": "(\n (\"agent_environment_profiles\".\"owned_by_user_id\" IS NOT NULL AND \"agent_environment_profiles\".\"owned_by_organization_id\" IS NULL) OR\n (\"agent_environment_profiles\".\"owned_by_user_id\" IS NULL AND \"agent_environment_profiles\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.api_kind": { + "name": "api_kind", + "schema": "", + "columns": { + "api_kind_id": { + "name": "api_kind_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "api_kind": { + "name": "api_kind", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_api_kind": { + "name": "UQ_api_kind", + "columns": [ + { + "expression": "api_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_request_log": { + "name": "api_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_api_request_log_created_at": { + "name": "idx_api_request_log_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_builder_feedback": { + "name": "app_builder_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_status": { + "name": "preview_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_streaming": { + "name": "is_streaming", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "feedback_text": { + "name": "feedback_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recent_messages": { + "name": "recent_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_app_builder_feedback_created_at": { + "name": "IDX_app_builder_feedback_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_feedback_kilo_user_id": { + "name": "IDX_app_builder_feedback_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_feedback_project_id": { + "name": "IDX_app_builder_feedback_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_builder_feedback_kilo_user_id_kilocode_users_id_fk": { + "name": "app_builder_feedback_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "app_builder_feedback", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "app_builder_feedback_project_id_app_builder_projects_id_fk": { + "name": "app_builder_feedback_project_id_app_builder_projects_id_fk", + "tableFrom": "app_builder_feedback", + "tableTo": "app_builder_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_builder_project_sessions": { + "name": "app_builder_project_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "worker_version": { + "name": "worker_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'v2'" + } + }, + "indexes": { + "IDX_app_builder_project_sessions_project_id": { + "name": "IDX_app_builder_project_sessions_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_builder_project_sessions_project_id_app_builder_projects_id_fk": { + "name": "app_builder_project_sessions_project_id_app_builder_projects_id_fk", + "tableFrom": "app_builder_project_sessions", + "tableTo": "app_builder_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_app_builder_project_sessions_cloud_agent_session_id": { + "name": "UQ_app_builder_project_sessions_cloud_agent_session_id", + "nullsNotDistinct": false, + "columns": [ + "cloud_agent_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_builder_projects": { + "name": "app_builder_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "git_repo_full_name": { + "name": "git_repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_platform_integration_id": { + "name": "git_platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "migrated_at": { + "name": "migrated_at", + "type": "timestamp with time zone", + "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": { + "IDX_app_builder_projects_created_by_user_id": { + "name": "IDX_app_builder_projects_created_by_user_id", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_owned_by_user_id": { + "name": "IDX_app_builder_projects_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_owned_by_organization_id": { + "name": "IDX_app_builder_projects_owned_by_organization_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_created_at": { + "name": "IDX_app_builder_projects_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_last_message_at": { + "name": "IDX_app_builder_projects_last_message_at", + "columns": [ + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_builder_projects_owned_by_user_id_kilocode_users_id_fk": { + "name": "app_builder_projects_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "app_builder_projects_owned_by_organization_id_organizations_id_fk": { + "name": "app_builder_projects_owned_by_organization_id_organizations_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "app_builder_projects_deployment_id_deployments_id_fk": { + "name": "app_builder_projects_deployment_id_deployments_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "app_builder_projects_git_platform_integration_id_platform_integrations_id_fk": { + "name": "app_builder_projects_git_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "platform_integrations", + "columnsFrom": [ + "git_platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_builder_projects_owner_check": { + "name": "app_builder_projects_owner_check", + "value": "(\n (\"app_builder_projects\".\"owned_by_user_id\" IS NOT NULL AND \"app_builder_projects\".\"owned_by_organization_id\" IS NULL) OR\n (\"app_builder_projects\".\"owned_by_user_id\" IS NULL AND \"app_builder_projects\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.app_min_versions": { + "name": "app_min_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "ios_min_version": { + "name": "ios_min_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "android_min_version": { + "name": "android_min_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_reported_messages": { + "name": "app_reported_messages", + "schema": "", + "columns": { + "report_id": { + "name": "report_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "report_type": { + "name": "report_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "app_reported_messages_cli_session_id_cli_sessions_session_id_fk": { + "name": "app_reported_messages_cli_session_id_cli_sessions_session_id_fk", + "tableFrom": "app_reported_messages", + "tableTo": "cli_sessions", + "columnsFrom": [ + "cli_session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_fix_tickets": { + "name": "auto_fix_tickets", + "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 + }, + "triage_ticket_id": { + "name": "triage_ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issue_url": { + "name": "issue_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_title": { + "name": "issue_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_body": { + "name": "issue_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_author": { + "name": "issue_author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_labels": { + "name": "issue_labels", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "trigger_source": { + "name": "trigger_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'label'" + }, + "review_comment_id": { + "name": "review_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "review_comment_body": { + "name": "review_comment_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "diff_hunk": { + "name": "diff_hunk", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_head_ref": { + "name": "pr_head_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "classification": { + "name": "classification", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "intent_summary": { + "name": "intent_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_files": { + "name": "related_files", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_branch": { + "name": "pr_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "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_auto_fix_tickets_repo_issue": { + "name": "UQ_auto_fix_tickets_repo_issue", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_fix_tickets\".\"trigger_source\" = 'label'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_auto_fix_tickets_repo_review_comment": { + "name": "UQ_auto_fix_tickets_repo_review_comment", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "review_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_fix_tickets\".\"review_comment_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_owned_by_org": { + "name": "IDX_auto_fix_tickets_owned_by_org", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_owned_by_user": { + "name": "IDX_auto_fix_tickets_owned_by_user", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_status": { + "name": "IDX_auto_fix_tickets_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_created_at": { + "name": "IDX_auto_fix_tickets_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_triage_ticket_id": { + "name": "IDX_auto_fix_tickets_triage_ticket_id", + "columns": [ + { + "expression": "triage_ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_session_id": { + "name": "IDX_auto_fix_tickets_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_fix_tickets_owned_by_organization_id_organizations_id_fk": { + "name": "auto_fix_tickets_owned_by_organization_id_organizations_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_fix_tickets_owned_by_user_id_kilocode_users_id_fk": { + "name": "auto_fix_tickets_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_fix_tickets_platform_integration_id_platform_integrations_id_fk": { + "name": "auto_fix_tickets_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auto_fix_tickets_triage_ticket_id_auto_triage_tickets_id_fk": { + "name": "auto_fix_tickets_triage_ticket_id_auto_triage_tickets_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "auto_triage_tickets", + "columnsFrom": [ + "triage_ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auto_fix_tickets_cli_session_id_cli_sessions_session_id_fk": { + "name": "auto_fix_tickets_cli_session_id_cli_sessions_session_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "cli_sessions", + "columnsFrom": [ + "cli_session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auto_fix_tickets_owner_check": { + "name": "auto_fix_tickets_owner_check", + "value": "(\n (\"auto_fix_tickets\".\"owned_by_user_id\" IS NOT NULL AND \"auto_fix_tickets\".\"owned_by_organization_id\" IS NULL) OR\n (\"auto_fix_tickets\".\"owned_by_user_id\" IS NULL AND \"auto_fix_tickets\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "auto_fix_tickets_status_check": { + "name": "auto_fix_tickets_status_check", + "value": "\"auto_fix_tickets\".\"status\" IN ('pending', 'running', 'completed', 'failed', 'cancelled')" + }, + "auto_fix_tickets_classification_check": { + "name": "auto_fix_tickets_classification_check", + "value": "\"auto_fix_tickets\".\"classification\" IN ('bug', 'feature', 'question', 'unclear')" + }, + "auto_fix_tickets_confidence_check": { + "name": "auto_fix_tickets_confidence_check", + "value": "\"auto_fix_tickets\".\"confidence\" >= 0 AND \"auto_fix_tickets\".\"confidence\" <= 1" + }, + "auto_fix_tickets_trigger_source_check": { + "name": "auto_fix_tickets_trigger_source_check", + "value": "\"auto_fix_tickets\".\"trigger_source\" IN ('label', 'review_comment')" + } + }, + "isRLSEnabled": false + }, + "public.auto_model": { + "name": "auto_model", + "schema": "", + "columns": { + "auto_model_id": { + "name": "auto_model_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "auto_model": { + "name": "auto_model", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_auto_model": { + "name": "UQ_auto_model", + "columns": [ + { + "expression": "auto_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_top_up_configs": { + "name": "auto_top_up_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_method_id": { + "name": "stripe_payment_method_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5000 + }, + "last_auto_top_up_at": { + "name": "last_auto_top_up_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_started_at": { + "name": "attempt_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_reason": { + "name": "disabled_reason", + "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_auto_top_up_configs_owned_by_user_id": { + "name": "UQ_auto_top_up_configs_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_top_up_configs\".\"owned_by_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_auto_top_up_configs_owned_by_organization_id": { + "name": "UQ_auto_top_up_configs_owned_by_organization_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_top_up_configs\".\"owned_by_organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_top_up_configs_owned_by_user_id_kilocode_users_id_fk": { + "name": "auto_top_up_configs_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "auto_top_up_configs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "auto_top_up_configs_owned_by_organization_id_organizations_id_fk": { + "name": "auto_top_up_configs_owned_by_organization_id_organizations_id_fk", + "tableFrom": "auto_top_up_configs", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auto_top_up_configs_exactly_one_owner": { + "name": "auto_top_up_configs_exactly_one_owner", + "value": "(\"auto_top_up_configs\".\"owned_by_user_id\" IS NOT NULL AND \"auto_top_up_configs\".\"owned_by_organization_id\" IS NULL) OR (\"auto_top_up_configs\".\"owned_by_user_id\" IS NULL AND \"auto_top_up_configs\".\"owned_by_organization_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.auto_triage_tickets": { + "name": "auto_triage_tickets", + "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 + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issue_url": { + "name": "issue_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_title": { + "name": "issue_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_body": { + "name": "issue_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_author": { + "name": "issue_author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_type": { + "name": "issue_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_labels": { + "name": "issue_labels", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "classification": { + "name": "classification", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "intent_summary": { + "name": "intent_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_files": { + "name": "related_files", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "is_duplicate": { + "name": "is_duplicate", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duplicate_of_ticket_id": { + "name": "duplicate_of_ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "similarity_score": { + "name": "similarity_score", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "qdrant_point_id": { + "name": "qdrant_point_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "should_auto_fix": { + "name": "should_auto_fix", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "action_taken": { + "name": "action_taken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_metadata": { + "name": "action_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "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_auto_triage_tickets_repo_issue": { + "name": "UQ_auto_triage_tickets_repo_issue", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_owned_by_org": { + "name": "IDX_auto_triage_tickets_owned_by_org", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_owned_by_user": { + "name": "IDX_auto_triage_tickets_owned_by_user", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_status": { + "name": "IDX_auto_triage_tickets_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_created_at": { + "name": "IDX_auto_triage_tickets_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_qdrant_point_id": { + "name": "IDX_auto_triage_tickets_qdrant_point_id", + "columns": [ + { + "expression": "qdrant_point_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_owner_status_created": { + "name": "IDX_auto_triage_tickets_owner_status_created", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_user_status_created": { + "name": "IDX_auto_triage_tickets_user_status_created", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_repo_classification": { + "name": "IDX_auto_triage_tickets_repo_classification", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "classification", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_triage_tickets_owned_by_organization_id_organizations_id_fk": { + "name": "auto_triage_tickets_owned_by_organization_id_organizations_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_triage_tickets_owned_by_user_id_kilocode_users_id_fk": { + "name": "auto_triage_tickets_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_triage_tickets_platform_integration_id_platform_integrations_id_fk": { + "name": "auto_triage_tickets_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auto_triage_tickets_duplicate_of_ticket_id_auto_triage_tickets_id_fk": { + "name": "auto_triage_tickets_duplicate_of_ticket_id_auto_triage_tickets_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "auto_triage_tickets", + "columnsFrom": [ + "duplicate_of_ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auto_triage_tickets_owner_check": { + "name": "auto_triage_tickets_owner_check", + "value": "(\n (\"auto_triage_tickets\".\"owned_by_user_id\" IS NOT NULL AND \"auto_triage_tickets\".\"owned_by_organization_id\" IS NULL) OR\n (\"auto_triage_tickets\".\"owned_by_user_id\" IS NULL AND \"auto_triage_tickets\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "auto_triage_tickets_issue_type_check": { + "name": "auto_triage_tickets_issue_type_check", + "value": "\"auto_triage_tickets\".\"issue_type\" IN ('issue', 'pull_request')" + }, + "auto_triage_tickets_classification_check": { + "name": "auto_triage_tickets_classification_check", + "value": "\"auto_triage_tickets\".\"classification\" IN ('bug', 'feature', 'question', 'duplicate', 'unclear')" + }, + "auto_triage_tickets_confidence_check": { + "name": "auto_triage_tickets_confidence_check", + "value": "\"auto_triage_tickets\".\"confidence\" >= 0 AND \"auto_triage_tickets\".\"confidence\" <= 1" + }, + "auto_triage_tickets_similarity_score_check": { + "name": "auto_triage_tickets_similarity_score_check", + "value": "\"auto_triage_tickets\".\"similarity_score\" >= 0 AND \"auto_triage_tickets\".\"similarity_score\" <= 1" + }, + "auto_triage_tickets_status_check": { + "name": "auto_triage_tickets_status_check", + "value": "\"auto_triage_tickets\".\"status\" IN ('pending', 'analyzing', 'actioned', 'failed', 'skipped')" + }, + "auto_triage_tickets_action_taken_check": { + "name": "auto_triage_tickets_action_taken_check", + "value": "\"auto_triage_tickets\".\"action_taken\" IN ('pr_created', 'comment_posted', 'closed_duplicate', 'needs_clarification')" + } + }, + "isRLSEnabled": false + }, + "public.bot_request_cloud_agent_sessions": { + "name": "bot_request_cloud_agent_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "bot_request_id": { + "name": "bot_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "spawn_group_id": { + "name": "spawn_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_session_id": { + "name": "kilo_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repo": { + "name": "github_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitlab_project": { + "name": "gitlab_project", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_step": { + "name": "callback_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "final_message": { + "name": "final_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "final_message_fetched_at": { + "name": "final_message_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "final_message_error": { + "name": "final_message_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "continuation_started_at": { + "name": "continuation_started_at", + "type": "timestamp with time zone", + "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_bot_request_cas_cloud_agent_session_id": { + "name": "UQ_bot_request_cas_cloud_agent_session_id", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_request_cas_bot_request_id": { + "name": "IDX_bot_request_cas_bot_request_id", + "columns": [ + { + "expression": "bot_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_request_cas_bot_request_id_spawn_group_id": { + "name": "IDX_bot_request_cas_bot_request_id_spawn_group_id", + "columns": [ + { + "expression": "bot_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spawn_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_request_cas_bot_request_id_spawn_group_id_status": { + "name": "IDX_bot_request_cas_bot_request_id_spawn_group_id_status", + "columns": [ + { + "expression": "bot_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spawn_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_request_cloud_agent_sessions_bot_request_id_bot_requests_id_fk": { + "name": "bot_request_cloud_agent_sessions_bot_request_id_bot_requests_id_fk", + "tableFrom": "bot_request_cloud_agent_sessions", + "tableTo": "bot_requests", + "columnsFrom": [ + "bot_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_requests": { + "name": "bot_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_thread_id": { + "name": "platform_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_message_id": { + "name": "platform_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_message": { + "name": "user_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_used": { + "name": "model_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "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": { + "IDX_bot_requests_created_at": { + "name": "IDX_bot_requests_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_created_by": { + "name": "IDX_bot_requests_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_organization_id": { + "name": "IDX_bot_requests_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_platform_integration_id": { + "name": "IDX_bot_requests_platform_integration_id", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_status": { + "name": "IDX_bot_requests_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_requests_created_by_kilocode_users_id_fk": { + "name": "bot_requests_created_by_kilocode_users_id_fk", + "tableFrom": "bot_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_requests_organization_id_organizations_id_fk": { + "name": "bot_requests_organization_id_organizations_id_fk", + "tableFrom": "bot_requests", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_requests_platform_integration_id_platform_integrations_id_fk": { + "name": "bot_requests_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "bot_requests", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.byok_api_keys": { + "name": "byok_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_byok_api_keys_organization_id": { + "name": "IDX_byok_api_keys_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_byok_api_keys_kilo_user_id": { + "name": "IDX_byok_api_keys_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_byok_api_keys_provider_id": { + "name": "IDX_byok_api_keys_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "byok_api_keys_organization_id_organizations_id_fk": { + "name": "byok_api_keys_organization_id_organizations_id_fk", + "tableFrom": "byok_api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "byok_api_keys_kilo_user_id_kilocode_users_id_fk": { + "name": "byok_api_keys_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "byok_api_keys", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_byok_api_keys_org_provider": { + "name": "UQ_byok_api_keys_org_provider", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "provider_id" + ] + }, + "UQ_byok_api_keys_user_provider": { + "name": "UQ_byok_api_keys_user_provider", + "nullsNotDistinct": false, + "columns": [ + "kilo_user_id", + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "byok_api_keys_owner_check": { + "name": "byok_api_keys_owner_check", + "value": "(\n (\"byok_api_keys\".\"kilo_user_id\" IS NOT NULL AND \"byok_api_keys\".\"organization_id\" IS NULL) OR\n (\"byok_api_keys\".\"kilo_user_id\" IS NULL AND \"byok_api_keys\".\"organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.cli_sessions": { + "name": "cli_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_on_platform": { + "name": "created_on_platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "api_conversation_history_blob_url": { + "name": "api_conversation_history_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_metadata_blob_url": { + "name": "task_metadata_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ui_messages_blob_url": { + "name": "ui_messages_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_state_blob_url": { + "name": "git_state_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_url": { + "name": "git_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from": { + "name": "forked_from", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_mode": { + "name": "last_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_model": { + "name": "last_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "IDX_cli_sessions_kilo_user_id": { + "name": "IDX_cli_sessions_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_created_at": { + "name": "IDX_cli_sessions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_updated_at": { + "name": "IDX_cli_sessions_updated_at", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_organization_id": { + "name": "IDX_cli_sessions_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_user_updated": { + "name": "IDX_cli_sessions_user_updated", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cli_sessions_kilo_user_id_kilocode_users_id_fk": { + "name": "cli_sessions_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "cli_sessions_forked_from_cli_sessions_session_id_fk": { + "name": "cli_sessions_forked_from_cli_sessions_session_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "cli_sessions", + "columnsFrom": [ + "forked_from" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_sessions_parent_session_id_cli_sessions_session_id_fk": { + "name": "cli_sessions_parent_session_id_cli_sessions_session_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "cli_sessions", + "columnsFrom": [ + "parent_session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_sessions_organization_id_organizations_id_fk": { + "name": "cli_sessions_organization_id_organizations_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cli_sessions_cloud_agent_session_id_unique": { + "name": "cli_sessions_cloud_agent_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "cloud_agent_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cli_sessions_v2": { + "name": "cli_sessions_v2", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_on_platform": { + "name": "created_on_platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "git_url": { + "name": "git_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_updated_at": { + "name": "status_updated_at", + "type": "timestamp with time zone", + "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": { + "IDX_cli_sessions_v2_parent_session_id_kilo_user_id": { + "name": "IDX_cli_sessions_v2_parent_session_id_kilo_user_id", + "columns": [ + { + "expression": "parent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cli_sessions_v2_public_id": { + "name": "UQ_cli_sessions_v2_public_id", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cli_sessions_v2\".\"public_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cli_sessions_v2_cloud_agent_session_id": { + "name": "UQ_cli_sessions_v2_cloud_agent_session_id", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cli_sessions_v2\".\"cloud_agent_session_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_organization_id": { + "name": "IDX_cli_sessions_v2_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_kilo_user_id": { + "name": "IDX_cli_sessions_v2_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_created_at": { + "name": "IDX_cli_sessions_v2_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_user_updated": { + "name": "IDX_cli_sessions_v2_user_updated", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cli_sessions_v2_kilo_user_id_kilocode_users_id_fk": { + "name": "cli_sessions_v2_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "cli_sessions_v2", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "cli_sessions_v2_organization_id_organizations_id_fk": { + "name": "cli_sessions_v2_organization_id_organizations_id_fk", + "tableFrom": "cli_sessions_v2", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_sessions_v2_parent_session_id_kilo_user_id_fk": { + "name": "cli_sessions_v2_parent_session_id_kilo_user_id_fk", + "tableFrom": "cli_sessions_v2", + "tableTo": "cli_sessions_v2", + "columnsFrom": [ + "parent_session_id", + "kilo_user_id" + ], + "columnsTo": [ + "session_id", + "kilo_user_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "cli_sessions_v2_session_id_kilo_user_id_pk": { + "name": "cli_sessions_v2_session_id_kilo_user_id_pk", + "columns": [ + "session_id", + "kilo_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_agent_code_reviews": { + "name": "cloud_agent_code_reviews", + "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 + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_title": { + "name": "pr_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_author": { + "name": "pr_author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_author_github_id": { + "name": "pr_author_github_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_ref": { + "name": "head_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "platform_project_id": { + "name": "platform_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_reason": { + "name": "terminal_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'v1'" + }, + "check_run_id": { + "name": "check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_tokens_in": { + "name": "total_tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_tokens_out": { + "name": "total_tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_cost_musd": { + "name": "total_cost_musd", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "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_cloud_agent_code_reviews_repo_pr_sha": { + "name": "UQ_cloud_agent_code_reviews_repo_pr_sha", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "head_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_owned_by_org_id": { + "name": "idx_cloud_agent_code_reviews_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_owned_by_user_id": { + "name": "idx_cloud_agent_code_reviews_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_session_id": { + "name": "idx_cloud_agent_code_reviews_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_cli_session_id": { + "name": "idx_cloud_agent_code_reviews_cli_session_id", + "columns": [ + { + "expression": "cli_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_status": { + "name": "idx_cloud_agent_code_reviews_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_repo": { + "name": "idx_cloud_agent_code_reviews_repo", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_pr_number": { + "name": "idx_cloud_agent_code_reviews_pr_number", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_created_at": { + "name": "idx_cloud_agent_code_reviews_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_pr_author_github_id": { + "name": "idx_cloud_agent_code_reviews_pr_author_github_id", + "columns": [ + { + "expression": "pr_author_github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_code_reviews_owned_by_organization_id_organizations_id_fk": { + "name": "cloud_agent_code_reviews_owned_by_organization_id_organizations_id_fk", + "tableFrom": "cloud_agent_code_reviews", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_code_reviews_owned_by_user_id_kilocode_users_id_fk": { + "name": "cloud_agent_code_reviews_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_agent_code_reviews", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_code_reviews_platform_integration_id_platform_integrations_id_fk": { + "name": "cloud_agent_code_reviews_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "cloud_agent_code_reviews", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cloud_agent_code_reviews_owner_check": { + "name": "cloud_agent_code_reviews_owner_check", + "value": "(\n (\"cloud_agent_code_reviews\".\"owned_by_user_id\" IS NOT NULL AND \"cloud_agent_code_reviews\".\"owned_by_organization_id\" IS NULL) OR\n (\"cloud_agent_code_reviews\".\"owned_by_user_id\" IS NULL AND \"cloud_agent_code_reviews\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.cloud_agent_feedback": { + "name": "cloud_agent_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_streaming": { + "name": "is_streaming", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "feedback_text": { + "name": "feedback_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recent_messages": { + "name": "recent_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_cloud_agent_feedback_created_at": { + "name": "IDX_cloud_agent_feedback_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_feedback_kilo_user_id": { + "name": "IDX_cloud_agent_feedback_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_feedback_cloud_agent_session_id": { + "name": "IDX_cloud_agent_feedback_cloud_agent_session_id", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_feedback_kilo_user_id_kilocode_users_id_fk": { + "name": "cloud_agent_feedback_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_agent_feedback", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "cloud_agent_feedback_organization_id_organizations_id_fk": { + "name": "cloud_agent_feedback_organization_id_organizations_id_fk", + "tableFrom": "cloud_agent_feedback", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_agent_webhook_triggers": { + "name": "cloud_agent_webhook_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trigger_id": { + "name": "trigger_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cloud_agent'" + }, + "kiloclaw_instance_id": { + "name": "kiloclaw_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "activation_mode": { + "name": "activation_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'webhook'" + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_timezone": { + "name": "cron_timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "github_repo": { + "name": "github_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "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_cloud_agent_webhook_triggers_user_trigger": { + "name": "UQ_cloud_agent_webhook_triggers_user_trigger", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cloud_agent_webhook_triggers\".\"user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cloud_agent_webhook_triggers_org_trigger": { + "name": "UQ_cloud_agent_webhook_triggers_org_trigger", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cloud_agent_webhook_triggers\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_user": { + "name": "IDX_cloud_agent_webhook_triggers_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_org": { + "name": "IDX_cloud_agent_webhook_triggers_org", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_active": { + "name": "IDX_cloud_agent_webhook_triggers_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_profile": { + "name": "IDX_cloud_agent_webhook_triggers_profile", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_webhook_triggers_user_id_kilocode_users_id_fk": { + "name": "cloud_agent_webhook_triggers_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_webhook_triggers_organization_id_organizations_id_fk": { + "name": "cloud_agent_webhook_triggers_organization_id_organizations_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_webhook_triggers_kiloclaw_instance_id_kiloclaw_instances_id_fk": { + "name": "cloud_agent_webhook_triggers_kiloclaw_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "kiloclaw_instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cloud_agent_webhook_triggers_profile_id_agent_environment_profiles_id_fk": { + "name": "cloud_agent_webhook_triggers_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "CHK_cloud_agent_webhook_triggers_owner": { + "name": "CHK_cloud_agent_webhook_triggers_owner", + "value": "(\n (\"cloud_agent_webhook_triggers\".\"user_id\" IS NOT NULL AND \"cloud_agent_webhook_triggers\".\"organization_id\" IS NULL) OR\n (\"cloud_agent_webhook_triggers\".\"user_id\" IS NULL AND \"cloud_agent_webhook_triggers\".\"organization_id\" IS NOT NULL)\n )" + }, + "CHK_cloud_agent_webhook_triggers_cloud_agent_fields": { + "name": "CHK_cloud_agent_webhook_triggers_cloud_agent_fields", + "value": "(\n \"cloud_agent_webhook_triggers\".\"target_type\" != 'cloud_agent' OR\n (\"cloud_agent_webhook_triggers\".\"github_repo\" IS NOT NULL AND \"cloud_agent_webhook_triggers\".\"profile_id\" IS NOT NULL)\n )" + }, + "CHK_cloud_agent_webhook_triggers_kiloclaw_fields": { + "name": "CHK_cloud_agent_webhook_triggers_kiloclaw_fields", + "value": "(\n \"cloud_agent_webhook_triggers\".\"target_type\" != 'kiloclaw_chat' OR\n \"cloud_agent_webhook_triggers\".\"kiloclaw_instance_id\" IS NOT NULL\n )" + }, + "CHK_cloud_agent_webhook_triggers_scheduled_fields": { + "name": "CHK_cloud_agent_webhook_triggers_scheduled_fields", + "value": "(\n \"cloud_agent_webhook_triggers\".\"activation_mode\" != 'scheduled' OR\n \"cloud_agent_webhook_triggers\".\"cron_expression\" IS NOT NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.code_indexing_manifest": { + "name": "code_indexing_manifest", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_lines": { + "name": "total_lines", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_ai_lines": { + "name": "total_ai_lines", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_code_indexing_manifest_organization_id": { + "name": "IDX_code_indexing_manifest_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_kilo_user_id": { + "name": "IDX_code_indexing_manifest_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_project_id": { + "name": "IDX_code_indexing_manifest_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_file_hash": { + "name": "IDX_code_indexing_manifest_file_hash", + "columns": [ + { + "expression": "file_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_git_branch": { + "name": "IDX_code_indexing_manifest_git_branch", + "columns": [ + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_created_at": { + "name": "IDX_code_indexing_manifest_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "code_indexing_manifest_kilo_user_id_kilocode_users_id_fk": { + "name": "code_indexing_manifest_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "code_indexing_manifest", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_code_indexing_manifest_org_user_project_hash_branch": { + "name": "UQ_code_indexing_manifest_org_user_project_hash_branch", + "nullsNotDistinct": true, + "columns": [ + "organization_id", + "kilo_user_id", + "project_id", + "file_path", + "git_branch" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.code_indexing_search": { + "name": "code_indexing_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_code_indexing_search_organization_id": { + "name": "IDX_code_indexing_search_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_search_kilo_user_id": { + "name": "IDX_code_indexing_search_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_search_project_id": { + "name": "IDX_code_indexing_search_project_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_search_created_at": { + "name": "IDX_code_indexing_search_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "code_indexing_search_kilo_user_id_kilocode_users_id_fk": { + "name": "code_indexing_search_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "code_indexing_search", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contributor_champion_contributors": { + "name": "contributor_champion_contributors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_profile_url": { + "name": "github_profile_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_user_id": { + "name": "github_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "first_contribution_at": { + "name": "first_contribution_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_contribution_at": { + "name": "last_contribution_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "all_time_contributions": { + "name": "all_time_contributions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "manual_email": { + "name": "manual_email", + "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": { + "IDX_contributor_champion_contributors_last_contribution_at": { + "name": "IDX_contributor_champion_contributors_last_contribution_at", + "columns": [ + { + "expression": "last_contribution_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_contributors_manual_email": { + "name": "IDX_contributor_champion_contributors_manual_email", + "columns": [ + { + "expression": "manual_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_contributor_champion_contributors_github_login": { + "name": "UQ_contributor_champion_contributors_github_login", + "nullsNotDistinct": false, + "columns": [ + "github_login" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contributor_champion_events": { + "name": "contributor_champion_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "contributor_id": { + "name": "contributor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_pr_number": { + "name": "github_pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "github_pr_url": { + "name": "github_pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_pr_title": { + "name": "github_pr_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_author_login": { + "name": "github_author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_author_email": { + "name": "github_author_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_contributor_champion_events_contributor_id": { + "name": "IDX_contributor_champion_events_contributor_id", + "columns": [ + { + "expression": "contributor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_events_merged_at": { + "name": "IDX_contributor_champion_events_merged_at", + "columns": [ + { + "expression": "merged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_events_author_email": { + "name": "IDX_contributor_champion_events_author_email", + "columns": [ + { + "expression": "github_author_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contributor_champion_events_contributor_id_contributor_champion_contributors_id_fk": { + "name": "contributor_champion_events_contributor_id_contributor_champion_contributors_id_fk", + "tableFrom": "contributor_champion_events", + "tableTo": "contributor_champion_contributors", + "columnsFrom": [ + "contributor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_contributor_champion_events_repo_pr": { + "name": "UQ_contributor_champion_events_repo_pr", + "nullsNotDistinct": false, + "columns": [ + "repo_full_name", + "github_pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contributor_champion_memberships": { + "name": "contributor_champion_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "contributor_id": { + "name": "contributor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selected_tier": { + "name": "selected_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrolled_tier": { + "name": "enrolled_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "credit_amount_microdollars": { + "name": "credit_amount_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credits_last_granted_at": { + "name": "credits_last_granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "linked_kilo_user_id": { + "name": "linked_kilo_user_id", + "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": { + "IDX_contributor_champion_memberships_credits_due": { + "name": "IDX_contributor_champion_memberships_credits_due", + "columns": [ + { + "expression": "credits_last_granted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"contributor_champion_memberships\".\"enrolled_tier\" IS NOT NULL AND \"contributor_champion_memberships\".\"credit_amount_microdollars\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_memberships_linked_kilo_user_id": { + "name": "IDX_contributor_champion_memberships_linked_kilo_user_id", + "columns": [ + { + "expression": "linked_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contributor_champion_memberships_contributor_id_contributor_champion_contributors_id_fk": { + "name": "contributor_champion_memberships_contributor_id_contributor_champion_contributors_id_fk", + "tableFrom": "contributor_champion_memberships", + "tableTo": "contributor_champion_contributors", + "columnsFrom": [ + "contributor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "contributor_champion_memberships_linked_kilo_user_id_kilocode_users_id_fk": { + "name": "contributor_champion_memberships_linked_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "contributor_champion_memberships", + "tableTo": "kilocode_users", + "columnsFrom": [ + "linked_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_contributor_champion_memberships_contributor_id": { + "name": "UQ_contributor_champion_memberships_contributor_id", + "nullsNotDistinct": false, + "columns": [ + "contributor_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "contributor_champion_memberships_selected_tier_check": { + "name": "contributor_champion_memberships_selected_tier_check", + "value": "\"contributor_champion_memberships\".\"selected_tier\" IS NULL OR \"contributor_champion_memberships\".\"selected_tier\" IN ('contributor', 'ambassador', 'champion')" + }, + "contributor_champion_memberships_enrolled_tier_check": { + "name": "contributor_champion_memberships_enrolled_tier_check", + "value": "\"contributor_champion_memberships\".\"enrolled_tier\" IS NULL OR \"contributor_champion_memberships\".\"enrolled_tier\" IN ('contributor', 'ambassador', 'champion')" + } + }, + "isRLSEnabled": false + }, + "public.contributor_champion_sync_state": { + "name": "contributor_champion_sync_state", + "schema": "", + "columns": { + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_merged_at": { + "name": "last_merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_campaigns": { + "name": "credit_campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credit_category": { + "name": "credit_category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_microdollars": { + "name": "amount_microdollars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_expiry_hours": { + "name": "credit_expiry_hours", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_ends_at": { + "name": "campaign_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_redemptions_allowed": { + "name": "total_redemptions_allowed", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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_credit_campaigns_slug": { + "name": "UQ_credit_campaigns_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_credit_campaigns_credit_category": { + "name": "UQ_credit_campaigns_credit_category", + "columns": [ + { + "expression": "credit_category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credit_campaigns_slug_format_check": { + "name": "credit_campaigns_slug_format_check", + "value": "\"credit_campaigns\".\"slug\" ~ '^[a-z0-9-]{5,40}$'" + }, + "credit_campaigns_amount_positive_check": { + "name": "credit_campaigns_amount_positive_check", + "value": "\"credit_campaigns\".\"amount_microdollars\" > 0" + }, + "credit_campaigns_credit_expiry_hours_positive_check": { + "name": "credit_campaigns_credit_expiry_hours_positive_check", + "value": "\"credit_campaigns\".\"credit_expiry_hours\" IS NULL OR \"credit_campaigns\".\"credit_expiry_hours\" > 0" + }, + "credit_campaigns_total_redemptions_allowed_positive_check": { + "name": "credit_campaigns_total_redemptions_allowed_positive_check", + "value": "\"credit_campaigns\".\"total_redemptions_allowed\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.credit_transactions": { + "name": "credit_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_microdollars": { + "name": "amount_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expiration_baseline_microdollars_used": { + "name": "expiration_baseline_microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "original_baseline_microdollars_used": { + "name": "original_baseline_microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_transaction_id": { + "name": "original_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_id": { + "name": "stripe_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coinbase_credit_block_id": { + "name": "coinbase_credit_block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credit_category": { + "name": "credit_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "check_category_uniqueness": { + "name": "check_category_uniqueness", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "IDX_credit_transactions_created_at": { + "name": "IDX_credit_transactions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_is_free": { + "name": "IDX_credit_transactions_is_free", + "columns": [ + { + "expression": "is_free", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_kilo_user_id": { + "name": "IDX_credit_transactions_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_credit_category": { + "name": "IDX_credit_transactions_credit_category", + "columns": [ + { + "expression": "credit_category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_stripe_payment_id": { + "name": "IDX_credit_transactions_stripe_payment_id", + "columns": [ + { + "expression": "stripe_payment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_original_transaction_id": { + "name": "IDX_credit_transactions_original_transaction_id", + "columns": [ + { + "expression": "original_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_coinbase_credit_block_id": { + "name": "IDX_credit_transactions_coinbase_credit_block_id", + "columns": [ + { + "expression": "coinbase_credit_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_organization_id": { + "name": "IDX_credit_transactions_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_unique_category": { + "name": "IDX_credit_transactions_unique_category", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credit_category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credit_transactions\".\"check_category_uniqueness\" = TRUE", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_llm2": { + "name": "custom_llm2", + "schema": "", + "columns": { + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deleted_user_email_tombstones": { + "name": "deleted_user_email_tombstones", + "schema": "", + "columns": { + "normalized_email_hash": { + "name": "normalized_email_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_builds": { + "name": "deployment_builds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_builds_deployment_id": { + "name": "idx_deployment_builds_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_builds_status": { + "name": "idx_deployment_builds_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_builds_deployment_id_deployments_id_fk": { + "name": "deployment_builds_deployment_id_deployments_id_fk", + "tableFrom": "deployment_builds", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_env_vars": { + "name": "deployment_env_vars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "idx_deployment_env_vars_deployment_id": { + "name": "idx_deployment_env_vars_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_env_vars_deployment_id_deployments_id_fk": { + "name": "deployment_env_vars_deployment_id_deployments_id_fk", + "tableFrom": "deployment_env_vars", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_deployment_env_vars_deployment_key": { + "name": "UQ_deployment_env_vars_deployment_key", + "nullsNotDistinct": false, + "columns": [ + "deployment_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_events": { + "name": "deployment_events", + "schema": "", + "columns": { + "build_id": { + "name": "build_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'log'" + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_deployment_events_build_id": { + "name": "idx_deployment_events_build_id", + "columns": [ + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_events_timestamp": { + "name": "idx_deployment_events_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_events_type": { + "name": "idx_deployment_events_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_events_build_id_deployment_builds_id_fk": { + "name": "deployment_events_build_id_deployment_builds_id_fk", + "tableFrom": "deployment_events", + "tableTo": "deployment_builds", + "columnsFrom": [ + "build_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deployment_events_build_id_event_id_pk": { + "name": "deployment_events_build_id_event_id_pk", + "columns": [ + "build_id", + "event_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_threat_detections": { + "name": "deployment_threat_detections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "build_id": { + "name": "build_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "threat_type": { + "name": "threat_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_threat_detections_deployment_id": { + "name": "idx_deployment_threat_detections_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_threat_detections_created_at": { + "name": "idx_deployment_threat_detections_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_threat_detections_deployment_id_deployments_id_fk": { + "name": "deployment_threat_detections_deployment_id_deployments_id_fk", + "tableFrom": "deployment_threat_detections", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_threat_detections_build_id_deployment_builds_id_fk": { + "name": "deployment_threat_detections_build_id_deployment_builds_id_fk", + "tableFrom": "deployment_threat_detections", + "tableTo": "deployment_builds", + "columnsFrom": [ + "build_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "deployment_slug": { + "name": "deployment_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_worker_name": { + "name": "internal_worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_source": { + "name": "repository_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_url": { + "name": "deployment_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "git_auth_token": { + "name": "git_auth_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_deployed_at": { + "name": "last_deployed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_build_id": { + "name": "last_build_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "threat_status": { + "name": "threat_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_from": { + "name": "created_from", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_deployments_owned_by_user_id": { + "name": "idx_deployments_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_owned_by_organization_id": { + "name": "idx_deployments_owned_by_organization_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_platform_integration_id": { + "name": "idx_deployments_platform_integration_id", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_repository_source_branch": { + "name": "idx_deployments_repository_source_branch", + "columns": [ + { + "expression": "repository_source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_threat_status_pending": { + "name": "idx_deployments_threat_status_pending", + "columns": [ + { + "expression": "threat_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"deployments\".\"threat_status\" = 'pending_scan'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_owned_by_user_id_kilocode_users_id_fk": { + "name": "deployments_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "deployments", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deployments_owned_by_organization_id_organizations_id_fk": { + "name": "deployments_owned_by_organization_id_organizations_id_fk", + "tableFrom": "deployments", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_deployments_deployment_slug": { + "name": "UQ_deployments_deployment_slug", + "nullsNotDistinct": false, + "columns": [ + "deployment_slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "deployments_owner_check": { + "name": "deployments_owner_check", + "value": "(\n (\"deployments\".\"owned_by_user_id\" IS NOT NULL AND \"deployments\".\"owned_by_organization_id\" IS NULL) OR\n (\"deployments\".\"owned_by_user_id\" IS NULL AND \"deployments\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "deployments_source_type_check": { + "name": "deployments_source_type_check", + "value": "\"deployments\".\"source_type\" IN ('github', 'git', 'app-builder')" + } + }, + "isRLSEnabled": false + }, + "public.device_auth_requests": { + "name": "device_auth_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "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_device_auth_requests_code": { + "name": "UQ_device_auth_requests_code", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_auth_requests_status": { + "name": "IDX_device_auth_requests_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_auth_requests_expires_at": { + "name": "IDX_device_auth_requests_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_auth_requests_kilo_user_id": { + "name": "IDX_device_auth_requests_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_auth_requests_kilo_user_id_kilocode_users_id_fk": { + "name": "device_auth_requests_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "device_auth_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_gateway_listener": { + "name": "discord_gateway_listener", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "listener_id": { + "name": "listener_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.editor_name": { + "name": "editor_name", + "schema": "", + "columns": { + "editor_name_id": { + "name": "editor_name_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "editor_name": { + "name": "editor_name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_editor_name": { + "name": "UQ_editor_name", + "columns": [ + { + "expression": "editor_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enrichment_data": { + "name": "enrichment_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_enrichment_data": { + "name": "github_enrichment_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "linkedin_enrichment_data": { + "name": "linkedin_enrichment_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "clay_enrichment_data": { + "name": "clay_enrichment_data", + "type": "jsonb", + "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": { + "IDX_enrichment_data_user_id": { + "name": "IDX_enrichment_data_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "enrichment_data_user_id_kilocode_users_id_fk": { + "name": "enrichment_data_user_id_kilocode_users_id_fk", + "tableFrom": "enrichment_data", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_enrichment_data_user_id": { + "name": "UQ_enrichment_data_user_id", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exa_monthly_usage": { + "name": "exa_monthly_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "month": { + "name": "month", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "total_cost_microdollars": { + "name": "total_cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_charged_microdollars": { + "name": "total_charged_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "free_allowance_microdollars": { + "name": "free_allowance_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 10000000 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_exa_monthly_usage_personal": { + "name": "idx_exa_monthly_usage_personal", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"exa_monthly_usage\".\"organization_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_exa_monthly_usage_org": { + "name": "idx_exa_monthly_usage_org", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"exa_monthly_usage\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exa_usage_log": { + "name": "exa_usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_microdollars": { + "name": "cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "charged_to_balance": { + "name": "charged_to_balance", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_exa_usage_log_user_created": { + "name": "idx_exa_usage_log_user_created", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "exa_usage_log_id_created_at_pk": { + "name": "exa_usage_log_id_created_at_pk", + "columns": [ + "id", + "created_at" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feature": { + "name": "feature", + "schema": "", + "columns": { + "feature_id": { + "name": "feature_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_feature": { + "name": "UQ_feature", + "columns": [ + { + "expression": "feature", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.finish_reason": { + "name": "finish_reason", + "schema": "", + "columns": { + "finish_reason_id": { + "name": "finish_reason_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "finish_reason": { + "name": "finish_reason", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_finish_reason": { + "name": "UQ_finish_reason", + "columns": [ + { + "expression": "finish_reason", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_model_usage": { + "name": "free_model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_free_model_usage_ip_created_at": { + "name": "idx_free_model_usage_ip_created_at", + "columns": [ + { + "expression": "ip_address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_free_model_usage_created_at": { + "name": "idx_free_model_usage_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_free_model_usage_user_created_at": { + "name": "idx_free_model_usage_user_created_at", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"free_model_usage\".\"kilo_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.http_ip": { + "name": "http_ip", + "schema": "", + "columns": { + "http_ip_id": { + "name": "http_ip_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "http_ip": { + "name": "http_ip", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_http_ip": { + "name": "UQ_http_ip", + "columns": [ + { + "expression": "http_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.http_user_agent": { + "name": "http_user_agent", + "schema": "", + "columns": { + "http_user_agent_id": { + "name": "http_user_agent_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "http_user_agent": { + "name": "http_user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_http_user_agent": { + "name": "UQ_http_user_agent", + "columns": [ + { + "expression": "http_user_agent", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impact_advocate_participants": { + "name": "impact_advocate_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "advocate_id": { + "name": "advocate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "advocate_account_id": { + "name": "advocate_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opaque_referral_identifier": { + "name": "opaque_referral_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_state": { + "name": "registration_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "registered_at": { + "name": "registered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_registration_attempt_at": { + "name": "last_registration_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "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": { + "IDX_impact_advocate_participants_registration_state": { + "name": "IDX_impact_advocate_participants_registration_state", + "columns": [ + { + "expression": "registration_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_advocate_participants_user_id_kilocode_users_id_fk": { + "name": "impact_advocate_participants_user_id_kilocode_users_id_fk", + "tableFrom": "impact_advocate_participants", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_advocate_participants_user_id": { + "name": "UQ_impact_advocate_participants_user_id", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "UQ_impact_advocate_participants_opaque_referral_identifier": { + "name": "UQ_impact_advocate_participants_opaque_referral_identifier", + "nullsNotDistinct": false, + "columns": [ + "opaque_referral_identifier" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_advocate_participants_registration_state_check": { + "name": "impact_advocate_participants_registration_state_check", + "value": "\"impact_advocate_participants\".\"registration_state\" IN ('pending', 'retrying', 'registered', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.impact_advocate_registration_attempts": { + "name": "impact_advocate_registration_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opaque_cookie_value": { + "name": "opaque_cookie_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cookie_value_length": { + "name": "cookie_value_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "delivery_state": { + "name": "delivery_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "request_payload": { + "name": "request_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_payload": { + "name": "response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_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 + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "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": { + "IDX_impact_advocate_registration_attempts_participant_id": { + "name": "IDX_impact_advocate_registration_attempts_participant_id", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_advocate_registration_attempts_delivery_state": { + "name": "IDX_impact_advocate_registration_attempts_delivery_state", + "columns": [ + { + "expression": "delivery_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_advocate_registration_attempts_participant_id_impact_advocate_participants_id_fk": { + "name": "impact_advocate_registration_attempts_participant_id_impact_advocate_participants_id_fk", + "tableFrom": "impact_advocate_registration_attempts", + "tableTo": "impact_advocate_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_advocate_registration_attempts_dedupe_key": { + "name": "UQ_impact_advocate_registration_attempts_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_advocate_registration_attempts_delivery_state_check": { + "name": "impact_advocate_registration_attempts_delivery_state_check", + "value": "\"impact_advocate_registration_attempts\".\"delivery_state\" IN ('queued', 'sending', 'succeeded', 'failed')" + }, + "impact_advocate_registration_attempts_cookie_value_length_non_negative_check": { + "name": "impact_advocate_registration_attempts_cookie_value_length_non_negative_check", + "value": "\"impact_advocate_registration_attempts\".\"cookie_value_length\" >= 0" + }, + "impact_advocate_registration_attempts_attempt_count_non_negative_check": { + "name": "impact_advocate_registration_attempts_attempt_count_non_negative_check", + "value": "\"impact_advocate_registration_attempts\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_advocate_reward_redemptions": { + "name": "impact_advocate_reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "reward_id": { + "name": "reward_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "impact_reward_id": { + "name": "impact_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_payload": { + "name": "request_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lookup_response_payload": { + "name": "lookup_response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "redeem_response_payload": { + "name": "redeem_response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_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 + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "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": { + "IDX_impact_advocate_reward_redemptions_beneficiary_user_id": { + "name": "IDX_impact_advocate_reward_redemptions_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_advocate_reward_redemptions_state": { + "name": "IDX_impact_advocate_reward_redemptions_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_advocate_reward_redemptions_reward_id_kiloclaw_referral_rewards_id_fk": { + "name": "impact_advocate_reward_redemptions_reward_id_kiloclaw_referral_rewards_id_fk", + "tableFrom": "impact_advocate_reward_redemptions", + "tableTo": "kiloclaw_referral_rewards", + "columnsFrom": [ + "reward_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_advocate_reward_redemptions_beneficiary_user_id_kilocode_users_id_fk": { + "name": "impact_advocate_reward_redemptions_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "impact_advocate_reward_redemptions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_advocate_reward_redemptions_reward_id": { + "name": "UQ_impact_advocate_reward_redemptions_reward_id", + "nullsNotDistinct": false, + "columns": [ + "reward_id" + ] + }, + "UQ_impact_advocate_reward_redemptions_dedupe_key": { + "name": "UQ_impact_advocate_reward_redemptions_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_advocate_reward_redemptions_state_check": { + "name": "impact_advocate_reward_redemptions_state_check", + "value": "\"impact_advocate_reward_redemptions\".\"state\" IN ('queued', 'retrying', 'redeemed', 'failed')" + }, + "impact_advocate_reward_redemptions_attempt_count_non_negative_check": { + "name": "impact_advocate_reward_redemptions_attempt_count_non_negative_check", + "value": "\"impact_advocate_reward_redemptions\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_conversion_reports": { + "name": "impact_conversion_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "conversion_id": { + "name": "conversion_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_tracker_id": { + "name": "action_tracker_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "request_payload": { + "name": "request_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_payload": { + "name": "response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_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 + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "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": { + "IDX_impact_conversion_reports_conversion_id": { + "name": "IDX_impact_conversion_reports_conversion_id", + "columns": [ + { + "expression": "conversion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_conversion_reports_state": { + "name": "IDX_impact_conversion_reports_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_conversion_reports_conversion_id_kiloclaw_referral_conversions_id_fk": { + "name": "impact_conversion_reports_conversion_id_kiloclaw_referral_conversions_id_fk", + "tableFrom": "impact_conversion_reports", + "tableTo": "kiloclaw_referral_conversions", + "columnsFrom": [ + "conversion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_conversion_reports_dedupe_key": { + "name": "UQ_impact_conversion_reports_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_conversion_reports_state_check": { + "name": "impact_conversion_reports_state_check", + "value": "\"impact_conversion_reports\".\"state\" IN ('queued', 'retrying', 'delivered', 'failed')" + }, + "impact_conversion_reports_attempt_count_non_negative_check": { + "name": "impact_conversion_reports_attempt_count_non_negative_check", + "value": "\"impact_conversion_reports\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.ja4_digest": { + "name": "ja4_digest", + "schema": "", + "columns": { + "ja4_digest_id": { + "name": "ja4_digest_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ja4_digest": { + "name": "ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_ja4_digest": { + "name": "UQ_ja4_digest", + "columns": [ + { + "expression": "ja4_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kilo_pass_audit_log": { + "name": "kilo_pass_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kilo_pass_subscription_id": { + "name": "kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_event_id": { + "name": "stripe_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_credit_transaction_id": { + "name": "related_credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "related_monthly_issuance_id": { + "name": "related_monthly_issuance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "IDX_kilo_pass_audit_log_created_at": { + "name": "IDX_kilo_pass_audit_log_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_kilo_user_id": { + "name": "IDX_kilo_pass_audit_log_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_kilo_pass_subscription_id": { + "name": "IDX_kilo_pass_audit_log_kilo_pass_subscription_id", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_action": { + "name": "IDX_kilo_pass_audit_log_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_result": { + "name": "IDX_kilo_pass_audit_log_result", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_idempotency_key": { + "name": "IDX_kilo_pass_audit_log_idempotency_key", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_stripe_event_id": { + "name": "IDX_kilo_pass_audit_log_stripe_event_id", + "columns": [ + { + "expression": "stripe_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_stripe_invoice_id": { + "name": "IDX_kilo_pass_audit_log_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_stripe_subscription_id": { + "name": "IDX_kilo_pass_audit_log_stripe_subscription_id", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_related_credit_transaction_id": { + "name": "IDX_kilo_pass_audit_log_related_credit_transaction_id", + "columns": [ + { + "expression": "related_credit_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_related_monthly_issuance_id": { + "name": "IDX_kilo_pass_audit_log_related_monthly_issuance_id", + "columns": [ + { + "expression": "related_monthly_issuance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_audit_log_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_audit_log_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_audit_log_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk": { + "name": "kilo_pass_audit_log_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_audit_log_related_credit_transaction_id_credit_transactions_id_fk": { + "name": "kilo_pass_audit_log_related_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "credit_transactions", + "columnsFrom": [ + "related_credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_audit_log_related_monthly_issuance_id_kilo_pass_issuances_id_fk": { + "name": "kilo_pass_audit_log_related_monthly_issuance_id_kilo_pass_issuances_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "kilo_pass_issuances", + "columnsFrom": [ + "related_monthly_issuance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_audit_log_action_check": { + "name": "kilo_pass_audit_log_action_check", + "value": "\"kilo_pass_audit_log\".\"action\" IN ('stripe_webhook_received', 'kilo_pass_invoice_paid_handled', 'base_credits_issued', 'bonus_credits_issued', 'bonus_credits_skipped_idempotent', 'first_month_50pct_promo_issued', 'yearly_monthly_base_cron_started', 'yearly_monthly_base_cron_completed', 'issue_yearly_remaining_credits', 'yearly_monthly_bonus_cron_started', 'yearly_monthly_bonus_cron_completed')" + }, + "kilo_pass_audit_log_result_check": { + "name": "kilo_pass_audit_log_result_check", + "value": "\"kilo_pass_audit_log\".\"result\" IN ('success', 'skipped_idempotent', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_issuance_items": { + "name": "kilo_pass_issuance_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_pass_issuance_id": { + "name": "kilo_pass_issuance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credit_transaction_id": { + "name": "credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "bonus_percent_applied": { + "name": "bonus_percent_applied", + "type": "numeric(6, 4)", + "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": { + "IDX_kilo_pass_issuance_items_issuance_id": { + "name": "IDX_kilo_pass_issuance_items_issuance_id", + "columns": [ + { + "expression": "kilo_pass_issuance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_issuance_items_credit_transaction_id": { + "name": "IDX_kilo_pass_issuance_items_credit_transaction_id", + "columns": [ + { + "expression": "credit_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_issuance_items_kilo_pass_issuance_id_kilo_pass_issuances_id_fk": { + "name": "kilo_pass_issuance_items_kilo_pass_issuance_id_kilo_pass_issuances_id_fk", + "tableFrom": "kilo_pass_issuance_items", + "tableTo": "kilo_pass_issuances", + "columnsFrom": [ + "kilo_pass_issuance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_issuance_items_credit_transaction_id_credit_transactions_id_fk": { + "name": "kilo_pass_issuance_items_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "kilo_pass_issuance_items", + "tableTo": "credit_transactions", + "columnsFrom": [ + "credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kilo_pass_issuance_items_credit_transaction_id_unique": { + "name": "kilo_pass_issuance_items_credit_transaction_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credit_transaction_id" + ] + }, + "UQ_kilo_pass_issuance_items_issuance_kind": { + "name": "UQ_kilo_pass_issuance_items_issuance_kind", + "nullsNotDistinct": false, + "columns": [ + "kilo_pass_issuance_id", + "kind" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_issuance_items_bonus_percent_applied_range_check": { + "name": "kilo_pass_issuance_items_bonus_percent_applied_range_check", + "value": "\"kilo_pass_issuance_items\".\"bonus_percent_applied\" IS NULL OR (\"kilo_pass_issuance_items\".\"bonus_percent_applied\" >= 0 AND \"kilo_pass_issuance_items\".\"bonus_percent_applied\" <= 1)" + }, + "kilo_pass_issuance_items_amount_usd_non_negative_check": { + "name": "kilo_pass_issuance_items_amount_usd_non_negative_check", + "value": "\"kilo_pass_issuance_items\".\"amount_usd\" >= 0" + }, + "kilo_pass_issuance_items_kind_check": { + "name": "kilo_pass_issuance_items_kind_check", + "value": "\"kilo_pass_issuance_items\".\"kind\" IN ('base', 'bonus', 'promo_first_month_50pct')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_issuances": { + "name": "kilo_pass_issuances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_pass_subscription_id": { + "name": "kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_month": { + "name": "issue_month", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "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_kilo_pass_issuances_stripe_invoice_id": { + "name": "UQ_kilo_pass_issuances_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_issuances\".\"stripe_invoice_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_issuances_subscription_id": { + "name": "IDX_kilo_pass_issuances_subscription_id", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_issuances_issue_month": { + "name": "IDX_kilo_pass_issuances_issue_month", + "columns": [ + { + "expression": "issue_month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_issuances_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk": { + "name": "kilo_pass_issuances_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk", + "tableFrom": "kilo_pass_issuances", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_issuances_subscription_issue_month": { + "name": "UQ_kilo_pass_issuances_subscription_issue_month", + "nullsNotDistinct": false, + "columns": [ + "kilo_pass_subscription_id", + "issue_month" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_issuances_issue_month_day_one_check": { + "name": "kilo_pass_issuances_issue_month_day_one_check", + "value": "EXTRACT(DAY FROM \"kilo_pass_issuances\".\"issue_month\") = 1" + }, + "kilo_pass_issuances_source_check": { + "name": "kilo_pass_issuances_source_check", + "value": "\"kilo_pass_issuances\".\"source\" IN ('stripe_invoice', 'cron')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_pause_events": { + "name": "kilo_pass_pause_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_pass_subscription_id": { + "name": "kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resumes_at": { + "name": "resumes_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resumed_at": { + "name": "resumed_at", + "type": "timestamp with time zone", + "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": { + "IDX_kilo_pass_pause_events_subscription_id": { + "name": "IDX_kilo_pass_pause_events_subscription_id", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_pause_events_one_open_per_sub": { + "name": "UQ_kilo_pass_pause_events_one_open_per_sub", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_pause_events\".\"resumed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_pause_events_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk": { + "name": "kilo_pass_pause_events_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk", + "tableFrom": "kilo_pass_pause_events", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_pause_events_resumed_at_after_paused_at_check": { + "name": "kilo_pass_pause_events_resumed_at_after_paused_at_check", + "value": "\"kilo_pass_pause_events\".\"resumed_at\" IS NULL OR \"kilo_pass_pause_events\".\"resumed_at\" >= \"kilo_pass_pause_events\".\"paused_at\"" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_scheduled_changes": { + "name": "kilo_pass_scheduled_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_tier": { + "name": "from_tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_cadence": { + "name": "from_cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_tier": { + "name": "to_tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_cadence": { + "name": "to_cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "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": { + "IDX_kilo_pass_scheduled_changes_kilo_user_id": { + "name": "IDX_kilo_pass_scheduled_changes_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_status": { + "name": "IDX_kilo_pass_scheduled_changes_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_stripe_subscription_id": { + "name": "IDX_kilo_pass_scheduled_changes_stripe_subscription_id", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_scheduled_changes_active_stripe_subscription_id": { + "name": "UQ_kilo_pass_scheduled_changes_active_stripe_subscription_id", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_scheduled_changes\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_effective_at": { + "name": "IDX_kilo_pass_scheduled_changes_effective_at", + "columns": [ + { + "expression": "effective_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_deleted_at": { + "name": "IDX_kilo_pass_scheduled_changes_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_scheduled_changes_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_scheduled_changes_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_scheduled_changes", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_scheduled_changes_stripe_subscription_id_kilo_pass_subscriptions_stripe_subscription_id_fk": { + "name": "kilo_pass_scheduled_changes_stripe_subscription_id_kilo_pass_subscriptions_stripe_subscription_id_fk", + "tableFrom": "kilo_pass_scheduled_changes", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "stripe_subscription_id" + ], + "columnsTo": [ + "stripe_subscription_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_scheduled_changes_from_tier_check": { + "name": "kilo_pass_scheduled_changes_from_tier_check", + "value": "\"kilo_pass_scheduled_changes\".\"from_tier\" IN ('tier_19', 'tier_49', 'tier_199')" + }, + "kilo_pass_scheduled_changes_from_cadence_check": { + "name": "kilo_pass_scheduled_changes_from_cadence_check", + "value": "\"kilo_pass_scheduled_changes\".\"from_cadence\" IN ('monthly', 'yearly')" + }, + "kilo_pass_scheduled_changes_to_tier_check": { + "name": "kilo_pass_scheduled_changes_to_tier_check", + "value": "\"kilo_pass_scheduled_changes\".\"to_tier\" IN ('tier_19', 'tier_49', 'tier_199')" + }, + "kilo_pass_scheduled_changes_to_cadence_check": { + "name": "kilo_pass_scheduled_changes_to_cadence_check", + "value": "\"kilo_pass_scheduled_changes\".\"to_cadence\" IN ('monthly', 'yearly')" + }, + "kilo_pass_scheduled_changes_status_check": { + "name": "kilo_pass_scheduled_changes_status_check", + "value": "\"kilo_pass_scheduled_changes\".\"status\" IN ('not_started', 'active', 'completed', 'released', 'canceled')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_subscriptions": { + "name": "kilo_pass_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cadence": { + "name": "cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_streak_months": { + "name": "current_streak_months", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_yearly_issue_at": { + "name": "next_yearly_issue_at", + "type": "timestamp with time zone", + "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": { + "IDX_kilo_pass_subscriptions_kilo_user_id": { + "name": "IDX_kilo_pass_subscriptions_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_subscriptions_status": { + "name": "IDX_kilo_pass_subscriptions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_subscriptions_cadence": { + "name": "IDX_kilo_pass_subscriptions_cadence", + "columns": [ + { + "expression": "cadence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_subscriptions_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_subscriptions_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_subscriptions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kilo_pass_subscriptions_stripe_subscription_id_unique": { + "name": "kilo_pass_subscriptions_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_subscriptions_current_streak_months_non_negative_check": { + "name": "kilo_pass_subscriptions_current_streak_months_non_negative_check", + "value": "\"kilo_pass_subscriptions\".\"current_streak_months\" >= 0" + }, + "kilo_pass_subscriptions_tier_check": { + "name": "kilo_pass_subscriptions_tier_check", + "value": "\"kilo_pass_subscriptions\".\"tier\" IN ('tier_19', 'tier_49', 'tier_199')" + }, + "kilo_pass_subscriptions_cadence_check": { + "name": "kilo_pass_subscriptions_cadence_check", + "value": "\"kilo_pass_subscriptions\".\"cadence\" IN ('monthly', 'yearly')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_access_codes": { + "name": "kiloclaw_access_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kiloclaw_access_codes_code": { + "name": "UQ_kiloclaw_access_codes_code", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_access_codes_user_status": { + "name": "IDX_kiloclaw_access_codes_user_status", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_access_codes_one_active_per_user": { + "name": "UQ_kiloclaw_access_codes_one_active_per_user", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_access_codes_kilo_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_access_codes_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_access_codes", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_admin_audit_logs": { + "name": "kiloclaw_admin_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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_kiloclaw_admin_audit_logs_target_user_id": { + "name": "IDX_kiloclaw_admin_audit_logs_target_user_id", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_admin_audit_logs_action": { + "name": "IDX_kiloclaw_admin_audit_logs_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_admin_audit_logs_created_at": { + "name": "IDX_kiloclaw_admin_audit_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_attribution_touches": { + "name": "kiloclaw_attribution_touches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anonymous_id": { + "name": "anonymous_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "touch_type": { + "name": "touch_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opaque_tracking_value": { + "name": "opaque_tracking_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_value_length": { + "name": "tracking_value_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_tracking_value_accepted": { + "name": "is_tracking_value_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rs_code": { + "name": "rs_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rs_share_medium": { + "name": "rs_share_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rs_engagement_medium": { + "name": "rs_engagement_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "im_ref": { + "name": "im_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landing_path": { + "name": "landing_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "touched_at": { + "name": "touched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "sale_attributed_at": { + "name": "sale_attributed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_attribution_touches_user_id": { + "name": "IDX_kiloclaw_attribution_touches_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_attribution_touches_anonymous_id": { + "name": "IDX_kiloclaw_attribution_touches_anonymous_id", + "columns": [ + { + "expression": "anonymous_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_attribution_touches_expires_at": { + "name": "IDX_kiloclaw_attribution_touches_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_attribution_touches_sale_attributed_at": { + "name": "IDX_kiloclaw_attribution_touches_sale_attributed_at", + "columns": [ + { + "expression": "sale_attributed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_attribution_touches_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_attribution_touches_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_attribution_touches", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kiloclaw_attribution_touches_dedupe_key": { + "name": "UQ_kiloclaw_attribution_touches_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kiloclaw_attribution_touches_touch_type_check": { + "name": "kiloclaw_attribution_touches_touch_type_check", + "value": "\"kiloclaw_attribution_touches\".\"touch_type\" IN ('affiliate', 'referral')" + }, + "kiloclaw_attribution_touches_provider_check": { + "name": "kiloclaw_attribution_touches_provider_check", + "value": "\"kiloclaw_attribution_touches\".\"provider\" IN ('impact_performance', 'impact_advocate')" + }, + "kiloclaw_attribution_touches_tracking_value_length_non_negative_check": { + "name": "kiloclaw_attribution_touches_tracking_value_length_non_negative_check", + "value": "\"kiloclaw_attribution_touches\".\"tracking_value_length\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_cli_runs": { + "name": "kiloclaw_cli_runs", + "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 + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "initiated_by_admin_id": { + "name": "initiated_by_admin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_kiloclaw_cli_runs_user_id": { + "name": "IDX_kiloclaw_cli_runs_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_cli_runs_started_at": { + "name": "IDX_kiloclaw_cli_runs_started_at", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_cli_runs_instance_id": { + "name": "IDX_kiloclaw_cli_runs_instance_id", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_cli_runs_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_cli_runs_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_cli_runs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kiloclaw_cli_runs_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_cli_runs_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_cli_runs", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_cli_runs_initiated_by_admin_id_kilocode_users_id_fk": { + "name": "kiloclaw_cli_runs_initiated_by_admin_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_cli_runs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "initiated_by_admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "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": false + }, + "manual_payment_id": { + "name": "manual_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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": "no action", + "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" + ] + }, + "kiloclaw_earlybird_purchases_manual_payment_id_unique": { + "name": "kiloclaw_earlybird_purchases_manual_payment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "manual_payment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_email_log": { + "name": "kiloclaw_email_log", + "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 + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "email_type": { + "name": "email_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "'epoch'" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kiloclaw_email_log_user_type_global": { + "name": "UQ_kiloclaw_email_log_user_type_global", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_email_log\".\"instance_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_email_log_user_instance_type_period": { + "name": "UQ_kiloclaw_email_log_user_instance_type_period", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_email_log\".\"instance_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_email_log_type_sent_instance": { + "name": "IDX_kiloclaw_email_log_type_sent_instance", + "columns": [ + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_email_log\".\"instance_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_email_log_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_email_log_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_email_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_email_log_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_email_log_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_email_log", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_google_oauth_connections": { + "name": "kiloclaw_google_oauth_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'google'" + }, + "account_email": { + "name": "account_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_subject": { + "name": "account_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_secret_encrypted": { + "name": "oauth_client_secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_profile": { + "name": "credential_profile", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kilo_owned'" + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "grants_by_source": { + "name": "grants_by_source", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "capabilities": { + "name": "capabilities", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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_kiloclaw_google_oauth_connections_instance": { + "name": "UQ_kiloclaw_google_oauth_connections_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_google_oauth_connections_status": { + "name": "IDX_kiloclaw_google_oauth_connections_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_google_oauth_connections_provider": { + "name": "IDX_kiloclaw_google_oauth_connections_provider", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_google_oauth_connections_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_google_oauth_connections_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_google_oauth_connections", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kiloclaw_google_oauth_connections_status_check": { + "name": "kiloclaw_google_oauth_connections_status_check", + "value": "\"kiloclaw_google_oauth_connections\".\"status\" IN ('active', 'action_required', 'disconnected')" + }, + "kiloclaw_google_oauth_connections_credential_profile_check": { + "name": "kiloclaw_google_oauth_connections_credential_profile_check", + "value": "\"kiloclaw_google_oauth_connections\".\"credential_profile\" IN ('legacy', 'kilo_owned')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_image_catalog": { + "name": "kiloclaw_image_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "openclaw_version": { + "name": "openclaw_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "image_tag": { + "name": "image_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_digest": { + "name": "image_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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()" + }, + "rollout_percent": { + "name": "rollout_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_latest": { + "name": "is_latest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "IDX_kiloclaw_image_catalog_status": { + "name": "IDX_kiloclaw_image_catalog_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_image_catalog_variant": { + "name": "IDX_kiloclaw_image_catalog_variant", + "columns": [ + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_image_catalog_one_latest_per_variant": { + "name": "UQ_kiloclaw_image_catalog_one_latest_per_variant", + "columns": [ + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_image_catalog\".\"is_latest\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_image_catalog_one_candidate_per_variant": { + "name": "UQ_kiloclaw_image_catalog_one_candidate_per_variant", + "columns": [ + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_image_catalog\".\"is_latest\" = false AND \"kiloclaw_image_catalog\".\"rollout_percent\" > 0 AND \"kiloclaw_image_catalog\".\"status\" = 'available'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_image_catalog_image_tag_unique": { + "name": "kiloclaw_image_catalog_image_tag_unique", + "nullsNotDistinct": false, + "columns": [ + "image_tag" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_inbound_email_aliases": { + "name": "kiloclaw_inbound_email_aliases", + "schema": "", + "columns": { + "alias": { + "name": "alias", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_kiloclaw_inbound_email_aliases_instance_id": { + "name": "IDX_kiloclaw_inbound_email_aliases_instance_id", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_inbound_email_aliases_active_instance": { + "name": "UQ_kiloclaw_inbound_email_aliases_active_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_inbound_email_aliases\".\"retired_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_inbound_email_aliases_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_inbound_email_aliases_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_inbound_email_aliases", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_inbound_email_reserved_aliases": { + "name": "kiloclaw_inbound_email_reserved_aliases", + "schema": "", + "columns": { + "alias": { + "name": "alias", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_instances": { + "name": "kiloclaw_instances", + "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 + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fly'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_email_enabled": { + "name": "inbound_email_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inactive_trial_stopped_at": { + "name": "inactive_trial_stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "tracked_image_tag": { + "name": "tracked_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_size_override": { + "name": "admin_size_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_kiloclaw_instances_active": { + "name": "UQ_kiloclaw_instances_active", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sandbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_instances\".\"destroyed_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_active_personal_by_user": { + "name": "IDX_kiloclaw_instances_active_personal_by_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"organization_id\" IS NULL AND \"kiloclaw_instances\".\"destroyed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_active_org_by_user_org": { + "name": "IDX_kiloclaw_instances_active_org_by_user_org", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"organization_id\" IS NOT NULL AND \"kiloclaw_instances\".\"destroyed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_tracked_image_tag": { + "name": "IDX_kiloclaw_instances_tracked_image_tag", + "columns": [ + { + "expression": "tracked_image_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"destroyed_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_instance_type": { + "name": "IDX_kiloclaw_instances_instance_type", + "columns": [ + { + "expression": "instance_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"destroyed_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_admin_size_override": { + "name": "IDX_kiloclaw_instances_admin_size_override", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"admin_size_override\" IS NOT NULL AND \"kiloclaw_instances\".\"destroyed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_instances_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_instances_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_instances", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_instances_organization_id_organizations_id_fk": { + "name": "kiloclaw_instances_organization_id_organizations_id_fk", + "tableFrom": "kiloclaw_instances", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "CHK_kiloclaw_instances_instance_type": { + "name": "CHK_kiloclaw_instances_instance_type", + "value": "\"kiloclaw_instances\".\"instance_type\" IS NULL OR \"kiloclaw_instances\".\"instance_type\" IN ('perf-1-3', 'perf-4-8', 'perf-4-16', 'shared-2-3', 'shared-2-4', 'custom')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_referral_conversions": { + "name": "kiloclaw_referral_conversions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "referee_user_id": { + "name": "referee_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer_user_id": { + "name": "referrer_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_touch_id": { + "name": "source_touch_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "winning_touch_type": { + "name": "winning_touch_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_payment_id": { + "name": "source_payment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "qualified": { + "name": "qualified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disqualification_reason": { + "name": "disqualification_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "converted_at": { + "name": "converted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_referral_conversions_referee_user_id": { + "name": "IDX_kiloclaw_referral_conversions_referee_user_id", + "columns": [ + { + "expression": "referee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_referral_conversions_referrer_user_id": { + "name": "IDX_kiloclaw_referral_conversions_referrer_user_id", + "columns": [ + { + "expression": "referrer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_referral_conversions_referee_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_referral_conversions_referee_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_referral_conversions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kiloclaw_referral_conversions_referrer_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_referral_conversions_referrer_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_referral_conversions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referrer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kiloclaw_referral_conversions_source_touch_id_kiloclaw_attribution_touches_id_fk": { + "name": "kiloclaw_referral_conversions_source_touch_id_kiloclaw_attribution_touches_id_fk", + "tableFrom": "kiloclaw_referral_conversions", + "tableTo": "kiloclaw_attribution_touches", + "columnsFrom": [ + "source_touch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kiloclaw_referral_conversions_source_payment_id": { + "name": "UQ_kiloclaw_referral_conversions_source_payment_id", + "nullsNotDistinct": false, + "columns": [ + "source_payment_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kiloclaw_referral_conversions_winning_touch_type_check": { + "name": "kiloclaw_referral_conversions_winning_touch_type_check", + "value": "\"kiloclaw_referral_conversions\".\"winning_touch_type\" IN ('referral', 'affiliate', 'none')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_referral_reward_applications": { + "name": "kiloclaw_referral_reward_applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "reward_id": { + "name": "reward_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "previous_renewal_boundary": { + "name": "previous_renewal_boundary", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "new_renewal_boundary": { + "name": "new_renewal_boundary", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "local_operation_id": { + "name": "local_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_operation_id": { + "name": "stripe_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_idempotency_key": { + "name": "stripe_idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_referral_reward_applications_reward_id": { + "name": "IDX_kiloclaw_referral_reward_applications_reward_id", + "columns": [ + { + "expression": "reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_referral_reward_applications_beneficiary_user_id": { + "name": "IDX_kiloclaw_referral_reward_applications_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_referral_reward_applications_reward_id_kiloclaw_referral_rewards_id_fk": { + "name": "kiloclaw_referral_reward_applications_reward_id_kiloclaw_referral_rewards_id_fk", + "tableFrom": "kiloclaw_referral_reward_applications", + "tableTo": "kiloclaw_referral_rewards", + "columnsFrom": [ + "reward_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kiloclaw_referral_reward_applications_beneficiary_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_referral_reward_applications_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_referral_reward_applications", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_referral_reward_decisions": { + "name": "kiloclaw_referral_reward_decisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "conversion_id": { + "name": "conversion_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beneficiary_role": { + "name": "beneficiary_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "months_granted": { + "name": "months_granted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_referral_reward_decisions_beneficiary_user_id": { + "name": "IDX_kiloclaw_referral_reward_decisions_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_referral_reward_decisions_conversion_id_kiloclaw_referral_conversions_id_fk": { + "name": "kiloclaw_referral_reward_decisions_conversion_id_kiloclaw_referral_conversions_id_fk", + "tableFrom": "kiloclaw_referral_reward_decisions", + "tableTo": "kiloclaw_referral_conversions", + "columnsFrom": [ + "conversion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kiloclaw_referral_reward_decisions_beneficiary_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_referral_reward_decisions_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_referral_reward_decisions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kiloclaw_referral_reward_decisions_conversion_role": { + "name": "UQ_kiloclaw_referral_reward_decisions_conversion_role", + "nullsNotDistinct": false, + "columns": [ + "conversion_id", + "beneficiary_role" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kiloclaw_referral_reward_decisions_beneficiary_role_check": { + "name": "kiloclaw_referral_reward_decisions_beneficiary_role_check", + "value": "\"kiloclaw_referral_reward_decisions\".\"beneficiary_role\" IN ('referrer', 'referee')" + }, + "kiloclaw_referral_reward_decisions_outcome_check": { + "name": "kiloclaw_referral_reward_decisions_outcome_check", + "value": "\"kiloclaw_referral_reward_decisions\".\"outcome\" IN ('granted', 'cap_limited', 'disqualified')" + }, + "kiloclaw_referral_reward_decisions_months_granted_non_negative_check": { + "name": "kiloclaw_referral_reward_decisions_months_granted_non_negative_check", + "value": "\"kiloclaw_referral_reward_decisions\".\"months_granted\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_referral_rewards": { + "name": "kiloclaw_referral_rewards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "conversion_id": { + "name": "conversion_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "decision_id": { + "name": "decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beneficiary_role": { + "name": "beneficiary_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "months_granted": { + "name": "months_granted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "applies_to_subscription_id": { + "name": "applies_to_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "earned_at": { + "name": "earned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reversed_at": { + "name": "reversed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_reason": { + "name": "review_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_referral_rewards_beneficiary_user_id": { + "name": "IDX_kiloclaw_referral_rewards_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_referral_rewards_status": { + "name": "IDX_kiloclaw_referral_rewards_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_referral_rewards_conversion_id_kiloclaw_referral_conversions_id_fk": { + "name": "kiloclaw_referral_rewards_conversion_id_kiloclaw_referral_conversions_id_fk", + "tableFrom": "kiloclaw_referral_rewards", + "tableTo": "kiloclaw_referral_conversions", + "columnsFrom": [ + "conversion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kiloclaw_referral_rewards_decision_id_kiloclaw_referral_reward_decisions_id_fk": { + "name": "kiloclaw_referral_rewards_decision_id_kiloclaw_referral_reward_decisions_id_fk", + "tableFrom": "kiloclaw_referral_rewards", + "tableTo": "kiloclaw_referral_reward_decisions", + "columnsFrom": [ + "decision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kiloclaw_referral_rewards_beneficiary_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_referral_rewards_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_referral_rewards", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kiloclaw_referral_rewards_conversion_role": { + "name": "UQ_kiloclaw_referral_rewards_conversion_role", + "nullsNotDistinct": false, + "columns": [ + "conversion_id", + "beneficiary_role" + ] + }, + "UQ_kiloclaw_referral_rewards_decision_id": { + "name": "UQ_kiloclaw_referral_rewards_decision_id", + "nullsNotDistinct": false, + "columns": [ + "decision_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kiloclaw_referral_rewards_beneficiary_role_check": { + "name": "kiloclaw_referral_rewards_beneficiary_role_check", + "value": "\"kiloclaw_referral_rewards\".\"beneficiary_role\" IN ('referrer', 'referee')" + }, + "kiloclaw_referral_rewards_status_check": { + "name": "kiloclaw_referral_rewards_status_check", + "value": "\"kiloclaw_referral_rewards\".\"status\" IN ('pending', 'earned', 'applied', 'reversed', 'expired', 'canceled', 'review_required')" + }, + "kiloclaw_referral_rewards_months_granted_positive_check": { + "name": "kiloclaw_referral_rewards_months_granted_positive_check", + "value": "\"kiloclaw_referral_rewards\".\"months_granted\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_referrals": { + "name": "kiloclaw_referrals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "referee_user_id": { + "name": "referee_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer_user_id": { + "name": "referrer_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_touch_id": { + "name": "source_touch_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "impact_referral_id": { + "name": "impact_referral_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_referrals_referrer_user_id": { + "name": "IDX_kiloclaw_referrals_referrer_user_id", + "columns": [ + { + "expression": "referrer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_referrals_source_touch_id": { + "name": "IDX_kiloclaw_referrals_source_touch_id", + "columns": [ + { + "expression": "source_touch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_referrals_referee_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_referrals_referee_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_referrals", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kiloclaw_referrals_referrer_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_referrals_referrer_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_referrals", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referrer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kiloclaw_referrals_source_touch_id_kiloclaw_attribution_touches_id_fk": { + "name": "kiloclaw_referrals_source_touch_id_kiloclaw_attribution_touches_id_fk", + "tableFrom": "kiloclaw_referrals", + "tableTo": "kiloclaw_attribution_touches", + "columnsFrom": [ + "source_touch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kiloclaw_referrals_referee_user_id": { + "name": "UQ_kiloclaw_referrals_referee_user_id", + "nullsNotDistinct": false, + "columns": [ + "referee_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_action_notifications": { + "name": "kiloclaw_scheduled_action_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'notice'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_kiloclaw_scheduled_action_notifications_target_kind_channel": { + "name": "UQ_kiloclaw_scheduled_action_notifications_target_kind_channel", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_notifications_pending": { + "name": "IDX_kiloclaw_scheduled_action_notifications_pending", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_scheduled_action_notifications\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_action_notifications_target_id_kiloclaw_scheduled_action_targets_id_fk": { + "name": "kiloclaw_scheduled_action_notifications_target_id_kiloclaw_scheduled_action_targets_id_fk", + "tableFrom": "kiloclaw_scheduled_action_notifications", + "tableTo": "kiloclaw_scheduled_action_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_action_stages": { + "name": "kiloclaw_scheduled_action_stages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scheduled_action_id": { + "name": "scheduled_action_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_index": { + "name": "stage_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "notice_sent_at": { + "name": "notice_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "applied_count": { + "name": "applied_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "UQ_kiloclaw_scheduled_action_stages_parent_index": { + "name": "UQ_kiloclaw_scheduled_action_stages_parent_index", + "columns": [ + { + "expression": "scheduled_action_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stage_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_stages_notice_due": { + "name": "IDX_kiloclaw_scheduled_action_stages_notice_due", + "columns": [ + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_scheduled_action_stages\".\"notice_sent_at\" IS NULL AND \"kiloclaw_scheduled_action_stages\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_action_stages_scheduled_action_id_kiloclaw_scheduled_actions_id_fk": { + "name": "kiloclaw_scheduled_action_stages_scheduled_action_id_kiloclaw_scheduled_actions_id_fk", + "tableFrom": "kiloclaw_scheduled_action_stages", + "tableTo": "kiloclaw_scheduled_actions", + "columnsFrom": [ + "scheduled_action_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_action_targets": { + "name": "kiloclaw_scheduled_action_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scheduled_action_id": { + "name": "scheduled_action_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_image_tag": { + "name": "source_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_image_tag": { + "name": "target_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_kiloclaw_scheduled_action_targets_parent_instance": { + "name": "UQ_kiloclaw_scheduled_action_targets_parent_instance", + "columns": [ + { + "expression": "scheduled_action_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_targets_stage": { + "name": "IDX_kiloclaw_scheduled_action_targets_stage", + "columns": [ + { + "expression": "stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_targets_pending_by_instance": { + "name": "IDX_kiloclaw_scheduled_action_targets_pending_by_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_scheduled_action_targets\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_action_targets_scheduled_action_id_kiloclaw_scheduled_actions_id_fk": { + "name": "kiloclaw_scheduled_action_targets_scheduled_action_id_kiloclaw_scheduled_actions_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kiloclaw_scheduled_actions", + "columnsFrom": [ + "scheduled_action_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_action_targets_stage_id_kiloclaw_scheduled_action_stages_id_fk": { + "name": "kiloclaw_scheduled_action_targets_stage_id_kiloclaw_scheduled_action_stages_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kiloclaw_scheduled_action_stages", + "columnsFrom": [ + "stage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_action_targets_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_scheduled_action_targets_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_action_targets_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_scheduled_action_targets_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_actions": { + "name": "kiloclaw_scheduled_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_image_tag": { + "name": "target_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_pins": { + "name": "override_pins", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notice_lead_hours": { + "name": "notice_lead_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "notice_subject": { + "name": "notice_subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "notice_body": { + "name": "notice_body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_count": { + "name": "total_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "applied_count": { + "name": "applied_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "IDX_kiloclaw_scheduled_actions_status": { + "name": "IDX_kiloclaw_scheduled_actions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_actions_action_type": { + "name": "IDX_kiloclaw_scheduled_actions_action_type", + "columns": [ + { + "expression": "action_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_actions_created_by": { + "name": "IDX_kiloclaw_scheduled_actions_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_actions_target_image_tag_kiloclaw_image_catalog_image_tag_fk": { + "name": "kiloclaw_scheduled_actions_target_image_tag_kiloclaw_image_catalog_image_tag_fk", + "tableFrom": "kiloclaw_scheduled_actions", + "tableTo": "kiloclaw_image_catalog", + "columnsFrom": [ + "target_image_tag" + ], + "columnsTo": [ + "image_tag" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_actions_created_by_kilocode_users_id_fk": { + "name": "kiloclaw_scheduled_actions_created_by_kilocode_users_id_fk", + "tableFrom": "kiloclaw_scheduled_actions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_subscription_change_log": { + "name": "kiloclaw_subscription_change_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before_state": { + "name": "before_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_state": { + "name": "after_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_kiloclaw_subscription_change_log_subscription_created_at": { + "name": "IDX_kiloclaw_subscription_change_log_subscription_created_at", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscription_change_log_created_at": { + "name": "IDX_kiloclaw_subscription_change_log_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_subscription_change_log_subscription_id_kiloclaw_subscriptions_id_fk": { + "name": "kiloclaw_subscription_change_log_subscription_id_kiloclaw_subscriptions_id_fk", + "tableFrom": "kiloclaw_subscription_change_log", + "tableTo": "kiloclaw_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kiloclaw_subscription_change_log_actor_type_check": { + "name": "kiloclaw_subscription_change_log_actor_type_check", + "value": "\"kiloclaw_subscription_change_log\".\"actor_type\" IN ('user', 'system')" + }, + "kiloclaw_subscription_change_log_action_check": { + "name": "kiloclaw_subscription_change_log_action_check", + "value": "\"kiloclaw_subscription_change_log\".\"action\" IN ('created', 'status_changed', 'plan_switched', 'period_advanced', 'canceled', 'reactivated', 'suspended', 'destruction_scheduled', 'reassigned', 'backfilled', 'payment_source_changed', 'schedule_changed', 'admin_override')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_subscriptions": { + "name": "kiloclaw_subscriptions", + "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_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transferred_to_subscription_id": { + "name": "transferred_to_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "access_origin": { + "name": "access_origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_source": { + "name": "payment_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_plan": { + "name": "scheduled_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_by": { + "name": "scheduled_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pending_conversion": { + "name": "pending_conversion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trial_started_at": { + "name": "trial_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "credit_renewal_at": { + "name": "credit_renewal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "commit_ends_at": { + "name": "commit_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "past_due_since": { + "name": "past_due_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "destruction_deadline": { + "name": "destruction_deadline", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_resume_requested_at": { + "name": "auto_resume_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_resume_retry_after": { + "name": "auto_resume_retry_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_resume_attempt_count": { + "name": "auto_resume_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "auto_top_up_triggered_for_period": { + "name": "auto_top_up_triggered_for_period", + "type": "timestamp with time zone", + "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": { + "IDX_kiloclaw_subscriptions_status": { + "name": "IDX_kiloclaw_subscriptions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_user_id": { + "name": "IDX_kiloclaw_subscriptions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_user_status": { + "name": "IDX_kiloclaw_subscriptions_user_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_transferred_to": { + "name": "IDX_kiloclaw_subscriptions_transferred_to", + "columns": [ + { + "expression": "transferred_to_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_stripe_schedule_id": { + "name": "IDX_kiloclaw_subscriptions_stripe_schedule_id", + "columns": [ + { + "expression": "stripe_schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_auto_resume_retry_after": { + "name": "IDX_kiloclaw_subscriptions_auto_resume_retry_after", + "columns": [ + { + "expression": "auto_resume_retry_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_subscriptions_instance": { + "name": "UQ_kiloclaw_subscriptions_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_subscriptions\".\"instance_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_subscriptions_transferred_to": { + "name": "UQ_kiloclaw_subscriptions_transferred_to", + "columns": [ + { + "expression": "transferred_to_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_subscriptions\".\"transferred_to_subscription_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_earlybird_origin": { + "name": "IDX_kiloclaw_subscriptions_earlybird_origin", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "access_origin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_subscriptions\".\"access_origin\" = 'earlybird'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_subscriptions_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_subscriptions_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_subscriptions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_subscriptions_transferred_to_subscription_id_kiloclaw_subscriptions_id_fk": { + "name": "kiloclaw_subscriptions_transferred_to_subscription_id_kiloclaw_subscriptions_id_fk", + "tableFrom": "kiloclaw_subscriptions", + "tableTo": "kiloclaw_subscriptions", + "columnsFrom": [ + "transferred_to_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_subscriptions_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_subscriptions_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_subscriptions", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_subscriptions_stripe_subscription_id_unique": { + "name": "kiloclaw_subscriptions_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kiloclaw_subscriptions_plan_check": { + "name": "kiloclaw_subscriptions_plan_check", + "value": "\"kiloclaw_subscriptions\".\"plan\" IN ('trial', 'commit', 'standard')" + }, + "kiloclaw_subscriptions_scheduled_plan_check": { + "name": "kiloclaw_subscriptions_scheduled_plan_check", + "value": "\"kiloclaw_subscriptions\".\"scheduled_plan\" IN ('commit', 'standard')" + }, + "kiloclaw_subscriptions_scheduled_by_check": { + "name": "kiloclaw_subscriptions_scheduled_by_check", + "value": "\"kiloclaw_subscriptions\".\"scheduled_by\" IN ('auto', 'user')" + }, + "kiloclaw_subscriptions_status_check": { + "name": "kiloclaw_subscriptions_status_check", + "value": "\"kiloclaw_subscriptions\".\"status\" IN ('trialing', 'active', 'past_due', 'canceled', 'unpaid')" + }, + "kiloclaw_subscriptions_access_origin_check": { + "name": "kiloclaw_subscriptions_access_origin_check", + "value": "\"kiloclaw_subscriptions\".\"access_origin\" IN ('earlybird')" + }, + "kiloclaw_subscriptions_payment_source_check": { + "name": "kiloclaw_subscriptions_payment_source_check", + "value": "\"kiloclaw_subscriptions\".\"payment_source\" IN ('stripe', 'credits')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_version_pins": { + "name": "kiloclaw_version_pins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "image_tag": { + "name": "image_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_by": { + "name": "pinned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "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": {}, + "foreignKeys": { + "kiloclaw_version_pins_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_version_pins_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_version_pins", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_version_pins_image_tag_kiloclaw_image_catalog_image_tag_fk": { + "name": "kiloclaw_version_pins_image_tag_kiloclaw_image_catalog_image_tag_fk", + "tableFrom": "kiloclaw_version_pins", + "tableTo": "kiloclaw_image_catalog", + "columnsFrom": [ + "image_tag" + ], + "columnsTo": [ + "image_tag" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "kiloclaw_version_pins_pinned_by_kilocode_users_id_fk": { + "name": "kiloclaw_version_pins_pinned_by_kilocode_users_id_fk", + "tableFrom": "kiloclaw_version_pins", + "tableTo": "kilocode_users", + "columnsFrom": [ + "pinned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_version_pins_instance_id_unique": { + "name": "kiloclaw_version_pins_instance_id_unique", + "nullsNotDistinct": false, + "columns": [ + "instance_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kilocode_users": { + "name": "kilocode_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "google_user_email": { + "name": "google_user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_user_name": { + "name": "google_user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_user_image_url": { + "name": "google_user_image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + }, + "hosted_domain": { + "name": "hosted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "microdollars_used": { + "name": "microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "kilo_pass_threshold": { + "name": "kilo_pass_threshold", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "total_microdollars_acquired": { + "name": "total_microdollars_acquired", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "next_credit_expiration_at": { + "name": "next_credit_expiration_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "has_validation_stytch": { + "name": "has_validation_stytch", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_validation_novel_card_with_hold": { + "name": "has_validation_novel_card_with_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_at": { + "name": "blocked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "blocked_by_kilo_user_id": { + "name": "blocked_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_token_pepper": { + "name": "api_token_pepper", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web_session_pepper": { + "name": "web_session_pepper", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_top_up_enabled": { + "name": "auto_top_up_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kiloclaw_early_access": { + "name": "kiloclaw_early_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cohorts": { + "name": "cohorts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "completed_welcome_form": { + "name": "completed_welcome_form", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "linkedin_url": { + "name": "linkedin_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_url": { + "name": "github_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_server_membership_verified_at": { + "name": "discord_server_membership_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "openrouter_upstream_safety_identifier": { + "name": "openrouter_upstream_safety_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vercel_downstream_safety_identifier": { + "name": "vercel_downstream_safety_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_source": { + "name": "customer_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signup_ip": { + "name": "signup_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_deletion_requested_at": { + "name": "account_deletion_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_domain": { + "name": "email_domain", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_kilocode_users_signup_ip_created_at": { + "name": "IDX_kilocode_users_signup_ip_created_at", + "columns": [ + { + "expression": "signup_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_blocked_at": { + "name": "IDX_kilocode_users_blocked_at", + "columns": [ + { + "expression": "blocked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_blocked_by_kilo_user_id": { + "name": "IDX_kilocode_users_blocked_by_kilo_user_id", + "columns": [ + { + "expression": "blocked_by_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilocode_users_openrouter_upstream_safety_identifier": { + "name": "UQ_kilocode_users_openrouter_upstream_safety_identifier", + "columns": [ + { + "expression": "openrouter_upstream_safety_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilocode_users\".\"openrouter_upstream_safety_identifier\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilocode_users_vercel_downstream_safety_identifier": { + "name": "UQ_kilocode_users_vercel_downstream_safety_identifier", + "columns": [ + { + "expression": "vercel_downstream_safety_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilocode_users\".\"vercel_downstream_safety_identifier\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_normalized_email": { + "name": "IDX_kilocode_users_normalized_email", + "columns": [ + { + "expression": "normalized_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_email_domain": { + "name": "IDX_kilocode_users_email_domain", + "columns": [ + { + "expression": "email_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_b1afacbcf43f2c7c4cb9f7e7faa": { + "name": "UQ_b1afacbcf43f2c7c4cb9f7e7faa", + "nullsNotDistinct": false, + "columns": [ + "google_user_email" + ] + } + }, + "policies": {}, + "checkConstraints": { + "blocked_reason_not_empty": { + "name": "blocked_reason_not_empty", + "value": "length(blocked_reason) > 0" + } + }, + "isRLSEnabled": false + }, + "public.magic_link_tokens": { + "name": "magic_link_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_magic_link_tokens_email": { + "name": "idx_magic_link_tokens_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_magic_link_tokens_expires_at": { + "name": "idx_magic_link_tokens_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_expires_at_future": { + "name": "check_expires_at_future", + "value": "\"magic_link_tokens\".\"expires_at\" > \"magic_link_tokens\".\"created_at\"" + } + }, + "isRLSEnabled": false + }, + "public.microdollar_usage": { + "name": "microdollar_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_hit_tokens": { + "name": "cache_hit_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_discount": { + "name": "cache_discount", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "has_error": { + "name": "has_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "abuse_classification": { + "name": "abuse_classification", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "inference_provider": { + "name": "inference_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_created_at": { + "name": "idx_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_abuse_classification": { + "name": "idx_abuse_classification", + "columns": [ + { + "expression": "abuse_classification", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_kilo_user_id_created_at2": { + "name": "idx_kilo_user_id_created_at2", + "columns": [ + { + "expression": "kilo_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_microdollar_usage_organization_id": { + "name": "idx_microdollar_usage_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"microdollar_usage\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.microdollar_usage_metadata": { + "name": "microdollar_usage_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "http_user_agent_id": { + "name": "http_user_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "http_ip_id": { + "name": "http_ip_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_city_id": { + "name": "vercel_ip_city_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_country_id": { + "name": "vercel_ip_country_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_latitude": { + "name": "vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_longitude": { + "name": "vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "ja4_digest_id": { + "name": "ja4_digest_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_prompt_prefix": { + "name": "user_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt_prefix_id": { + "name": "system_prompt_prefix_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "system_prompt_length": { + "name": "system_prompt_length", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_tokens": { + "name": "max_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "has_middle_out_transform": { + "name": "has_middle_out_transform", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finish_reason_id": { + "name": "finish_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency": { + "name": "latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "moderation_latency": { + "name": "moderation_latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "generation_time": { + "name": "generation_time", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "is_byok": { + "name": "is_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_user_byok": { + "name": "is_user_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "streamed": { + "name": "streamed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancelled": { + "name": "cancelled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "editor_name_id": { + "name": "editor_name_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_kind_id": { + "name": "api_kind_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_tools": { + "name": "has_tools", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode_id": { + "name": "mode_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_model_id": { + "name": "auto_model_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "market_cost": { + "name": "market_cost", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_microdollar_usage_metadata_created_at": { + "name": "idx_microdollar_usage_metadata_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "microdollar_usage_metadata_http_user_agent_id_http_user_agent_http_user_agent_id_fk": { + "name": "microdollar_usage_metadata_http_user_agent_id_http_user_agent_http_user_agent_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "http_user_agent", + "columnsFrom": [ + "http_user_agent_id" + ], + "columnsTo": [ + "http_user_agent_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_http_ip_id_http_ip_http_ip_id_fk": { + "name": "microdollar_usage_metadata_http_ip_id_http_ip_http_ip_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "http_ip", + "columnsFrom": [ + "http_ip_id" + ], + "columnsTo": [ + "http_ip_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_vercel_ip_city_id_vercel_ip_city_vercel_ip_city_id_fk": { + "name": "microdollar_usage_metadata_vercel_ip_city_id_vercel_ip_city_vercel_ip_city_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "vercel_ip_city", + "columnsFrom": [ + "vercel_ip_city_id" + ], + "columnsTo": [ + "vercel_ip_city_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_vercel_ip_country_id_vercel_ip_country_vercel_ip_country_id_fk": { + "name": "microdollar_usage_metadata_vercel_ip_country_id_vercel_ip_country_vercel_ip_country_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "vercel_ip_country", + "columnsFrom": [ + "vercel_ip_country_id" + ], + "columnsTo": [ + "vercel_ip_country_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_ja4_digest_id_ja4_digest_ja4_digest_id_fk": { + "name": "microdollar_usage_metadata_ja4_digest_id_ja4_digest_ja4_digest_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "ja4_digest", + "columnsFrom": [ + "ja4_digest_id" + ], + "columnsTo": [ + "ja4_digest_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_system_prompt_prefix_id_system_prompt_prefix_system_prompt_prefix_id_fk": { + "name": "microdollar_usage_metadata_system_prompt_prefix_id_system_prompt_prefix_system_prompt_prefix_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "system_prompt_prefix", + "columnsFrom": [ + "system_prompt_prefix_id" + ], + "columnsTo": [ + "system_prompt_prefix_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mode": { + "name": "mode", + "schema": "", + "columns": { + "mode_id": { + "name": "mode_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_mode": { + "name": "UQ_mode", + "columns": [ + { + "expression": "mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_stats": { + "name": "model_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_stealth": { + "name": "is_stealth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_recommended": { + "name": "is_recommended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "openrouter_id": { + "name": "openrouter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aa_slug": { + "name": "aa_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_creator": { + "name": "model_creator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_slug": { + "name": "creator_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_date": { + "name": "release_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "price_input": { + "name": "price_input", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "price_output": { + "name": "price_output", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "coding_index": { + "name": "coding_index", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "speed_tokens_per_sec": { + "name": "speed_tokens_per_sec", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": false + }, + "context_length": { + "name": "context_length", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_modalities": { + "name": "input_modalities", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "openrouter_data": { + "name": "openrouter_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "benchmarks": { + "name": "benchmarks", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "chart_data": { + "name": "chart_data", + "type": "jsonb", + "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": { + "IDX_model_stats_openrouter_id": { + "name": "IDX_model_stats_openrouter_id", + "columns": [ + { + "expression": "openrouter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_slug": { + "name": "IDX_model_stats_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_is_active": { + "name": "IDX_model_stats_is_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_creator_slug": { + "name": "IDX_model_stats_creator_slug", + "columns": [ + { + "expression": "creator_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_price_input": { + "name": "IDX_model_stats_price_input", + "columns": [ + { + "expression": "price_input", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_coding_index": { + "name": "IDX_model_stats_coding_index", + "columns": [ + { + "expression": "coding_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_context_length": { + "name": "IDX_model_stats_context_length", + "columns": [ + { + "expression": "context_length", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_stats_openrouter_id_unique": { + "name": "model_stats_openrouter_id_unique", + "nullsNotDistinct": false, + "columns": [ + "openrouter_id" + ] + }, + "model_stats_slug_unique": { + "name": "model_stats_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.models_by_provider": { + "name": "models_by_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "openrouter": { + "name": "openrouter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "vercel": { + "name": "vercel", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_audit_logs": { + "name": "organization_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_audit_logs_organization_id": { + "name": "IDX_organization_audit_logs_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_audit_logs_action": { + "name": "IDX_organization_audit_logs_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_audit_logs_actor_id": { + "name": "IDX_organization_audit_logs_actor_id", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_audit_logs_created_at": { + "name": "IDX_organization_audit_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_invitations": { + "name": "organization_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_organization_invitations_token": { + "name": "UQ_organization_invitations_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_invitations_org_id": { + "name": "IDX_organization_invitations_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_invitations_email": { + "name": "IDX_organization_invitations_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_invitations_expires_at": { + "name": "IDX_organization_invitations_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_membership_removals": { + "name": "organization_membership_removals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_by": { + "name": "removed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_role": { + "name": "previous_role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_org_membership_removals_org_id": { + "name": "IDX_org_membership_removals_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_org_membership_removals_user_id": { + "name": "IDX_org_membership_removals_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_org_membership_removals_org_user": { + "name": "UQ_org_membership_removals_org_user", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_memberships": { + "name": "organization_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_memberships_org_id": { + "name": "IDX_organization_memberships_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_memberships_user_id": { + "name": "IDX_organization_memberships_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_memberships_org_user": { + "name": "UQ_organization_memberships_org_user", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_seats_purchases": { + "name": "organization_seats_purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subscription_stripe_id": { + "name": "subscription_stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seat_count": { + "name": "seat_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_cycle": { + "name": "billing_cycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'monthly'" + } + }, + "indexes": { + "IDX_organization_seats_org_id": { + "name": "IDX_organization_seats_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_expires_at": { + "name": "IDX_organization_seats_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_created_at": { + "name": "IDX_organization_seats_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_updated_at": { + "name": "IDX_organization_seats_updated_at", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_starts_at": { + "name": "IDX_organization_seats_starts_at", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_seats_idempotency_key": { + "name": "UQ_organization_seats_idempotency_key", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_user_limits": { + "name": "organization_user_limits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "limit_type": { + "name": "limit_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microdollar_limit": { + "name": "microdollar_limit", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "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": { + "IDX_organization_user_limits_org_id": { + "name": "IDX_organization_user_limits_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_user_limits_user_id": { + "name": "IDX_organization_user_limits_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_user_limits_org_user": { + "name": "UQ_organization_user_limits_org_user", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id", + "limit_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_user_usage": { + "name": "organization_user_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "limit_type": { + "name": "limit_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microdollar_usage": { + "name": "microdollar_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "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": { + "IDX_organization_user_daily_usage_org_id": { + "name": "IDX_organization_user_daily_usage_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_user_daily_usage_user_id": { + "name": "IDX_organization_user_daily_usage_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_user_daily_usage_org_user_date": { + "name": "UQ_organization_user_daily_usage_org_user_date", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id", + "limit_type", + "usage_date" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + }, + "microdollars_used": { + "name": "microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "microdollars_balance": { + "name": "microdollars_balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_microdollars_acquired": { + "name": "total_microdollars_acquired", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "next_credit_expiration_at": { + "name": "next_credit_expiration_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_top_up_enabled": { + "name": "auto_top_up_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "seat_count": { + "name": "seat_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "require_seats": { + "name": "require_seats", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sso_domain": { + "name": "sso_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'teams'" + }, + "free_trial_end_at": { + "name": "free_trial_end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "company_domain": { + "name": "company_domain", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_organizations_sso_domain": { + "name": "IDX_organizations_sso_domain", + "columns": [ + { + "expression": "sso_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organizations_name_not_empty_check": { + "name": "organizations_name_not_empty_check", + "value": "length(trim(\"organizations\".\"name\")) > 0" + } + }, + "isRLSEnabled": false + }, + "public.organization_modes": { + "name": "organization_modes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "IDX_organization_modes_organization_id": { + "name": "IDX_organization_modes_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_modes_org_id_slug": { + "name": "UQ_organization_modes_org_id_slug", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "stripe_fingerprint": { + "name": "stripe_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + }, + "last4": { + "name": "last4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line1": { + "name": "address_line1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line2": { + "name": "address_line2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_city": { + "name": "address_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_state": { + "name": "address_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_zip": { + "name": "address_zip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_country": { + "name": "address_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "three_d_secure_supported": { + "name": "three_d_secure_supported", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "funding": { + "name": "funding", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "regulated_status": { + "name": "regulated_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line1_check_status": { + "name": "address_line1_check_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code_check_status": { + "name": "postal_code_check_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_forwarded_for": { + "name": "http_x_forwarded_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_city": { + "name": "http_x_vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_country": { + "name": "http_x_vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_latitude": { + "name": "http_x_vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_longitude": { + "name": "http_x_vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ja4_digest": { + "name": "http_x_vercel_ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eligible_for_free_credits": { + "name": "eligible_for_free_credits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripe_data": { + "name": "stripe_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_d7d7fb15569674aaadcfbc0428": { + "name": "IDX_d7d7fb15569674aaadcfbc0428", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_e1feb919d0ab8a36381d5d5138": { + "name": "IDX_e1feb919d0ab8a36381d5d5138", + "columns": [ + { + "expression": "stripe_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_payment_methods_organization_id": { + "name": "IDX_payment_methods_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_29df1b0403df5792c96bbbfdbe6": { + "name": "UQ_29df1b0403df5792c96bbbfdbe6", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_impact_sale_reversals": { + "name": "pending_impact_sale_reversals", + "schema": "", + "columns": { + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "dispute_id": { + "name": "dispute_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_date": { + "name": "event_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_impact_sale_reversals_attempt_count_non_negative_check": { + "name": "pending_impact_sale_reversals_attempt_count_non_negative_check", + "value": "\"pending_impact_sale_reversals\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.platform_integrations": { + "name": "platform_integrations", + "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 + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration_type": { + "name": "integration_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_installation_id": { + "name": "platform_installation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_account_id": { + "name": "platform_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_account_login": { + "name": "platform_account_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "repository_access": { + "name": "repository_access", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repositories": { + "name": "repositories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "repositories_synced_at": { + "name": "repositories_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "kilo_requester_user_id": { + "name": "kilo_requester_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_requester_account_id": { + "name": "platform_requester_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "integration_status": { + "name": "integration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_by": { + "name": "suspended_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_app_type": { + "name": "github_app_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'standard'" + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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_platform_integrations_owned_by_org_platform_inst": { + "name": "UQ_platform_integrations_owned_by_org_platform_inst", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_owned_by_user_platform_inst": { + "name": "UQ_platform_integrations_owned_by_user_platform_inst", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_slack_platform_inst": { + "name": "UQ_platform_integrations_slack_platform_inst", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'slack' AND \"platform_integrations\".\"platform_installation_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_linear_platform_inst": { + "name": "UQ_platform_integrations_linear_platform_inst", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'linear' AND \"platform_integrations\".\"platform_installation_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_org_id": { + "name": "IDX_platform_integrations_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_user_id": { + "name": "IDX_platform_integrations_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_platform_inst_id": { + "name": "IDX_platform_integrations_platform_inst_id", + "columns": [ + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_platform": { + "name": "IDX_platform_integrations_platform", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_org_platform": { + "name": "IDX_platform_integrations_owned_by_org_platform", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_user_platform": { + "name": "IDX_platform_integrations_owned_by_user_platform", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_integration_status": { + "name": "IDX_platform_integrations_integration_status", + "columns": [ + { + "expression": "integration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_kilo_requester": { + "name": "IDX_platform_integrations_kilo_requester", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_requester_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_platform_requester": { + "name": "IDX_platform_integrations_platform_requester", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_requester_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_integrations_owned_by_organization_id_organizations_id_fk": { + "name": "platform_integrations_owned_by_organization_id_organizations_id_fk", + "tableFrom": "platform_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_integrations_owned_by_user_id_kilocode_users_id_fk": { + "name": "platform_integrations_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "platform_integrations", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_integrations_owner_check": { + "name": "platform_integrations_owner_check", + "value": "(\n (\"platform_integrations\".\"owned_by_user_id\" IS NOT NULL AND \"platform_integrations\".\"owned_by_organization_id\" IS NULL) OR\n (\"platform_integrations\".\"owned_by_user_id\" IS NULL AND \"platform_integrations\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.referral_code_usages": { + "name": "referral_code_usages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "referring_kilo_user_id": { + "name": "referring_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redeeming_kilo_user_id": { + "name": "redeeming_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "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": { + "IDX_referral_code_usages_redeeming_kilo_user_id": { + "name": "IDX_referral_code_usages_redeeming_kilo_user_id", + "columns": [ + { + "expression": "redeeming_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_referral_code_usages_redeeming_user_id_code": { + "name": "UQ_referral_code_usages_redeeming_user_id_code", + "nullsNotDistinct": false, + "columns": [ + "redeeming_kilo_user_id", + "referring_kilo_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "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_referral_codes_kilo_user_id": { + "name": "UQ_referral_codes_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_referral_codes_code": { + "name": "IDX_referral_codes_code", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_advisor_check_catalog": { + "name": "security_advisor_check_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "risk": { + "name": "risk", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "security_advisor_check_catalog_check_id_unique": { + "name": "security_advisor_check_catalog_check_id_unique", + "nullsNotDistinct": false, + "columns": [ + "check_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "security_advisor_check_catalog_severity_check": { + "name": "security_advisor_check_catalog_severity_check", + "value": "\"security_advisor_check_catalog\".\"severity\" in ('critical', 'warn', 'info')" + } + }, + "isRLSEnabled": false + }, + "public.security_advisor_content": { + "name": "security_advisor_content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "security_advisor_content_key_unique": { + "name": "security_advisor_content_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_advisor_kiloclaw_coverage": { + "name": "security_advisor_kiloclaw_coverage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "area": { + "name": "area", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_check_ids": { + "name": "match_check_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "security_advisor_kiloclaw_coverage_area_unique": { + "name": "security_advisor_kiloclaw_coverage_area_unique", + "nullsNotDistinct": false, + "columns": [ + "area" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_advisor_scans": { + "name": "security_advisor_scans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_platform": { + "name": "source_platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_method": { + "name": "source_method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_version": { + "name": "plugin_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "openclaw_version": { + "name": "openclaw_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_ip": { + "name": "public_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "findings_critical": { + "name": "findings_critical", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "findings_warn": { + "name": "findings_warn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "findings_info": { + "name": "findings_info", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_security_advisor_scans_user_created_at": { + "name": "idx_security_advisor_scans_user_created_at", + "columns": [ + { + "expression": "kilo_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_advisor_scans_created_at": { + "name": "idx_security_advisor_scans_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_advisor_scans_platform": { + "name": "idx_security_advisor_scans_platform", + "columns": [ + { + "expression": "source_platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_analysis_owner_state": { + "name": "security_analysis_owner_state", + "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 + }, + "auto_analysis_enabled_at": { + "name": "auto_analysis_enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "blocked_until": { + "name": "blocked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "block_reason": { + "name": "block_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consecutive_actor_resolution_failures": { + "name": "consecutive_actor_resolution_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_actor_resolution_failure_at": { + "name": "last_actor_resolution_failure_at", + "type": "timestamp with time zone", + "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_owner_state_org_owner": { + "name": "UQ_security_analysis_owner_state_org_owner", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_analysis_owner_state\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_analysis_owner_state_user_owner": { + "name": "UQ_security_analysis_owner_state_user_owner", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_analysis_owner_state\".\"owned_by_user_id\" is not null", + "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", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "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", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "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_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')" + } + }, + "isRLSEnabled": false + }, + "public.security_analysis_queue": { + "name": "security_analysis_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "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", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "queue_status": { + "name": "queue_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity_rank": { + "name": "severity_rank", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by_job_id": { + "name": "claimed_by_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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", + "primaryKey": false, + "notNull": true + }, + "vulnerable_version_range": { + "name": "vulnerable_version_range", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patched_version": { + "name": "patched_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manifest_path": { + "name": "manifest_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "ignored_reason": { + "name": "ignored_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ignored_by": { + "name": "ignored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fixed_at": { + "name": "fixed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sla_due_at": { + "name": "sla_due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dependabot_html_url": { + "name": "dependabot_html_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwe_ids": { + "name": "cwe_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "cvss_score": { + "name": "cvss_score", + "type": "numeric(3, 1)", + "primaryKey": false, + "notNull": false + }, + "dependency_scope": { + "name": "dependency_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analysis_status": { + "name": "analysis_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analysis_started_at": { + "name": "analysis_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analysis_completed_at": { + "name": "analysis_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analysis_error": { + "name": "analysis_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analysis": { + "name": "analysis", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "raw_data": { + "name": "raw_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_detected_at": { + "name": "first_detected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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": { + "idx_security_findings_org_id": { + "name": "idx_security_findings_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_user_id": { + "name": "idx_security_findings_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_repo": { + "name": "idx_security_findings_repo", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_severity": { + "name": "idx_security_findings_severity", + "columns": [ + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_status": { + "name": "idx_security_findings_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_package": { + "name": "idx_security_findings_package", + "columns": [ + { + "expression": "package_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_sla_due_at": { + "name": "idx_security_findings_sla_due_at", + "columns": [ + { + "expression": "sla_due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_session_id": { + "name": "idx_security_findings_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_cli_session_id": { + "name": "idx_security_findings_cli_session_id", + "columns": [ + { + "expression": "cli_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_analysis_status": { + "name": "idx_security_findings_analysis_status", + "columns": [ + { + "expression": "analysis_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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": { + "security_findings_owned_by_organization_id_organizations_id_fk": { + "name": "security_findings_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_findings", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_findings_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_findings_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_findings", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_findings_platform_integration_id_platform_integrations_id_fk": { + "name": "security_findings_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "security_findings", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_security_findings_source": { + "name": "uq_security_findings_source", + "nullsNotDistinct": false, + "columns": [ + "repo_full_name", + "source", + "source_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "security_findings_owner_check": { + "name": "security_findings_owner_check", + "value": "(\n (\"security_findings\".\"owned_by_user_id\" IS NOT NULL AND \"security_findings\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_findings\".\"owned_by_user_id\" IS NULL AND \"security_findings\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.shared_cli_sessions": { + "name": "shared_cli_sessions", + "schema": "", + "columns": { + "share_id": { + "name": "share_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_state": { + "name": "shared_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "api_conversation_history_blob_url": { + "name": "api_conversation_history_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_metadata_blob_url": { + "name": "task_metadata_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ui_messages_blob_url": { + "name": "ui_messages_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_state_blob_url": { + "name": "git_state_blob_url", + "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": { + "IDX_shared_cli_sessions_session_id": { + "name": "IDX_shared_cli_sessions_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_shared_cli_sessions_created_at": { + "name": "IDX_shared_cli_sessions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_cli_sessions_session_id_cli_sessions_session_id_fk": { + "name": "shared_cli_sessions_session_id_cli_sessions_session_id_fk", + "tableFrom": "shared_cli_sessions", + "tableTo": "cli_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shared_cli_sessions_kilo_user_id_kilocode_users_id_fk": { + "name": "shared_cli_sessions_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "shared_cli_sessions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_cli_sessions_shared_state_check": { + "name": "shared_cli_sessions_shared_state_check", + "value": "\"shared_cli_sessions\".\"shared_state\" IN ('public', 'organization')" + } + }, + "isRLSEnabled": false + }, + "public.slack_bot_requests": { + "name": "slack_bot_requests", + "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 + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_name": { + "name": "slack_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message": { + "name": "user_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message_truncated": { + "name": "user_message_truncated", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model_used": { + "name": "model_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_calls_made": { + "name": "tool_calls_made", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_slack_bot_requests_created_at": { + "name": "idx_slack_bot_requests_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_slack_team_id": { + "name": "idx_slack_bot_requests_slack_team_id", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_owned_by_org_id": { + "name": "idx_slack_bot_requests_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_owned_by_user_id": { + "name": "idx_slack_bot_requests_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_status": { + "name": "idx_slack_bot_requests_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_event_type": { + "name": "idx_slack_bot_requests_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_team_created": { + "name": "idx_slack_bot_requests_team_created", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_bot_requests_owned_by_organization_id_organizations_id_fk": { + "name": "slack_bot_requests_owned_by_organization_id_organizations_id_fk", + "tableFrom": "slack_bot_requests", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_bot_requests_owned_by_user_id_kilocode_users_id_fk": { + "name": "slack_bot_requests_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "slack_bot_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_bot_requests_platform_integration_id_platform_integrations_id_fk": { + "name": "slack_bot_requests_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "slack_bot_requests", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "slack_bot_requests_owner_check": { + "name": "slack_bot_requests_owner_check", + "value": "(\n (\"slack_bot_requests\".\"owned_by_user_id\" IS NOT NULL AND \"slack_bot_requests\".\"owned_by_organization_id\" IS NULL) OR\n (\"slack_bot_requests\".\"owned_by_user_id\" IS NULL AND \"slack_bot_requests\".\"owned_by_organization_id\" IS NOT NULL) OR\n (\"slack_bot_requests\".\"owned_by_user_id\" IS NULL AND \"slack_bot_requests\".\"owned_by_organization_id\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.source_embeddings": { + "name": "source_embeddings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_line": { + "name": "start_line", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_line": { + "name": "end_line", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_base_branch": { + "name": "is_base_branch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "IDX_source_embeddings_organization_id": { + "name": "IDX_source_embeddings_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_kilo_user_id": { + "name": "IDX_source_embeddings_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_project_id": { + "name": "IDX_source_embeddings_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_created_at": { + "name": "IDX_source_embeddings_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_updated_at": { + "name": "IDX_source_embeddings_updated_at", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_file_path_lower": { + "name": "IDX_source_embeddings_file_path_lower", + "columns": [ + { + "expression": "LOWER(\"file_path\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_git_branch": { + "name": "IDX_source_embeddings_git_branch", + "columns": [ + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_org_project_branch": { + "name": "IDX_source_embeddings_org_project_branch", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_embeddings_organization_id_organizations_id_fk": { + "name": "source_embeddings_organization_id_organizations_id_fk", + "tableFrom": "source_embeddings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "source_embeddings_kilo_user_id_kilocode_users_id_fk": { + "name": "source_embeddings_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "source_embeddings", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_source_embeddings_org_project_branch_file_lines": { + "name": "UQ_source_embeddings_org_project_branch_file_lines", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "project_id", + "git_branch", + "file_path", + "start_line", + "end_line" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stytch_fingerprints": { + "name": "stytch_fingerprints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visitor_fingerprint": { + "name": "visitor_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_fingerprint": { + "name": "browser_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_id": { + "name": "browser_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hardware_fingerprint": { + "name": "hardware_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network_fingerprint": { + "name": "network_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visitor_id": { + "name": "visitor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verdict_action": { + "name": "verdict_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detected_device_type": { + "name": "detected_device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_authentic_device": { + "name": "is_authentic_device", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "reasons": { + "name": "reasons", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"\"}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fingerprint_data": { + "name": "fingerprint_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "kilo_free_tier_allowed": { + "name": "kilo_free_tier_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "http_x_forwarded_for": { + "name": "http_x_forwarded_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_city": { + "name": "http_x_vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_country": { + "name": "http_x_vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_latitude": { + "name": "http_x_vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_longitude": { + "name": "http_x_vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ja4_digest": { + "name": "http_x_vercel_ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_user_agent": { + "name": "http_user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_fingerprint_data": { + "name": "idx_fingerprint_data", + "columns": [ + { + "expression": "fingerprint_data", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_hardware_fingerprint": { + "name": "idx_hardware_fingerprint", + "columns": [ + { + "expression": "hardware_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_kilo_user_id": { + "name": "idx_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_stytch_fingerprints_reasons_gin": { + "name": "idx_stytch_fingerprints_reasons_gin", + "columns": [ + { + "expression": "reasons", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_verdict_action": { + "name": "idx_verdict_action", + "columns": [ + { + "expression": "verdict_action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_visitor_fingerprint": { + "name": "idx_visitor_fingerprint", + "columns": [ + { + "expression": "visitor_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_prompt_prefix": { + "name": "system_prompt_prefix", + "schema": "", + "columns": { + "system_prompt_prefix_id": { + "name": "system_prompt_prefix_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "system_prompt_prefix": { + "name": "system_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_system_prompt_prefix": { + "name": "UQ_system_prompt_prefix", + "columns": [ + { + "expression": "system_prompt_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactional_email_log": { + "name": "transactional_email_log", + "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 + }, + "email_type": { + "name": "email_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_transactional_email_log_type_idempotency_key": { + "name": "UQ_transactional_email_log_type_idempotency_key", + "columns": [ + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_transactional_email_log_user_id": { + "name": "IDX_transactional_email_log_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactional_email_log_user_id_kilocode_users_id_fk": { + "name": "transactional_email_log_user_id_kilocode_users_id_fk", + "tableFrom": "transactional_email_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_admin_notes": { + "name": "user_admin_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note_content": { + "name": "note_content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admin_kilo_user_id": { + "name": "admin_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_34517df0b385234babc38fe81b": { + "name": "IDX_34517df0b385234babc38fe81b", + "columns": [ + { + "expression": "admin_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_ccbde98c4c14046daa5682ec4f": { + "name": "IDX_ccbde98c4c14046daa5682ec4f", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_d0270eb24ef6442d65a0b7853c": { + "name": "IDX_d0270eb24ef6442d65a0b7853c", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_affiliate_attributions": { + "name": "user_affiliate_attributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_id": { + "name": "tracking_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_affiliate_attributions_user_id": { + "name": "IDX_user_affiliate_attributions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_affiliate_attributions_user_id_kilocode_users_id_fk": { + "name": "user_affiliate_attributions_user_id_kilocode_users_id_fk", + "tableFrom": "user_affiliate_attributions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_user_affiliate_attributions_user_provider": { + "name": "UQ_user_affiliate_attributions_user_provider", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_affiliate_attributions_provider_check": { + "name": "user_affiliate_attributions_provider_check", + "value": "\"user_affiliate_attributions\".\"provider\" IN ('impact')" + } + }, + "isRLSEnabled": false + }, + "public.user_affiliate_events": { + "name": "user_affiliate_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_event_id": { + "name": "parent_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "delivery_state": { + "name": "delivery_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impact_action_id": { + "name": "impact_action_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impact_submission_uri": { + "name": "impact_submission_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_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 + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_affiliate_events_claim_path": { + "name": "IDX_user_affiliate_events_claim_path", + "columns": [ + { + "expression": "delivery_state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_affiliate_events_parent_event_id": { + "name": "IDX_user_affiliate_events_parent_event_id", + "columns": [ + { + "expression": "parent_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_affiliate_events_provider_event_type_charge": { + "name": "IDX_user_affiliate_events_provider_event_type_charge", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stripe_charge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_affiliate_events_user_id_kilocode_users_id_fk": { + "name": "user_affiliate_events_user_id_kilocode_users_id_fk", + "tableFrom": "user_affiliate_events", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "user_affiliate_events_parent_event_id_fk": { + "name": "user_affiliate_events_parent_event_id_fk", + "tableFrom": "user_affiliate_events", + "tableTo": "user_affiliate_events", + "columnsFrom": [ + "parent_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_user_affiliate_events_dedupe_key": { + "name": "UQ_user_affiliate_events_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_affiliate_events_provider_check": { + "name": "user_affiliate_events_provider_check", + "value": "\"user_affiliate_events\".\"provider\" IN ('impact')" + }, + "user_affiliate_events_event_type_check": { + "name": "user_affiliate_events_event_type_check", + "value": "\"user_affiliate_events\".\"event_type\" IN ('signup', 'trial_start', 'trial_end', 'sale', 'sale_reversal')" + }, + "user_affiliate_events_delivery_state_check": { + "name": "user_affiliate_events_delivery_state_check", + "value": "\"user_affiliate_events\".\"delivery_state\" IN ('queued', 'blocked', 'sending', 'delivered', 'failed')" + }, + "user_affiliate_events_attempt_count_non_negative_check": { + "name": "user_affiliate_events_attempt_count_non_negative_check", + "value": "\"user_affiliate_events\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.user_auth_provider": { + "name": "user_auth_provider", + "schema": "", + "columns": { + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hosted_domain": { + "name": "hosted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_auth_provider_kilo_user_id": { + "name": "IDX_user_auth_provider_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_auth_provider_hosted_domain": { + "name": "IDX_user_auth_provider_hosted_domain", + "columns": [ + { + "expression": "hosted_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_auth_provider_provider_provider_account_id_pk": { + "name": "user_auth_provider_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_feedback": { + "name": "user_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feedback_text": { + "name": "feedback_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback_for": { + "name": "feedback_for", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "feedback_batch": { + "name": "feedback_batch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "context_json": { + "name": "context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_feedback_created_at": { + "name": "IDX_user_feedback_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_kilo_user_id": { + "name": "IDX_user_feedback_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_feedback_for": { + "name": "IDX_user_feedback_feedback_for", + "columns": [ + { + "expression": "feedback_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_feedback_batch": { + "name": "IDX_user_feedback_feedback_batch", + "columns": [ + { + "expression": "feedback_batch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_source": { + "name": "IDX_user_feedback_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_feedback_kilo_user_id_kilocode_users_id_fk": { + "name": "user_feedback_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "user_feedback", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_period_cache": { + "name": "user_period_cache", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cache_type": { + "name": "cache_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_type": { + "name": "period_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_key": { + "name": "period_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "shared_url_token": { + "name": "shared_url_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_user_period_cache_kilo_user_id": { + "name": "IDX_user_period_cache_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_user_period_cache": { + "name": "UQ_user_period_cache", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cache_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_period_cache_lookup": { + "name": "IDX_user_period_cache_lookup", + "columns": [ + { + "expression": "cache_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_user_period_cache_share_token": { + "name": "UQ_user_period_cache_share_token", + "columns": [ + { + "expression": "shared_url_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_period_cache\".\"shared_url_token\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_period_cache_kilo_user_id_kilocode_users_id_fk": { + "name": "user_period_cache_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "user_period_cache", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_period_cache_period_type_check": { + "name": "user_period_cache_period_type_check", + "value": "\"user_period_cache\".\"period_type\" IN ('year', 'quarter', 'month', 'week', 'custom')" + } + }, + "isRLSEnabled": false + }, + "public.user_push_tokens": { + "name": "user_push_tokens", + "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 + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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_user_push_tokens_token": { + "name": "UQ_user_push_tokens_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_push_tokens_user_id": { + "name": "IDX_user_push_tokens_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_push_tokens_user_id_kilocode_users_id_fk": { + "name": "user_push_tokens_user_id_kilocode_users_id_fk", + "tableFrom": "user_push_tokens", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_ip_city": { + "name": "vercel_ip_city", + "schema": "", + "columns": { + "vercel_ip_city_id": { + "name": "vercel_ip_city_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vercel_ip_city": { + "name": "vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_vercel_ip_city": { + "name": "UQ_vercel_ip_city", + "columns": [ + { + "expression": "vercel_ip_city", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_ip_country": { + "name": "vercel_ip_country", + "schema": "", + "columns": { + "vercel_ip_country_id": { + "name": "vercel_ip_country_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vercel_ip_country": { + "name": "vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_vercel_ip_country": { + "name": "UQ_vercel_ip_country", + "columns": [ + { + "expression": "vercel_ip_country", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_events": { + "name": "webhook_events", + "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": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_action": { + "name": "event_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "handlers_triggered": { + "name": "handlers_triggered", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "event_signature": { + "name": "event_signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_webhook_events_owned_by_org_id": { + "name": "IDX_webhook_events_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_owned_by_user_id": { + "name": "IDX_webhook_events_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_platform": { + "name": "IDX_webhook_events_platform", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_event_type": { + "name": "IDX_webhook_events_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_created_at": { + "name": "IDX_webhook_events_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_events_owned_by_organization_id_organizations_id_fk": { + "name": "webhook_events_owned_by_organization_id_organizations_id_fk", + "tableFrom": "webhook_events", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_events_owned_by_user_id_kilocode_users_id_fk": { + "name": "webhook_events_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "webhook_events", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_webhook_events_signature": { + "name": "UQ_webhook_events_signature", + "nullsNotDistinct": false, + "columns": [ + "event_signature" + ] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_events_owner_check": { + "name": "webhook_events_owner_check", + "value": "(\n (\"webhook_events\".\"owned_by_user_id\" IS NOT NULL AND \"webhook_events\".\"owned_by_organization_id\" IS NULL) OR\n (\"webhook_events\".\"owned_by_user_id\" IS NULL AND \"webhook_events\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.microdollar_usage_view": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_hit_tokens": { + "name": "cache_hit_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "http_x_forwarded_for": { + "name": "http_x_forwarded_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_city": { + "name": "http_x_vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_country": { + "name": "http_x_vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_latitude": { + "name": "http_x_vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_longitude": { + "name": "http_x_vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ja4_digest": { + "name": "http_x_vercel_ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_prompt_prefix": { + "name": "user_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt_prefix": { + "name": "system_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt_length": { + "name": "system_prompt_length", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "http_user_agent": { + "name": "http_user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_discount": { + "name": "cache_discount", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "max_tokens": { + "name": "max_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "has_middle_out_transform": { + "name": "has_middle_out_transform", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_error": { + "name": "has_error", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "abuse_classification": { + "name": "abuse_classification", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "inference_provider": { + "name": "inference_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finish_reason": { + "name": "finish_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency": { + "name": "latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "moderation_latency": { + "name": "moderation_latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "generation_time": { + "name": "generation_time", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "is_byok": { + "name": "is_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_user_byok": { + "name": "is_user_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "streamed": { + "name": "streamed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancelled": { + "name": "cancelled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "editor_name": { + "name": "editor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_kind": { + "name": "api_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_tools": { + "name": "has_tools", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_model": { + "name": "auto_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "market_cost": { + "name": "market_cost", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "definition": "\n SELECT\n mu.id,\n mu.kilo_user_id,\n meta.message_id,\n mu.cost,\n mu.input_tokens,\n mu.output_tokens,\n mu.cache_write_tokens,\n mu.cache_hit_tokens,\n mu.created_at,\n ip.http_ip AS http_x_forwarded_for,\n city.vercel_ip_city AS http_x_vercel_ip_city,\n country.vercel_ip_country AS http_x_vercel_ip_country,\n meta.vercel_ip_latitude AS http_x_vercel_ip_latitude,\n meta.vercel_ip_longitude AS http_x_vercel_ip_longitude,\n ja4.ja4_digest AS http_x_vercel_ja4_digest,\n mu.provider,\n mu.model,\n mu.requested_model,\n meta.user_prompt_prefix,\n spp.system_prompt_prefix,\n meta.system_prompt_length,\n ua.http_user_agent,\n mu.cache_discount,\n meta.max_tokens,\n meta.has_middle_out_transform,\n mu.has_error,\n mu.abuse_classification,\n mu.organization_id,\n mu.inference_provider,\n mu.project_id,\n meta.status_code,\n meta.upstream_id,\n frfr.finish_reason,\n meta.latency,\n meta.moderation_latency,\n meta.generation_time,\n meta.is_byok,\n meta.is_user_byok,\n meta.streamed,\n meta.cancelled,\n edit.editor_name,\n ak.api_kind,\n meta.has_tools,\n meta.machine_id,\n feat.feature,\n meta.session_id,\n md.mode,\n am.auto_model,\n meta.market_cost,\n meta.is_free\n FROM \"microdollar_usage\" mu\n LEFT JOIN \"microdollar_usage_metadata\" meta ON mu.id = meta.id\n LEFT JOIN \"http_ip\" ip ON meta.http_ip_id = ip.http_ip_id\n LEFT JOIN \"vercel_ip_city\" city ON meta.vercel_ip_city_id = city.vercel_ip_city_id\n LEFT JOIN \"vercel_ip_country\" country ON meta.vercel_ip_country_id = country.vercel_ip_country_id\n LEFT JOIN \"ja4_digest\" ja4 ON meta.ja4_digest_id = ja4.ja4_digest_id\n LEFT JOIN \"system_prompt_prefix\" spp ON meta.system_prompt_prefix_id = spp.system_prompt_prefix_id\n LEFT JOIN \"http_user_agent\" ua ON meta.http_user_agent_id = ua.http_user_agent_id\n LEFT JOIN \"finish_reason\" frfr ON meta.finish_reason_id = frfr.finish_reason_id\n LEFT JOIN \"editor_name\" edit ON meta.editor_name_id = edit.editor_name_id\n LEFT JOIN \"api_kind\" ak ON meta.api_kind_id = ak.api_kind_id\n LEFT JOIN \"feature\" feat ON meta.feature_id = feat.feature_id\n LEFT JOIN \"mode\" md ON meta.mode_id = md.mode_id\n LEFT JOIN \"auto_model\" am ON meta.auto_model_id = am.auto_model_id\n", + "name": "microdollar_usage_view", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index c06a4dba12..89c401949e 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -834,6 +834,13 @@ "when": 1778169599348, "tag": "0118_blue_rawhide_kid", "breakpoints": true + }, + { + "idx": 119, + "version": "7", + "when": 1778177693899, + "tag": "0119_sad_katie_power", + "breakpoints": true } ] } \ No newline at end of file