1. Rowfire
  2. Use cases

Escalate the third declined charge in a month

Alert the account owner's team when an account's card fails for the third time in a calendar month in PostgreSQL. A window function makes the count.

  • PostgreSQL
  • Slack
  • Zendesk
  • Billing
  • once per account per month

The trigger

One read-only SELECT against PostgreSQL. Rowfire polls it for new rows, and the rule fires once per account per month.

SELECT *
FROM (
  SELECT a.id AS account_id,
         a.name AS account,
         a.owner_email,
         pa.invoice_no,
         pa.amount,
         pa.attempted_at,
         count(*) OVER (
           PARTITION BY pa.account_id, date_trunc('month', pa.attempted_at)
           ORDER BY pa.attempted_at
         ) AS declines_this_month
  FROM payment_attempts pa
  JOIN accounts a ON a.id = pa.account_id
  WHERE pa.outcome = 'failed'
    AND a.is_internal = false
) d
WHERE d.declines_this_month >= 3

A third failure in one month is a derived event: a count over rows, not any single row. The window function numbers each account’s declines within the month, and the outer query keeps the third and later ones. Table and column names are a sketch; adapt them to your schema.

The clock is attempted_at and the key is account_id. With once per account per month, the third decline fires and the fourth and fifth that month don’t.

{{ account }} has had {{ declines_this_month }} declined charges this month. Latest: {{ invoice_no }} ({{ amount }}). Owner: {{ owner_email }}.

Pair it with the everyday rule on single declines in declined cards to Slack and Zendesk: the first one handles routine retries, this one escalates the accounts that keep failing.

Questions

How is this different from alerting on every declined card?

The first decline is often an expired card the customer fixes on their own. The third in a month is a pattern worth a person's time, so this rule only fires once the count reaches three.

Does Rowfire's time window cut off the earlier declines?

No. Rowfire wraps the whole query in its time window, so the window function counts over every failed attempt first, and only the outer rows are filtered by time.