Automation Ā· Sales and Marketing Ā· Finance and Operations

New paying customers, posted to Slack the moment they convert

Every 15 minutes, spot customers whose first Stripe charge just succeeded and post one short win message per customer to Slack. Renewals, $0 invoices and repeats are skipped.

Created by Fletcher Richman from type.com

What it does

  • Posts only true first charges, including trial conversions; renewals and $0 invoices never post.
  • Never double-posts: it remembers handled invoices and checks the channel first.
  • Optional product-database lookup names the account and adds signup, teammates, integrations and a usage heads-up.
  • Runs fully in code every 15 minutes, with no agent cost when nothing is new.

Before you start

  • Stripe connected with read access to invoices.
  • Slack connected, with the Type app in the wins channel.
  • Optional: a read-only PostgreSQL connection to your product database.

See an example

Example output from a sample run. Unless the example says otherwise, company names and figures are sample data.

Example Slack post (illustrative, not real data)

šŸ’ø New paying customer: northwind.io (Northwind Labs) • Signed up Oct 20 (Jane Doe), connected Slack and GitHub • Raj Patel and Mei Chen also joined • Upgraded to Team, $99/month on Nov 3, after a trial • Already active: 310 messages, 42 agent runs so far

Without the optional enrichment query

Type's Stripe connector hides customer names and emails, so the post links to the customer in Stripe:

šŸ’ø New paying customer: Pro customer • Upgraded to Pro, $49/month on Nov 3, no trial

How to use New paying customers, posted to Slack the moment they convert

  1. Select Use automation to open a new conversation with the automation attached.
  2. Choose a space and describe any changes you want before sending the message.
  3. Work with the agent to connect your integrations and review the automation before turning it on.
Browse the automation instructions
---
name: "New Paying Customers to Slack"
description: "Every 15 minutes, find customers whose first paid Stripe invoice just landed and post one short win message per customer to a Slack channel. Renewals, $0 invoices, internal accounts and repeats are skipped."
connection_needs:
  - "Stripe (read access to invoices): list recently paid invoices and a customer's paid invoices through stripe_api_read (GetInvoices). Type's Stripe connector returns customer names and emails as [REDACTED]; the automation never relies on them."
  - "Slack (the Type Slack app, a member of the wins channel): search the channel's recent messages and post a message."
  - "Optional: PostgreSQL (your product database, read-only) to add signup, teammate, integration and usage details to each post."
inputs:
  - "stripe_connection: the Stripe connection"
  - "slack_connection: the Slack connection"
  - "wins_channel_id: the Slack channel ID where win posts go"
  - "internal_email_domains: comma-separated billing-email domains to ignore, such as your own company and test domains; applies only when Stripe returns a visible email (may be empty)"
  - "excluded_customer_ids: optional comma-separated Stripe customer IDs to ignore, such as internal or test customers (may be empty)"
  - "product_db_connection: optional PostgreSQL connection for enrichment; empty disables enrichment"
  - "enrichment_sql: optional read-only SQL returning one row per Stripe customer, bound by :stripe_customer_id (see Setup); empty disables enrichment"
  - "usage_primary_label: label of the first usage number returned by the enrichment query (source default: agent runs)"
  - "usage_secondary_label: label of the second usage number returned by the enrichment query (source default: messages)"
---

# New Paying Customers to Slack

## Purpose

Celebrate and act on every new paying customer the moment their first charge succeeds. Every 15 minutes, code reads recently paid Stripe invoices, keeps only a customer's **first invoice that actually charged money** (trial conversions included), and posts one short message per new customer to a Slack wins channel. Most runs find nothing and post nothing. No agent runs: the whole automation is code.

With the optional enrichment query, each post also says when the account signed up and who signed up first, who else joined, which integrations they connected, and a one-line usage note that flags near-zero usage so someone can do a quick onboarding touch.

Type's Stripe connector redacts customer names and emails (they arrive as `[REDACTED]`). Each post therefore names the customer by the enrichment query's account domain and name when available, else by a visible billing-email domain, else by a link to the customer in the Stripe dashboard. It never prints `[REDACTED]`.

## Setup

Settle every binding before creating the automation. None inherits a value from the original workspace.

| Binding | Meaning and how to supply it |
| --- | --- |
| `{{input:stripe_connection}}` | The Stripe connection (organizationIntegrationId). It needs read access to invoices. Save it in `integrationAccounts`. |
| `{{input:slack_connection}}` | The Slack connection. Save it in `integrationAccounts`. |
| `{{input:wins_channel_id}}` | The Slack channel ID for win posts (`slack_search_channels` with the channel name). Invite the Type app to the channel. |
| `{{input:internal_email_domains}}` | Comma-separated billing-email domains that are never wins, for example your own domain and QA domains. Leave empty if none. It applies only when Stripe returns a visible billing email; Type's Stripe connector redacts emails, so use `excluded_customer_ids` for accounts you must always skip. Visible customer names matching test, demo or sandbox are always skipped. |
| `{{input:excluded_customer_ids}}` | Optional. Comma-separated Stripe customer IDs (`cus_…`) that are never wins, for example your own company's and QA customers. Leave empty if none. Test-mode invoices and invoices on a Stripe test clock are always skipped. |
| `{{input:product_db_connection}}` | Optional. A read-only PostgreSQL connection to your product database. Leave empty to post Stripe facts only. |
| `{{input:enrichment_sql}}` | Optional. A read-only SQL query that returns at most one row for the customer. Match on `:stripe_customer_id`, which the code replaces with the quoted Stripe customer ID. `:customer_email` is also replaced, with the quoted billing email when Stripe shows one and with SQL `NULL` when it is redacted, so do not rely on it. Supported columns, all optional: `account_name` (text), `account_domain` (text, shown as the post's domain), `signed_up_at` (timestamp), `members` (JSON array of display names in join order; the first is who signed up), `integrations` (JSON array of names), `usage_primary` (number), `usage_secondary` (number). The query must not contain backticks. Leave empty to disable. |
| `{{input:usage_primary_label}}`, `{{input:usage_secondary_label}}` | Labels for the two usage numbers, used only when the query returns `usage_primary`. The source used agent runs (primary) and messages (secondary). |

Enrichment runs only when both `product_db_connection` (an `orgint_…` ID) and `enrichment_sql` are set. Create the automation with `check_automation_readiness` (Stripe read, Slack full access, PostgreSQL read when used) and then `create_task` with the code under Reference implementation and the saved `integrationAccounts`. Create it paused and enable it after review.

## Triggers and execution

Code automation with one recurring subscription, `scan`: every 900 seconds (15 minutes), all days. The entry stage receives `{ scheduledAt }`. There is no agent stage and no model setting. The run window is computed from `scheduledAt`, so a retried run evaluates the same window.

## Workflow

1. **Load state.** Read the checkpoint `{ handled: [invoice IDs] }`; a missing or malformed checkpoint means none handled.
2. **Read recent paid invoices.** Call Stripe `GetInvoices` with `{ status: "paid", limit: 100 }`. Keep invoices whose `status_transitions.paid_at` (or `created` when absent) is within the 3 hours before `scheduledAt`. Stripe lists newest-created first; fetch the next page with `starting_after` (up to 5 pages) only while `has_more` is true and the page's oldest invoice was created no more than 7 days before the window.
3. **Pre-filter without extra calls**, oldest payment first, ignoring invoices already in `handled`:
   - `livemode: false` → skip "test-mode invoice".
   - A `test_clock` → skip "test-clock invoice".
   - `amount_paid` of 0 → skip "$0 invoice".
   - Customer ID in `{{input:excluded_customer_ids}}` → skip "excluded customer".
   - A visible billing-email domain in `{{input:internal_email_domains}}`, or a visible customer name matching `test`, `demo` or `sandbox` as a word → skip "internal or test customer". Redacted values never match.
   - No valid customer ID → skip "missing Stripe customer".
   - At most 20 candidates per run; the rest wait for the next run.
4. **Confirm the first charge.** For each candidate, call `GetInvoices` with `{ customer, status: "paid", limit: 10 }`. Skip when: the invoice is not in that list; `amount_paid` ≤ 0; `post_payment_credit_notes_amount` ≄ `amount_paid` ("fully credited"); any other invoice with `amount_paid` > 0 was created earlier, or `has_more` is true ("not the first charge (renewal)"). The customer "had a trial" when an earlier $0 paid invoice exists.
5. **Enrich (optional).** Run `enrichment_sql` once for the customer (bound by `:stripe_customer_id`; `:customer_email` is `NULL` when redacted) and use the first row.
6. **Deduplicate in Slack.** Search the wins channel for the post's identity key: the domain, else the name, else the Stripe customer ID (`slack_search_messages`, 200 messages per channel, 20 results). If any match contains "New paying customer", skip "already posted in the wins channel". For a dashboard-link post the match must also contain `customers/<that exact customer ID>`, so a longer ID with the same prefix is not a duplicate.
7. **Post.** Call `slack_post_message` in the wins channel with the message below; it must return `status: "sent"`, otherwise the run fails.
8. **Save state** and complete with `{ candidates, posted: [identity keys], skipped: [{ invoice, reason }] }`.

Who the post names (redacted, empty or malformed values count as unknown):

- **Domain:** the enrichment row's `account_domain`, else the billing-email domain when Stripe shows an email and it is not free-mail (Gmail, Outlook, Hotmail, Live, Yahoo, iCloud, me.com, AOL, Proton).
- **Name:** the enrichment row's `account_name`, else a visible Stripe customer name.
- Header: `**<domain>** (<name>)` with a domain (the name is left out when unknown or equal to the domain), `**<name>**` with only a name, otherwise a link to the customer in the Stripe dashboard labelled with the plan ("Paid customer" when the plan is unknown): `**[<plan> customer](https://dashboard.stripe.com/customers/<customer ID>)**`.

Message format without enrichment:

```text
šŸ’ø New paying customer: **<domain>** (<customer name>)          (visible billing email)
šŸ’ø New paying customer: **[<plan> customer](https://dashboard.stripe.com/customers/<customer ID>)**   (redacted, as with Type's Stripe connector)
• Upgraded to **<plan>, <amount>/<interval>** on <Mon D>, after a trial | , no trial
```

With enrichment:

```text
šŸ’ø New paying customer: **<account_domain or billing domain>** (<account_name>)
• Signed up <Mon D> (<first member>), connected <up to 5 integrations, "+N more"> | no integrations connected yet
• <members 2–4, "+N more"> also joined | Solo so far, no other teammates have joined
• Upgraded to **<plan>, <amount>/<interval>** on <Mon D>, after a trial | , no trial
• Heads-up: no usage yet (<secondary> <label>, <primary> <label>), so a quick onboarding touch would help   (both 0)
  Heads-up: light usage so far (…), so a quick onboarding touch would help                                (primary < 10)
  Already active: <secondary> <label>, <primary> <label> so far                                            (otherwise)
```

Plan comes from the first invoice line's price or plan nickname, else from a description like `1 Ɨ Pro (at $49.00 / month)`, else "paid plan". Interval comes from the line description (`/ day|week|month|year`), else a line period longer than 40 days means year, else month. Amounts are shown as `$49` (or `$49.50`) for USD and `49 EUR` for other currencies. Dates are UTC. Nobody is @-mentioned.

## State and failure behavior

- Checkpoint `{ handled }` keeps the newest 300 invoice IDs (posted and skipped). The 3-hour window keeps the list small.
- Effect keys: `wins:recent:<scheduledAt>:<page>`, `wins:stripe:<invoice>`, `wins:details:<invoice>`, `wins:dedupe:<invoice>`, `wins:post:<invoice>`. The post's effect key plus the Slack search guard against double posts.
- Any failed Stripe, database or Slack call, or an unconfirmed post, fails the run before the checkpoint is saved; the next run re-evaluates the window, and candidates already posted are caught by the checkpoint or the Slack search.
- No message is ever posted when there is nothing new. Data is never modified anywhere.
- Redacted Stripe customer fields never fail a run and are never printed; they only change how the post names the customer and which exclusions can apply. Without enrichment or a visible email, internal accounts are excluded only by `excluded_customer_ids`.

## Integration data and capabilities

| Integration | Data and capabilities used | Relevant semantics |
| --- | --- | --- |
| Stripe | `stripe_api_read` with `stripe_api_operation_id: "GetInvoices"` and `parameters` (`status`, `limit`, `starting_after`, `customer`). Invoice fields: `id`, `customer`, `livemode`, `test_clock`, `amount_paid`, `currency`, `created`, `status_transitions.paid_at`, `post_payment_credit_notes_amount`, `lines.data[0]` (`description`, `period`, `price.nickname`, `plan.nickname`), and `customer_email` / `customer_name` only when visible; list `has_more`. | The response is an object `{ object: "list", data, has_more, url }`. Lists are newest-created first. Listing paid invoices without a customer filter and paging with `starting_after` was confirmed with read-only calls on Type's Stripe connector. That connector replaces `customer_email`, `customer_name`, `customer_phone`, address fields and `metadata` with `"[REDACTED]"`; IDs, amounts, `billing_reason`, `livemode`, `test_clock`, `lines` and `status_transitions` are returned. Trial conversions bill as `subscription_cycle`, so billing reason is not used to detect first charges. Read-only. |
| Slack (Type Slack app) | `slack_search_messages` (`query`, `channel_id`, `messages_per_channel: 200`, `limit: 20`, `response_format: "json"`) and `slack_post_message` (`channel_id`, `text`). | The post must return `status: "sent"`. The app must be in the channel. |
| PostgreSQL (optional) | `postgresql_query` (`integrationId`, `query`) through Type, returning `rows`. | One read-only query per new customer, bound by the Stripe customer ID; the billing email binds `NULL` when redacted. |

## Reference implementation

Original provider assumptions: Type's Stripe connection (`stripe_api_read` with Stripe API operation IDs), the Type Slack app tools, and Type's PostgreSQL query tool. The source found candidates in its own product database (stored Stripe webhook events joined to its account tables) and always enriched posts from that schema; this version reads candidates from Stripe directly so it works without any product database, and the enrichment is an optional query you supply. Because Type's Stripe connector redacts customer names and emails, this version also identifies customers by enrichment, a visible billing domain or a Stripe dashboard link, and adds test-mode, test-clock and customer-ID exclusions. **Reuse when these assumptions hold; adapt when they do not, preserving the behavior above.**

```ts
import { type ToolCall, TypeApiError } from "@type-dot-com/sdk";
import {
  type AutomationCodeContext,
  automation,
  code,
  complete,
  recurringEventSchema,
} from "@type-dot-com/sdk/automations";
import { err, ok, Result } from "neverthrow";
import { z } from "zod";

// Code-only automation. No agent, no LLM.
// Every 15 minutes: read recently paid Stripe invoices. Only when a plausible new
// customer shows up does it check that customer's invoice history, optionally enrich
// the post from your own database, and post one win message to Slack.

// Setup values: replace each input placeholder below with the installer's choice before deploying.
const STRIPE = "{{input:stripe_connection}}";
const SLACK = "{{input:slack_connection}}";
const WINS_CHANNEL = "{{input:wins_channel_id}}";
// Comma-separated customer email domains to ignore (your own company, test accounts). May be empty.
// Applies only when Stripe returns a visible billing email (Type's Stripe connector redacts it).
const INTERNAL_EMAIL_DOMAINS = "{{input:internal_email_domains}}";
// Comma-separated Stripe customer IDs to ignore (internal or test customers). May be empty.
const EXCLUDED_CUSTOMER_IDS = "{{input:excluded_customer_ids}}";
// Optional enrichment from your product database. Leave both empty to disable.
const PRODUCT_DB = "{{input:product_db_connection}}";
const ENRICHMENT_SQL = String.raw`{{input:enrichment_sql}}`;
const USAGE_PRIMARY_LABEL = "{{input:usage_primary_label}}";
const USAGE_SECONDARY_LABEL = "{{input:usage_secondary_label}}";

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const WINDOW_SECONDS = 3 * 3600; // invoices paid in the last 3 hours
const MAX_CANDIDATES = 20; // per run
const MAX_PAGES = 5; // 100 paid invoices per page
const CREATED_LOOKBACK_SECONDS = 7 * 86400; // stop paging once invoices were created this long before the window
const TEST_NAME = /(^|[^a-z])(test|demo|sandbox)([^a-z]|$)/i;
const FREE_MAIL = new Set([
  "gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com", "yahoo.com",
  "icloud.com", "me.com", "aol.com", "proton.me", "protonmail.com",
]);

const parseJson = Result.fromThrowable(
  (text: string): unknown => JSON.parse(text),
  () => undefined,
);

type ToolEnvelope = {
  content: { type?: string; text?: string }[];
  structuredContent?: unknown;
  isError?: boolean;
};

/** Returns structuredContent, or the first text block that parses as JSON. */
function payloadOf(value: ToolEnvelope): unknown {
  if (value.structuredContent && typeof value.structuredContent === "object")
    return value.structuredContent;
  for (const block of value.content) {
    const parsed = parseJson(block.text ?? "").unwrapOr(undefined);
    if (parsed !== undefined) return parsed;
  }
  return undefined;
}

async function callJson<S extends z.ZodType>(
  ctx: AutomationCodeContext,
  call: ToolCall,
  schema: S,
  effectKey: string,
): Promise<Result<z.output<S>, TypeApiError>> {
  const res = await ctx.tools.call(call, { automationEffectKey: effectKey });
  if (res.isErr()) return err(res.error);
  const value: ToolEnvelope = res.value;
  const text = value.content.map((c) => c.text ?? "").join("\n");
  if (value.isError)
    return err(new TypeApiError(`${call.name} failed: ${text.slice(0, 300)}`, "invalid_response"));
  // Accept the data from structuredContent or from a JSON text block, whichever fits.
  const options: unknown[] = [value.structuredContent];
  for (const block of value.content) options.push(parseJson(block.text ?? "").unwrapOr(undefined));
  for (const option of options) {
    const parsed = schema.safeParse(option);
    if (parsed.success) return ok(parsed.data);
  }
  return err(
    new TypeApiError(`${call.name} returned unexpected data: ${text.slice(0, 300)}`, "invalid_response"),
  );
}

function stripeRead(parameters: Record<string, string | number>): ToolCall {
  return {
    service: "stripe",
    name: "stripe_api_read",
    organizationIntegrationId: STRIPE,
    arguments: { stripe_api_operation_id: "GetInvoices", parameters },
  };
}

const invoiceSchema = z.looseObject({
  id: z.string(),
  customer: z.string().nullish(),
  customer_email: z.string().nullish(),
  customer_name: z.string().nullish(),
  amount_paid: z.number(),
  currency: z.string(),
  created: z.number(),
  billing_reason: z.string().nullish(),
  livemode: z.boolean().nullish(),
  test_clock: z.unknown().optional(),
  post_payment_credit_notes_amount: z.number().nullish(),
  status_transitions: z.looseObject({ paid_at: z.number().nullish() }).nullish(),
  lines: z
    .looseObject({
      data: z.array(
        z.looseObject({
          description: z.string().nullish(),
          period: z.looseObject({ start: z.number(), end: z.number() }).nullish(),
          price: z.looseObject({ nickname: z.string().nullish() }).nullish(),
          plan: z.looseObject({ nickname: z.string().nullish() }).nullish(),
        }),
      ),
    })
    .nullish(),
});
const invoiceListSchema = z.looseObject({
  data: z.array(invoiceSchema),
  has_more: z.boolean(),
});
type Invoice = z.output<typeof invoiceSchema>;

const enrichmentRow = z.looseObject({
  account_name: z.string().nullish(),
  account_domain: z.string().nullish(),
  signed_up_at: z.union([z.string(), z.number()]).nullish(),
  members: z.array(z.string().nullable()).nullish(),
  integrations: z.array(z.string().nullable()).nullish(),
  usage_primary: z.union([z.string(), z.number()]).nullish(),
  usage_secondary: z.union([z.string(), z.number()]).nullish(),
});
type Enrichment = z.output<typeof enrichmentRow>;

const searchSchema = z.looseObject({
  messages: z.array(z.looseObject({ text: z.string() })),
});

const checkpointSchema = z.object({ handled: z.array(z.string()) });

const quote = (s: string) => `'${s.replace(/'/g, "''")}'`;
const isId = (s: string) => /^[A-Za-z0-9_]+$/.test(s);
const enrichmentEnabled = () => PRODUCT_DB.startsWith("orgint_") && ENRICHMENT_SQL.trim().length > 0;

function shortDate(input: string | number): string {
  const d = new Date(typeof input === "number" ? input * 1000 : input);
  return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
}

function intervalOf(invoice: Invoice): string {
  const line = invoice.lines?.data[0];
  const match = line?.description?.match(/\/\s*(day|week|month|year)/);
  if (match) return match[1];
  if (line?.period && line.period.end - line.period.start > 40 * 86400) return "year";
  return "month";
}

/** Plan name from the first invoice line: price or plan nickname, else "1 Ɨ <name> (at …)". */
function planOf(invoice: Invoice): string | null {
  const line = invoice.lines?.data[0];
  const nickname = line?.price?.nickname ?? line?.plan?.nickname;
  if (nickname) return nickname;
  const described = line?.description?.match(/^\s*\d+\s*[Ɨx]\s*(.+?)\s*\(at\b/);
  return described ? described[1] : null;
}

function money(cents: number, currency: string): string {
  const amount = cents % 100 === 0 ? String(cents / 100) : (cents / 100).toFixed(2);
  return currency.toLowerCase() === "usd" ? `$${amount}` : `${amount} ${currency.toUpperCase()}`;
}

function listNames(names: string[]): string {
  if (names.length <= 1) return names.join("");
  if (names.length === 2) return `${names[0]} and ${names[1]}`;
  return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
}

/**
 * A customer field Stripe actually returned, or null when it is unknown. Type's Stripe
 * connector replaces customer_email, customer_name, phone, address and metadata with
 * "[REDACTED]", so redacted, empty and (for emails) values without "@" are all unknown.
 */
function known(value: string | null | undefined, kind: "email" | "text"): string | null {
  const text = (value ?? "").trim();
  if (!text || /\[redacted\]/i.test(text) || /^redacted$/i.test(text)) return null;
  if (kind === "email" && !text.includes("@")) return null;
  return text;
}

/** Domain of a visible billing email, or null. */
function emailDomain(invoice: Invoice): string | null {
  const email = known(invoice.customer_email, "email");
  if (!email) return null;
  const domain = email.slice(email.lastIndexOf("@") + 1).trim().toLowerCase();
  return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain) ? domain : null;
}

/** Text safe inside a Markdown link label. */
const plain = (s: string) => s.replace(/[\[\]*_`<>|]/g, "").trim();

type Identity = { key: string; header: string; byCustomerId: boolean };

/**
 * Who a post is about, never "[REDACTED]". Domain: the enrichment query's account_domain,
 * else a visible billing-email domain that is not free-mail. Name: the enrichment query's
 * account_name, else a visible Stripe customer name. With neither, link to the customer in
 * the Stripe dashboard, labelled with the plan.
 */
function identityOf(invoice: Invoice, enrichment: Enrichment | null, plan: string): Identity {
  const billingDomain = emailDomain(invoice);
  const domain =
    known(enrichment?.account_domain, "text")?.toLowerCase() ??
    (billingDomain && !FREE_MAIL.has(billingDomain) ? billingDomain : null);
  const name = known(enrichment?.account_name, "text") ?? known(invoice.customer_name, "text");
  if (domain)
    return { key: domain, header: `**${domain}**${name && name !== domain ? ` (${name})` : ""}`, byCustomerId: false };
  if (name) return { key: name, header: `**${name}**`, byCustomerId: false };
  const customer = invoice.customer ?? invoice.id;
  return {
    key: customer,
    header: `**[${plain(plan) || "Paid"} customer](https://dashboard.stripe.com/customers/${customer})**`,
    byCustomerId: true,
  };
}

function formatPost(
  invoice: Invoice,
  hadTrial: boolean,
  enrichment: Enrichment | null,
  identity: Identity,
): string {
  const paidAt = invoice.status_transitions?.paid_at ?? invoice.created;
  const plan = planOf(invoice) ?? "paid plan";
  const upgraded = `Upgraded to **${plan}, ${money(invoice.amount_paid, invoice.currency)}/${intervalOf(invoice)}** on ${shortDate(paidAt)}${hadTrial ? ", after a trial" : ", no trial"}`;
  const lines = [`šŸ’ø New paying customer: ${identity.header}`];
  if (enrichment) {
    const members = (enrichment.members ?? []).filter((n): n is string => !!n);
    const integrations = (enrichment.integrations ?? []).filter((i): i is string => !!i);
    const signedUp = enrichment.signed_up_at
      ? `Signed up ${shortDate(enrichment.signed_up_at)}${members[0] ? ` (${members[0]})` : ""}`
      : members[0]
        ? `First user: ${members[0]}`
        : "Signup details unavailable";
    const connected = integrations.length
      ? `connected ${listNames(integrations.slice(0, 5))}${integrations.length > 5 ? ` +${integrations.length - 5} more` : ""}`
      : "no integrations connected yet";
    const teammates = members.length > 1
      ? `${listNames(members.slice(1, 4))}${members.length > 4 ? ` +${members.length - 4} more` : ""} also joined`
      : "Solo so far, no other teammates have joined";
    lines.push(`• ${signedUp}, ${connected}`, `• ${teammates}`, `• ${upgraded}`);
    if (enrichment.usage_primary !== null && enrichment.usage_primary !== undefined) {
      const primary = Number(enrichment.usage_primary);
      const secondary = Number(enrichment.usage_secondary ?? 0);
      const usageStats = `${secondary} ${USAGE_SECONDARY_LABEL}, ${primary} ${USAGE_PRIMARY_LABEL}`;
      lines.push(
        primary === 0 && secondary === 0
          ? `• Heads-up: no usage yet (${usageStats}), so a quick onboarding touch would help`
          : primary < 10
            ? `• Heads-up: light usage so far (${usageStats}), so a quick onboarding touch would help`
            : `• Already active: ${usageStats} so far`,
      );
    }
  } else {
    lines.push(`• ${upgraded}`);
  }
  return lines.join("\n");
}

const output = z.object({
  candidates: z.number().int(),
  posted: z.array(z.string()),
  skipped: z.array(z.object({ invoice: z.string(), reason: z.string() })),
});

export default automation({
  output,
  deployment: {
    subscriptions: {
      scan: {
        title: "Check for new paying customers every 15 minutes",
        description: "Post each brand-new paying customer to the wins channel exactly once",
        source: { kind: "recurring", schedule: { kind: "interval", everySeconds: 900 } },
        entry: "scan",
      },
    },
  },
  stages: {
    scan: code({
      input: recurringEventSchema,
      transitions: [],
      async run({ ctx, input: event }) {
        const saved = checkpointSchema.safeParse(ctx.checkpoint?.value);
        const handled = new Set(saved.success ? saved.data.handled : []);
        const key = event.scheduledAt;
        const windowStart = Math.floor(Date.parse(event.scheduledAt) / 1000) - WINDOW_SECONDS;
        const internal = INTERNAL_EMAIL_DOMAINS.split(",")
          .map((d) => d.trim().toLowerCase().replace(/^@/, ""))
          .filter((d) => d.length > 0);
        const excluded = new Set(
          EXCLUDED_CUSTOMER_IDS.split(/[\s,]+/)
            .map((id) => id.trim())
            .filter((id) => id.length > 0 && isId(id)),
        );

        // Paid invoices whose payment landed in the last 3 hours. Stripe lists newest-created first.
        const recent: Invoice[] = [];
        let startingAfter: string | undefined;
        for (let page = 0; page < MAX_PAGES; page++) {
          const listed = await callJson(
            ctx,
            stripeRead({ status: "paid", limit: 100, ...(startingAfter ? { starting_after: startingAfter } : {}) }),
            invoiceListSchema,
            `wins:recent:${key}:${page}`,
          );
          if (listed.isErr()) return err(listed.error);
          for (const inv of listed.value.data)
            if ((inv.status_transitions?.paid_at ?? inv.created) >= windowStart) recent.push(inv);
          const last = listed.value.data[listed.value.data.length - 1];
          if (!listed.value.has_more || !last || last.created < windowStart - CREATED_LOOKBACK_SECONDS) break;
          startingAfter = last.id;
        }
        recent.sort((a, b) => (a.status_transitions?.paid_at ?? a.created) - (b.status_transitions?.paid_at ?? b.created));

        const posted: string[] = [];
        const skipped: { invoice: string; reason: string }[] = [];
        const skip = (invoice: string, reason: string) => {
          skipped.push({ invoice, reason });
          handled.add(invoice);
        };
        const fresh = recent.filter((inv) => !handled.has(inv.id));
        const candidates: Invoice[] = [];
        for (const inv of fresh) {
          const domain = emailDomain(inv);
          if (inv.livemode === false) skip(inv.id, "test-mode invoice");
          else if (inv.test_clock !== null && inv.test_clock !== undefined && inv.test_clock !== "")
            skip(inv.id, "test-clock invoice");
          else if (inv.amount_paid <= 0) skip(inv.id, "$0 invoice");
          else if (inv.customer && excluded.has(inv.customer)) skip(inv.id, "excluded customer");
          else if ((domain && internal.includes(domain)) || TEST_NAME.test(known(inv.customer_name, "text") ?? ""))
            skip(inv.id, "internal or test customer");
          else if (!inv.customer || !isId(inv.customer) || !isId(inv.id)) skip(inv.id, "missing Stripe customer");
          else if (candidates.length < MAX_CANDIDATES) candidates.push(inv);
        }

        for (const candidate of candidates) {
          const customer = candidate.customer ?? "";
          // Stripe is the source of truth: this must be the customer's first invoice
          // that actually charged money (trial conversions bill as subscription_cycle).
          const invoices = await callJson(
            ctx,
            stripeRead({ customer, status: "paid", limit: 10 }),
            invoiceListSchema,
            `wins:stripe:${candidate.id}`,
          );
          if (invoices.isErr()) return err(invoices.error);
          const list = invoices.value.data;
          const invoice = list.find((i) => i.id === candidate.id);
          if (!invoice) {
            skip(candidate.id, "invoice not in customer's recent paid invoices");
            continue;
          }
          if (invoice.amount_paid <= 0) {
            skip(candidate.id, "$0 invoice");
            continue;
          }
          if ((invoice.post_payment_credit_notes_amount ?? 0) >= invoice.amount_paid) {
            skip(candidate.id, "fully credited");
            continue;
          }
          const earlierCharged = list.some(
            (i) => i.id !== invoice.id && i.amount_paid > 0 && i.created < invoice.created,
          );
          if (earlierCharged || invoices.value.has_more) {
            skip(candidate.id, "not the first charge (renewal)");
            continue;
          }
          const hadTrial = list.some((i) => i.created < invoice.created && i.amount_paid === 0);

          // Optional enrichment hook: one row from your own database for this Stripe customer.
          let enrichment: Enrichment | null = null;
          if (enrichmentEnabled()) {
            // Bind by Stripe customer ID. The billing email is often redacted by the Stripe
            // connector; then :customer_email binds SQL NULL, which matches nothing.
            const email = known(invoice.customer_email, "email") ?? known(candidate.customer_email, "email");
            const query = ENRICHMENT_SQL.replaceAll(":stripe_customer_id", quote(customer)).replaceAll(
              ":customer_email",
              email ? quote(email) : "NULL",
            );
            const rows = await callJson(
              ctx,
              { service: "type-api", name: "postgresql_query", arguments: { integrationId: PRODUCT_DB, query } },
              z.looseObject({ rows: z.array(enrichmentRow) }),
              `wins:details:${candidate.id}`,
            );
            if (rows.isErr()) return err(rows.error);
            enrichment = rows.value.rows[0] ?? null;
          }

          // Never double-post: skip if the channel already has a win post for this customer.
          const merged: Invoice = { ...candidate, ...invoice };
          const identity = identityOf(merged, enrichment, planOf(merged) ?? "Paid");
          const search = await callJson(
            ctx,
            {
              service: "slack",
              name: "slack_search_messages",
              organizationIntegrationId: SLACK,
              arguments: {
                query: identity.key,
                channel_id: WINS_CHANNEL,
                messages_per_channel: 200,
                limit: 20,
                response_format: "json",
              },
            },
            searchSchema,
            `wins:dedupe:${candidate.id}`,
          );
          if (search.isErr()) return err(search.error);
          // A dashboard-link post names the customer only in its URL: match that exact ID
          // (identity.key is then a validated Stripe customer ID, safe inside a RegExp).
          const sameCustomer = identity.byCustomerId
            ? new RegExp(`customers/${identity.key}(?![A-Za-z0-9_])`)
            : null;
          if (
            search.value.messages.some(
              (m) => /New paying customer/i.test(m.text) && (!sameCustomer || sameCustomer.test(m.text)),
            )
          ) {
            skip(candidate.id, "already posted in the wins channel");
            continue;
          }

          const sent = await ctx.tools.call(
            {
              service: "slack",
              name: "slack_post_message",
              organizationIntegrationId: SLACK,
              arguments: { channel_id: WINS_CHANNEL, text: formatPost(merged, hadTrial, enrichment, identity) },
            },
            { automationEffectKey: `wins:post:${candidate.id}` },
          );
          if (sent.isErr()) return err(sent.error);
          const receipt = z.looseObject({ status: z.literal("sent") }).safeParse(payloadOf(sent.value));
          if (sent.value.isError || !receipt.success)
            return err(new TypeApiError(`Slack post failed for ${identity.key}`, "invalid_response"));
          handled.add(candidate.id);
          posted.push(identity.key);
        }

        // Keep the newest 300 handled invoice IDs; the window is only 3 hours.
        const checkpoint = ctx.saveCheckpoint({ handled: [...handled].slice(-300) });
        if (checkpoint.isErr()) return err(checkpoint.error);
        return complete({ candidates: candidates.length, posted, skipped });
      },
    }),
  },
});
```

## Verification

**Examples derived from the source rules and the redaction handling:**

- A trial customer's first $49/month charge posts once: "šŸ’ø New paying customer: **acme.com** (Acme Inc)" with "Upgraded to **Pro, $49/month** on Nov 3, after a trial".
- A monthly renewal for a long-time customer posts nothing.
- A run with no new first charges posts nothing at all.
- With Type's Stripe connector (customer name and email redacted) and no enrichment, a $99/month first charge posts "šŸ’ø New paying customer: **[Team customer](https://dashboard.stripe.com/customers/cus_…)**" and never shows `[REDACTED]`.

**Live read-only check of the call shape:** on Type's Stripe connector, `GetInvoices` with `{ status: "paid", limit: 1 }` and then with `starting_after` succeeded and returned `{ object, data, has_more, url }`. Customer names and emails came back as `[REDACTED]`, which the code below handles.

**Fixture checks run on this code (local deploy-equivalent validation with and without automation reports enabled, plus stage fixtures; synthetic IDs, no live calls; 17 cases, all passed):**

1. No invoice paid in the window → nothing posted. Passed.
2. First charge after a $0 trial invoice → one post with the exact text above, invoice saved in the checkpoint. Passed.
3. Earlier charged invoice for the customer → "not the first charge (renewal)", no Slack calls. Passed.
4. Invoice already in the checkpoint → no customer lookup. Passed.
5. The wins channel already has a "New paying customer" post for the domain → skipped. Passed.
6. Internal-domain, test-name and $0 invoices → skipped without lookups. Passed.
7. `has_more` with a page inside the lookback → second page fetched with `starting_after`. Passed.
8. Enrichment enabled → SQL with the quoted customer ID; signup, teammate, integration and "Already active" bullets. Passed.
9. Enrichment enabled with light usage → "Heads-up: light usage so far" bullet. Passed.
10. Redacted customer name and email, no enrichment → header is the Stripe dashboard link labelled "Team customer"; the Slack search uses the customer ID; nothing prints `[REDACTED]`. Passed.
11. Redacted customer already posted with its dashboard link → skipped. Passed.
12. A dashboard-link post for a longer customer ID with the same prefix is not treated as a duplicate. Passed.
13. `livemode: false` → "test-mode invoice", no lookups. Passed.
14. Invoice on a test clock → "test-clock invoice", no lookups. Passed.
15. Customer ID in `excluded_customer_ids` → "excluded customer", no lookups. Passed.
16. Enrichment with a redacted email → SQL binds `:customer_email` as `NULL`, and the header uses the row's `account_domain` and `account_name`. Passed.
17. Enrichment that returns no row → falls back to the dashboard link. Passed.

**Proposed acceptance checks after installation:**

1. Point the wins channel at a private test channel, enable, and wait for the next real first charge (test-mode and test-clock invoices are skipped by design); confirm exactly one post.
2. Confirm that a renewal in the same window does not post.
3. If enrichment is used, run the SQL by hand for a known customer and confirm it returns one row with the documented columns.
4. Add your own internal Stripe customer IDs to `excluded_customer_ids` and confirm a payment from one of them does not post.