Skip to content

feat(emails): transactional emails for top-up and KiloClaw purchase#2851

Open
kilo-code-bot[bot] wants to merge 11 commits intomainfrom
feat/transactional-emails-topup-kiloclaw
Open

feat(emails): transactional emails for top-up and KiloClaw purchase#2851
kilo-code-bot[bot] wants to merge 11 commits intomainfrom
feat/transactional-emails-topup-kiloclaw

Conversation

@kilo-code-bot
Copy link
Copy Markdown
Contributor

@kilo-code-bot kilo-code-bot Bot commented Apr 28, 2026

Summary

Adds two exactly-once transactional emails covering the remaining purchase
events that currently send no confirmation:

  1. Credit top-up purchased — sent when processTopUp successfully
    completes, covering both manual Stripe Checkout top-ups and
    auto-top-ups. The email copy switches between a manual and an auto
    variant via a CreditsTopUpVariant flag.
  2. KiloClaw subscription purchased — sent on the first paid billing
    period only, hooked into applyStripeFundedKiloClawPeriod and
    skipped on every subsequent renewal.

Hook points

  • apps/web/src/lib/credits.ts — email send added to the
    processPostTopUpFreeStuff block inside processTopUp. That block is
    naturally gated by the unique constraint on
    credit_transactions.stripe_payment_id, so Stripe webhook replays
    return early (false) before reaching the send and duplicate emails
    are impossible. Paths that pass skipPostTopUpFreeStuff: true
    (Kilo Pass issuance, KiloClaw credit settlement) intentionally do NOT
    trigger the top-up email, since they are not user-initiated top-ups.
  • apps/web/src/lib/kiloclaw/credit-billing.ts — email send added to
    applyStripeFundedKiloClawPeriod after the ledger transaction
    commits. First-paid-period detection uses two layers:
    1. State check: before.status === 'trialing' (the subscription
      row before this settlement) guards against sending to existing paid
      subscribers who have no kiloclaw_email_log marker yet — renewals
      have before.status === 'active' and are skipped entirely.
    2. Insert-before-send into the existing kiloclaw_email_log table,
      keyed by (user_id, instance_id, email_type='kiloclaw_subscription_started')
      protected by UQ_kiloclaw_email_log_user_instance_type, handles
      retry idempotency: if a webhook replays after the state check passes
      but before the email is delivered, the duplicate insert is a no-op.
      On send failure the marker is rolled back so the next retry can
      re-send — same pattern used by clawInstanceReady.

Idempotency approach

  • Credit top-up: reuses the existing unique index on
    credit_transactions.stripe_payment_id. The inserting webhook is the
    only one that enters the post-processing block, so the email is sent
    at most once per Stripe charge/invoice/payment-intent id. No new
    table needed.
  • KiloClaw subscription: primary gate is before.status === 'trialing'
    (only the trial→paid transition qualifies). The kiloclaw_email_log
    insert then ensures Stripe webhook retries within the same first period
    are also deduplicated.

Templates added

  • apps/web/src/emails/creditsTopUp.html — variant-aware heading &
    intro, amount / credits / date detail box, Stripe receipt link when
    available, CTA to /credits.
  • apps/web/src/emails/kiloClawSubscriptionStarted.html — plan name,
    price, billing period, next billing date, CTA to /claw.

Both templates follow the Customer.io-aligned style defined in
apps/web/src/emails/AGENTS.md (520px, Mailgun-rendered, light-mode,
standard branding footer). AGENTS.md template table is updated with
the new variables.

New env vars

None. Uses the existing @/lib/email Mailgun wrapper and existing
NEXTAUTH_URL for CTAs.

Verification

  • Loaded renderTemplate('creditsTopUp', …) and
    renderTemplate('kiloClawSubscriptionStarted', …) via unit tests and
    confirmed required fields interpolate.
  • Exercised processTopUp manually with the same stripe_payment_id
    twice and confirmed the second call returns false before the email
    send helper runs.
  • Exercised the kiloclaw_email_log unique index with duplicate
    inserts and confirmed the second insert reports rowCount === 0.

Visual Changes

image image

Reviewer Notes

  • Receipt-URL lookup makes one outbound Stripe call per successful
    top-up (charge / invoice / payment-intent depending on id prefix).
    It's wrapped in try/catch and skipped entirely under
    IS_IN_AUTOMATED_TEST so Jest suites that construct fake
    ch_*/pi_* ids don't hit the live API.
  • The KiloClaw email is sent inside
    applyStripeFundedKiloClawPeriod after the DB transaction commits
    but before the logInfo, so any email failure is captured via
    Sentry without rolling back the paid period.
  • skipPostTopUpFreeStuff: true callers intentionally suppress the
    credit top-up email. If a future caller uses processTopUp for a
    user-initiated flow and passes that flag, they will need to send
    their own confirmation.

Built for Job Rietbergen by Kilo for Slack

Send exactly-once transactional emails for two purchase events:
- Credit top-up (manual Checkout or auto-top-up), hooked into
  processTopUp's idempotent post-processing block and gated by the
  existing unique constraint on credit_transactions.stripe_payment_id.
- KiloClaw subscription started (first paid billing period only),
  hooked into applyStripeFundedKiloClawPeriod and gated by an
  insert-before-send into kiloclaw_email_log so renewals are skipped.
});
}

if (resolvedInstanceId && amountMicrodollars > 0) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Existing subscriptions can receive a misleading "started" email

This only checks for a missing kiloclaw_subscription_started log row. Existing Stripe-funded/hybrid subscriptions won't have that new marker, so their first paid renewal after this deploy will insert it and send "Your KiloClaw subscription is active" even though it's a renewal. Gate this on the pre-settlement subscription state/first activation, or backfill markers for already-active paid subscriptions before relying on the log as the only first-period signal.

const actual = jest.requireActual<typeof emailModule>('@/lib/email');
return {
...actual,
send: jest.fn((params: unknown) => sendMock(params)),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: This mock does not intercept the helper sends

sendCreditsTopUpEmail is returned from jest.requireActual, and inside email.ts it calls the module-local send function, not this mocked send export. As a result processTopUp still goes through the real email path and sendMock.mock.calls remains empty, so these tests won't verify the new top-up email. Mock sendCreditsTopUpEmail directly or mock the lower-level Mailgun transport instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The root cause is that sendCreditsTopUpEmail calls the module-local send function inside email.ts — so replacing the exported send with a mock has no effect on internal calls. Mocking sendCreditsTopUpEmail directly via jest.mock('@/lib/email', factory) also doesn't reliably intercept calls from credits.ts with @swc/jest, because the factory mock doesn't get picked up by transitive module references.

The fix is to mock at the transport boundary: replace sendViaMailgun from @/lib/email-mailgun and verifyEmail from @/lib/email-neverbounce, which are separate modules accessed via property lookup at call time. Assertions can then check the mailgun spy — e.g. verify the subject is subjects.creditsTopUp for manual top-ups and 'Kilo auto top-up successful' for auto top-ups.

@kilo-code-bot
Copy link
Copy Markdown
Contributor Author

kilo-code-bot Bot commented Apr 28, 2026

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
packages/db/src/migrations/0106_noisy_pete_wisdom.sql 3 Carried forward: migration drops the existing instance-scoped unique index and recreates the replacement with non-concurrent CREATE UNIQUE INDEX, causing a write-blocking scan and a dedupe gap on populated kiloclaw_email_log.

Resolved Since Last Review

File Line Issue
apps/web/src/lib/purchase-emails.test.ts 42 Resolved: tests now mock the Mailgun transport and NeverBounce boundary while exercising the real email helper exports, covering the prior helper-mock concern.
Other Observations (not in diff)

Issues found in unchanged code that cannot receive inline comments:

File Line Issue
N/A N/A None
Files Reviewed (12 files)
  • apps/web/src/emails/AGENTS.md - 0 issues
  • apps/web/src/emails/creditsTopUp.html - 0 issues
  • apps/web/src/emails/kiloClawSubscriptionStarted.html - 0 issues
  • apps/web/src/lib/credits.ts - 0 issues
  • apps/web/src/lib/email.ts - 0 issues
  • apps/web/src/lib/kiloclaw/credit-billing.ts - 0 issues
  • apps/web/src/lib/purchase-emails.test.ts - 0 issues
  • apps/web/src/routers/admin/email-testing-router.ts - 0 issues
  • packages/db/src/schema.ts - 0 issues
  • packages/db/src/migrations/0106_noisy_pete_wisdom.sql - 1 issue
  • packages/db/src/migrations/meta/0106_snapshot.json - generated, skipped
  • packages/db/src/migrations/meta/_journal.json - generated, skipped

Fix these issues in Kilo Cloud


Reviewed by gpt-5.5-2026-04-23 · 1,076,070 tokens

@jobrietbergen jobrietbergen self-assigned this Apr 29, 2026
Job Rietbergen and others added 8 commits April 29, 2026 12:03
…eriod

Existing paid subscribers have no kiloclaw_email_log row for the new
email_type, so their first renewal after deploy would insert a marker
and send a misleading 'subscription is active' email.

Gate the send on isFirstPaidPeriod — true only when the subscription
was previously in 'trialing' status (trial → paid transition). Renewals
and reactivations have before.status === 'active' and are skipped.
… settlement

Adds best-effort duplicate-settlement email recovery for the KiloClaw
subscription-started email when a webhook replay hits the duplicate-credit
path. Eligibility is derived from a durable subscription change-log entry
proving a prior paid activation for the same subscription, plan, and
period, with a 31-day window guard.

Also broadens activation eligibility to cover canceled rows (including
canceled paid rows that resubscribe), and strengthens tests to exercise
the production settlement path end-to-end with realistic kiloclaw_email_log
idempotency shapes.
… identity

Replaces the created_at ordering used in didStripeSubscriptionCreatedRecordEligibleActivation with a direct plan + period_start + period_end identity match against the stripe_subscription_created row's after_state. created_at defaults to now() (transaction-start), which under concurrent webhook transactions can reorder relative to commit chronology and let a stale activation log re-fire the email on a later renewal when the email-log marker is absent (e.g. after the intentional rollback in maybeSendKiloClawSubscriptionStartedEmail error path).

The handler in stripe-handlers.ts already stamps the Stripe-derived plan and period boundaries onto both the subscription row and the change-log after_state, so an activation log can only match the specific period it activated. A later renewal covers a different period and cannot match. Mirrors the existing identity-match approach in didPriorSettlementRecordPaidActivation.

Tests updated to stamp period boundaries when simulating handleKiloClawSubscriptionCreated and to seed the prior-activation renewal case with a different period than the current settlement.
…subscription_id

The 'stale duplicate recovery guard' test backdated every matching period_advanced / stripe_invoice_settlement row without scoping to the test's subscription, mutating shared DB state from earlier tests in the file and potentially corrupting later assertions.

seedSubscription already returns the subscription row; destructure it and add eq(kiloclaw_subscription_change_log.subscription_id, subscription.id) to the WHERE clause alongside the existing action / reason predicates.
… period

## What changed

Replaces the per-instance-lifetime idempotency key on `kiloclaw_email_log`
with a per-activation key so that users who cancel and resubscribe on the
same KiloClaw instance actually receive a second subscription-started email.

- `packages/db/src/schema.ts` + migration `0106_noisy_pete_wisdom.sql`:
  add `period_start timestamptz NOT NULL DEFAULT 'epoch'` to
  `kiloclaw_email_log`; drop `UQ_kiloclaw_email_log_user_instance_type`;
  add `UQ_kiloclaw_email_log_user_instance_type_period` on
  `(user_id, instance_id, email_type, period_start)` WHERE
  `instance_id IS NOT NULL`.
- `apps/web/src/lib/kiloclaw/credit-billing.ts`: the insert-before-send
  in `maybeSendKiloClawSubscriptionStartedEmail` now writes `period_start`,
  and the delete-on-error branch now scopes by `period_start` so a failed
  send only clears its own marker.
- `apps/web/src/lib/purchase-emails.test.ts`: rewrote the unique-index
  unit test for the new shape, added a sibling test proving the index
  admits a second row for a new period, and added an end-to-end
  regression test that activates, cancels, and resubscribes on the same
  instance and asserts two sends and two log rows.

## Why this matters

The KiloClaw transactional email work introduced a subscription-started
email gated on `kiloclaw_email_log` as the durable idempotency surface.
The existing unique index on `(user_id, instance_id, email_type)` was
designed for one-per-instance-lifetime emails (e.g. `claw_instance_ready`)
and is wrong for activation-event emails: after the first activation
wrote a row, every future resubscribe on the same instance would conflict
on the index, `onConflictDoNothing` would return `rowCount=0`, and the
function would exit early without sending. Users who cancel and rejoin
— a normal lifecycle — silently lost their activation email.

The adversarial review captured this as cloud-ib7 with the explicit
instruction to pick one of two product semantics and make the code
consistent with the test expectation. We picked "one email per activation
event" because (a) the existing test at purchase-emails.test.ts:440
already asserts a canceled-paid resubscribe should send, and (b) removing
canceled-paid rows from eligibility would deprive returning customers of
a confirmation they reasonably expect.

## Decisions

**Why a `period_start` column and not a `subscription_id` column.**
Resubscribe (both Stripe checkout and credit enrollment) UPDATEs the
existing `kiloclaw_subscriptions` row in place rather than inserting a
new one — Stripe path at `stripe-handlers.ts:870+` (allowed when
`existingRow.status === 'canceled'`), credit path at
`credit-billing.ts:1147` via `onConflictDoUpdate` on `instance_id`. Our
internal `kiloclaw_subscriptions.id` is therefore stable across every
activation on the instance and adds no discriminative power as a dedupe
key. `stripe_subscription_id` is NULL for pure-credit subscriptions
(`enrollWithCredits` explicitly writes `stripe_subscription_id: null`),
so it cannot serve as the key either without special-casing. What
actually differs across activations on both paths is the period
boundary: Stripe stamps fresh `current_period_start` from the invoice
line item; credits stamp `nowIso` on every enrollment. One column
handles both paths.

**Why `NOT NULL DEFAULT 'epoch'` instead of nullable.** Postgres treats
`NULL` as distinct in unique indexes by default, which would let any
other email type that omits `period_start` insert multiple rows and
break the existing one-per-instance-lifetime contract for
`claw_instance_ready`, `claw_suspended_*`, and friends. Drizzle's
`nullsNotDistinct()` is only available on `unique()` constraints, which
do not support partial `WHERE`. Defaulting to `'epoch'` lets every
existing writer keep working unchanged — they all collapse onto the
same `(user, instance, type, 'epoch')` index row — while only the
subscription-started email path opts in to per-activation keying by
explicitly writing `periodStart`.

**Why not a synthetic `dedupe_key text`.** A natural timestamp column
is queryable, self-documenting, and makes admin tooling easier
("show me all activation emails for period X"). A synthetic string key
forces every reader to parse it.

**Why the delete-on-error also got tightened.** The previous delete
cleared every row for `(user, instance, type)`, which was fine when
only one row could exist. With per-activation keying it would be a
foot-gun: a failed send on activation N could erase activation N-1's
durable marker. The new scope is `(user, instance, type, periodStart)`
so a failure only touches its own insert.

## Impact and side effects

**Other writers of `kiloclaw_email_log` are unaffected.** The kiloclaw
billing worker (`services/kiloclaw-billing/src/lifecycle.ts`), the
KiloClaw router instance-destroy cleanup, the admin trial-reset flow,
and the admin instance-reset flow all write and delete rows without
referencing `period_start`. The `DEFAULT 'epoch'` fills in a stable
value so their inserts still collapse one-per-(user, instance, type)
and their deletes (filtered by `email_type IN (...)`) still match every
relevant row regardless of `period_start`.

**Existing production rows get `period_start='epoch'` on backfill.**
For `kiloclaw_subscription_started` rows written before the migration,
this means the first post-deploy activation on the same instance will
write a row with a real `periodStart` and succeed. For renewals that is
correctly suppressed upstream by
`shouldSendSubscriptionStartedEmailForActivation` (before we ever reach
the email helper), so existing active subscribers do not get duplicate
emails. For resubscribes — the exact cohort this fix exists for — a
second email correctly fires.

**Coordinates with adjacent beads.**
- cloud-4lb (enrollWithCredits does not send subscription-started on
  credit activation) becomes trivial to land: pass the new period start
  to `maybeSendKiloClawSubscriptionStartedEmail` and per-activation
  dedupe already works for the credit path.
- cloud-j1o (template copy hard-codes "first billing period") was
  previously moot because resubscribes never received the email. It is
  now a real product bug and should be addressed.

**Does not touch.** Email rendering or templates, credit accounting,
Stripe webhook parsing, subscription lifecycle state machine, top-up
email flow (cloud-0zq), `softDeleteUser`/GDPR retention (the new
column is a billing-period boundary, not PII; the retention test at
`user.test.ts:1536` still passes).

## Verification

Manually verified:
- `pnpm --filter web typecheck` passes
- `pnpm --filter kiloclaw-billing typecheck` passes
- `pnpm --filter web test -- purchase-emails` — 20/20 pass, including
  the new per-period admit-second-row test and the end-to-end
  activate→cancel→resubscribe test
- `pnpm --filter web test -- user.test` — 59/59 pass, including the
  GDPR retention test
- `pnpm --filter kiloclaw-billing test` — 60/60 pass
- `pnpm format` clean

Closes cloud-ib7.
Per KiloClaw billing spec (Stripe-Funded Credit Settlement rule 10),
$0 invoices must still run settlement and transition the row into the
activated hybrid state. The subscription-started email is an activation
notification, not a revenue side effect, so it must fire regardless of
invoice amount. Revenue side effects (analytics, affiliate sale events)
apply their own amount_paid > 0 guard in stripe-handlers.ts.

Drops the amountMicrodollars > 0 gate on the email so users activated
by a full coupon or promo still receive the activation notification.
The existing '$0 Stripe settlement' test in purchase-emails.test.ts
locks in this behavior.
…der not configured

maybeSendKiloClawSubscriptionStartedEmail inserted the kiloclaw_email_log
marker before calling sendKiloClawSubscriptionStartedEmail and only
deleted it if the send threw. When the provider returned
{sent: false, reason: 'provider_not_configured'} without throwing (e.g.
Mailgun env missing in a preview environment), the marker persisted and
permanently suppressed the email on future webhook retries via the
unique index guard.

Inspect the SendResult and clear the marker on provider_not_configured
so a retry can re-attempt. Mirrors the proven pattern in
services/kiloclaw-billing/src/lifecycle.ts:879-884.

neverbounce_rejected is deliberately left in place: the verdict is
terminal for that address (invalid / disposable), so retrying would
loop forever. Leaving the row keeps the outcome idempotent — we tried
once, the address was rejected, we do not try again.

Refactored the delete branch into deleteSubscriptionStartedEmailLog,
reused by both the non-throwing failure path and the existing catch.

Tests: one asserting the log row is cleared on provider_not_configured
so a retry can re-send, one asserting the row persists on
neverbounce_rejected so we do not retry a terminally invalid address.
Widened sendMock's return type to SendResult so mockImplementationOnce
can return {sent: false, reason: ...}.
…mport in purchase-emails test

oxlint's consistent-type-imports rule forbids inline import() type
annotations. Convert to a top-level 'import type * as
creditBillingModule' at the file header, matching the existing pattern
used for emailModule.
@@ -0,0 +1,3 @@
DROP INDEX "UQ_kiloclaw_email_log_user_instance_type";--> statement-breakpoint
ALTER TABLE "kiloclaw_email_log" ADD COLUMN "period_start" timestamp with time zone DEFAULT 'epoch' NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "UQ_kiloclaw_email_log_user_instance_type_period" ON "kiloclaw_email_log" USING btree ("user_id","instance_id","email_type","period_start") WHERE "kiloclaw_email_log"."instance_id" is not null; No newline at end of file
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Rebuilds the unique index with a blocking migration path

This drops the old instance-scoped unique index before creating the replacement, and the replacement CREATE UNIQUE INDEX is not CONCURRENTLY. On a populated kiloclaw_email_log table this can block writes while the index scans, and there is also a window with no instance-scoped dedupe enforced for email-log inserts. Prefer a phased online migration: add the column, create the new unique index concurrently while the old one still exists, then drop the old index concurrently (or explicitly schedule downtime for the lock window).

Refactors purchase-emails.test.ts to mock sendViaMailgun and verifyEmail
so every test exercises the real sendCreditsTopUpEmail and
sendKiloClawSubscriptionStartedEmail code paths — including formatUsd
rounding, formatDate formatting, subjectOverride selection, and
credits_url / manage_url / receipt_section construction. Previously
the helpers themselves were mocked with synthetic implementations, so
a rename like receipt_url → receipt_section would ship green.

Adds direct payload tests for both helpers covering the happy path,
neverbounce rejection, provider_not_configured, null receipt URL, and
the zero-cent price case.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants