Skip to content
Seamline
← Use cases

Which accounts approaching renewal have overdue invoices and open tickets?

7 min read (+ optional SQL)

By Tim, founder of Seamline · Last updated 2 October 2026

The query is easy. The hard part is making the systems agree on who the customer is, what the data means, and why the answer should be trusted.

This use case walks through that in four steps: give every customer one ID, define the terms, ask the question, show the evidence. You can read it in about six minutes. If you want to build it yourself, every step has the full SQL, tested on Postgres, behind a "Show the SQL" toggle.

The question

Before every customer call, I do the same thing: open billing for invoices, the CRM for the contract, support for tickets. Fifteen to twenty minutes per account, just to learn what's going on. With a renewal coming up, that context matters most, and it's spread across the most tools.

The question in this use case came up in a discussion among customer success people about where to start. I think it's the right first one:

  • It's specific. Three conditions, all measurable.
  • It's early. An unpaid invoice and an unresolved problem 60 days before renewal can still be fixed. At 10 days, much less so.
  • It forces the foundation. You can't answer it without linking the same customer across three tools. Once that link exists, every other cross-tool question gets easier.

The goal is a list like this, where every row shows why it's there:

AccountOwnerRenews inOverdueOpen ticketsEvidence
Atlas & RoweDaan41 days€3,000 (23 days)1 (oldest 7 days)INV-2042 · T-4180
Northwind AnalyticsSanne64 days€1,950 (17 days)2 (oldest 15 days)INV-2041 · T-4170, T-4171

All companies, people and numbers in this use case are fictional.

Why it's harder than it looks

Each condition lives in a different tool:

 Renewal date   →  CRM (HubSpot, Salesforce)
 Invoice status →  Billing (Stripe, Chargebee)
 Open tickets   →  Support (Zendesk, Freshdesk)

Each tool is right about its own piece. None of them knows the others exist. And none of them agree on what the customer is called.

Step 1: Give every customer one ID

Here's one company, as each tool sees it:

 CRM       Northwind Analytics          northwind.io            hs_101
 Billing   NORTHWIND ANALYTICS B.V.     finance@northwind.io    cus_A1
 Support   northwind.io                 northwind.io            zd_9

To you, that's obviously one customer. To a database, it's three strings that don't match. The tempting fix is to compare names every time you run a report. Don't. That means re-deciding who the customer is on every query, and the answer can quietly change when someone edits a field.

Instead, decide it once and write it down. Give the customer an ID of your own, CUST_000001, and record which record in each tool belongs to it:

 CUST_000001  Northwind Analytics
   ├── crm      hs_101    seed     confirmed
   ├── billing  cus_A1    domain   confirmed
   └── support  zd_9      domain   confirmed

That's two small tables: customers (one row per real customer) and customer_source_ids (which native record belongs to which customer, how it was matched, and who confirmed it). From then on, every query joins on customer_id. The native IDs stay, because you need them to link back to the source: cus_A1 opens the customer in billing, zd_9 the organisation in support.

You don't need to push this ID into HubSpot or Stripe for it to work. The identity layer sits between your tools. Writing it back later, for example as a custom property in your CRM, is a useful second step, not a requirement.

Rules propose, people confirm

New records keep arriving, so links still have to be found. That's where matching rules come in, but only to propose a link, not to decide it:

  1. Seed from the anchor. For a renewal question, every CRM company with a contract becomes a customer. Those links are confirmed by definition.
  2. Match on email domain. finance@northwind.io matches the customer's domain northwind.io. In this use case, domain matches are confirmed automatically. On a first run with real data, I'd review them too: agencies managing several clients, holdings with subsidiaries, outsourced finance teams and shared domains all break domain matching.
  3. Match on normalised name as a fallback. Lowercase, remove punctuation, drop "B.V." or "Ltd", treat "and" and "&" as the same. Name matches always go to a review queue.

Normalisation is not identity. Turning "NORTHWIND ANALYTICS B.V." into northwindanalytics helps you find candidates. It never decides who the customer is. A person does, once, and the decision is stored.

On the sample data, the review queue holds three proposed matches:

CustomerSourceRecordName in sourceWhy it needs a person
CUST_000002 · Atlas & Rowecrmhs_103Atlas and RoweProbably a duplicate CRM record
CUST_000002 · Atlas & Rowesupportzd_10Atlas & RoweNo domain stored in support
CUST_000005 · Mosaic Retailbillingcus_A5Mosaic RetailInvoices go to a Gmail address

Only confirmed links count. Until someone confirms the support match, Atlas & Rowe's open ticket is invisible to the question in step 3, and Atlas & Rowe doesn't appear in the results. That's intended: an unconfirmed match shouldn't drive a renewal alarm.

Why this is sturdier

  • Sources can change without breaking identity. If someone deletes the domain from the Northwind record in the CRM, the customer keeps its stored domain and its confirmed links. The answer doesn't change. The edit can become an alert instead of a silent new identity.
  • Ambiguity is blocked. A source record can be confirmed for one customer only. If a rule tries to link cus_A1 to a second customer, the database refuses.
  • Gaps are visible. Any source record without a confirmed customer shows up in an "unlinked" list. In testing, I added a billing customer called "Northwind Analytics Germany GmbH" with a .de email. No rule matched it, which is right: whether a subsidiary is the same customer is a business decision. It appeared in the unlinked list until I linked it by hand.
  • Every decision has a history. status, match_method, confirmed_by and confirmed_at tell you who decided what, and when. Rejected matches stay rejected, so the same false pair doesn't come back to the queue.
Show the SQL for step 1

Two helper functions normalise names and email domains. They only help find candidates:

-- Turn a company name into a matching key:
-- "NORTHWIND ANALYTICS B.V." and "Northwind Analytics" both become "northwindanalytics"
CREATE OR REPLACE FUNCTION name_key(raw text) RETURNS text AS $$
  SELECT replace(
           regexp_replace(
             regexp_replace(
               ' ' || trim(regexp_replace(lower(raw), '[^a-z0-9]+', ' ', 'g')) || ' ',
               ' (b v|bv|ltd|inc|gmbh|llc|as) $', ' '),   -- drop a legal suffix
             ' and ', ' ', 'g'),                           -- "and" and "&" match
           ' ', '')
$$ LANGUAGE sql IMMUTABLE;

-- Business email domain only; personal addresses can't identify a company
CREATE OR REPLACE FUNCTION email_domain(email text) RETURNS text AS $$
  SELECT CASE
           WHEN lower(split_part(email, '@', 2)) IN ('gmail.com', 'outlook.com', 'hotmail.com', 'icloud.com')
           THEN NULL
           ELSE lower(split_part(email, '@', 2))
         END
$$ LANGUAGE sql IMMUTABLE;

The identity layer: two tables, and a guard that stops one record being confirmed for two customers:

-- The identity layer: one row per real customer, owned by us, not by any tool
CREATE SEQUENCE customer_seq;

CREATE TABLE customers (
  customer_id      text PRIMARY KEY DEFAULT 'CUST_' || lpad(nextval('customer_seq')::text, 6, '0'),
  canonical_name   text NOT NULL,     -- the name we agree to use
  canonical_domain text,              -- decided once, kept even if a source loses it
  created_at       timestamptz NOT NULL DEFAULT now()
);

-- Which source record belongs to which customer, how we know, and who confirmed it
CREATE TABLE customer_source_ids (
  customer_id  text NOT NULL REFERENCES customers,
  source       text NOT NULL,         -- 'crm', 'billing', 'support'
  source_id    text NOT NULL,         -- the native ID, for linking back to the tool
  match_method text NOT NULL,         -- 'seed', 'domain', 'name'
  status       text NOT NULL CHECK (status IN ('proposed', 'confirmed', 'rejected')),
  confirmed_by text,
  confirmed_at timestamptz,
  PRIMARY KEY (customer_id, source, source_id)
);

-- A source record can be CONFIRMED for one customer only
CREATE UNIQUE INDEX one_confirmed_customer_per_record
  ON customer_source_ids (source, source_id) WHERE status = 'confirmed';

Seed the customers from the CRM records that hold a contract:

-- Seed: every CRM company with a contract becomes a customer, once.
-- The canonical ID is ours; the CRM ID is kept as a source link.
WITH seeded AS MATERIALIZED (
  SELECT id AS crm_id, name, lower(domain) AS domain,
         'CUST_' || lpad(nextval('customer_seq')::text, 6, '0') AS customer_id
  FROM crm_companies
  WHERE renewal_date IS NOT NULL
),
new_customers AS (
  INSERT INTO customers (customer_id, canonical_name, canonical_domain)
  SELECT customer_id, name, domain FROM seeded
)
INSERT INTO customer_source_ids (customer_id, source, source_id, match_method, status, confirmed_by, confirmed_at)
SELECT customer_id, 'crm', crm_id, 'seed', 'confirmed', 'seed', now()
FROM seeded;

Propose links for everything not linked yet. Domain matches are confirmed automatically; name matches wait for a person. You can run this again whenever new records arrive:

-- Propose links for every source record that isn't linked yet.
-- Domain matches are confirmed automatically; name matches wait for a person.
INSERT INTO customer_source_ids (customer_id, source, source_id, match_method, status, confirmed_by, confirmed_at)
SELECT c.customer_id, 'billing', b.id,
       CASE WHEN c.canonical_domain = email_domain(b.email) THEN 'domain' ELSE 'name' END,
       CASE WHEN c.canonical_domain = email_domain(b.email) THEN 'confirmed' ELSE 'proposed' END,
       CASE WHEN c.canonical_domain = email_domain(b.email) THEN 'rule:domain' END,
       CASE WHEN c.canonical_domain = email_domain(b.email) THEN now() END
FROM billing_customers b
JOIN customers c
  ON c.canonical_domain = email_domain(b.email) OR name_key(c.canonical_name) = name_key(b.name)
WHERE NOT EXISTS (SELECT 1 FROM customer_source_ids l WHERE l.source = 'billing' AND l.source_id = b.id)
UNION ALL
SELECT c.customer_id, 'support', s.id,
       CASE WHEN c.canonical_domain = lower(s.domain) THEN 'domain' ELSE 'name' END,
       CASE WHEN c.canonical_domain = lower(s.domain) THEN 'confirmed' ELSE 'proposed' END,
       CASE WHEN c.canonical_domain = lower(s.domain) THEN 'rule:domain' END,
       CASE WHEN c.canonical_domain = lower(s.domain) THEN now() END
FROM support_orgs s
JOIN customers c
  ON c.canonical_domain = lower(s.domain) OR name_key(c.canonical_name) = name_key(s.name)
WHERE NOT EXISTS (SELECT 1 FROM customer_source_ids l WHERE l.source = 'support' AND l.source_id = s.id)
UNION ALL
SELECT c.customer_id, 'crm', d.id, 'name', 'proposed', NULL, NULL     -- possible CRM duplicates
FROM crm_companies d
JOIN customers c ON name_key(c.canonical_name) = name_key(d.name)
WHERE NOT EXISTS (SELECT 1 FROM customer_source_ids l WHERE l.source = 'crm' AND l.source_id = d.id);

The review queue:

-- The review queue: matches a person needs to confirm or reject
SELECT l.customer_id, c.canonical_name, c.canonical_domain,
       l.source, l.source_id, l.match_method,
       coalesce(b.name, s.name, d.name)    AS name_in_source,
       coalesce(b.email, s.domain, d.domain) AS contact_in_source
FROM customer_source_ids l
JOIN customers c USING (customer_id)
LEFT JOIN billing_customers b ON l.source = 'billing' AND b.id = l.source_id
LEFT JOIN support_orgs s      ON l.source = 'support' AND s.id = l.source_id
LEFT JOIN crm_companies d     ON l.source = 'crm'     AND d.id = l.source_id
WHERE l.status = 'proposed'
ORDER BY l.customer_id, l.source;

A person confirms what they've checked (to reject, set status = 'rejected' instead):

-- A person reviews the queue and decides
UPDATE customer_source_ids
SET status = 'confirmed', confirmed_by = 'Tim', confirmed_at = now()
WHERE (source, source_id) IN (('support', 'zd_10'), ('billing', 'cus_A5'), ('crm', 'hs_103'));

And a check for anything without a confirmed customer:

-- Source records with no confirmed customer yet: nothing should hide here for long
SELECT 'billing' AS source, b.id, b.name FROM billing_customers b
WHERE NOT EXISTS (SELECT 1 FROM customer_source_ids l
                  WHERE l.source = 'billing' AND l.source_id = b.id AND l.status = 'confirmed')
UNION ALL
SELECT 'support', s.id, s.name FROM support_orgs s
WHERE NOT EXISTS (SELECT 1 FROM customer_source_ids l
                  WHERE l.source = 'support' AND l.source_id = s.id AND l.status = 'confirmed')
UNION ALL
SELECT 'crm', d.id, d.name FROM crm_companies d
WHERE NOT EXISTS (SELECT 1 FROM customer_source_ids l
                  WHERE l.source = 'crm' AND l.source_id = d.id AND l.status = 'confirmed');

After the three confirmations, the check returns no rows.

Step 2: Define the terms

This is the step teams skip, and the reason two people run "the same report" and get different numbers. Write the definitions down, agree on them, and put them in the query.

TermDefinition used hereQuestions to settle with your team
Approaching renewalRenewal date in the next 90 daysIs the CRM renewal date kept up to date? What about monthly contracts with no end date?
Overdue invoiceStatus is open and the due date has passedIs there a grace period? Do credit notes count? What about disputed invoices?
Open ticketStatus is open or pendingDoes "pending" (waiting on the customer) count? Should old tickets weigh more?

None of these answers is universal. A 90-day window suits annual contracts; for monthly customers, 30 days might be better. What matters is that everyone uses the same definition.

Step 3: Ask the question

Once customers are linked and terms are defined, the question itself is short. In plain terms:

 For every customer (customer_id):
   renewal is within 90 days
   AND it has at least one overdue invoice
   AND it has at least one open ticket
 Return: the account, the owner, and the IDs of the invoices and tickets.

On the sample data, once the three matches in step 1 are confirmed, that returns Atlas & Rowe and Northwind Analytics. It's just as useful to see who didn't make the list, and why:

  • Kestrel Logistics has an overdue invoice, but renews in five months. That's a payment reminder, not a renewal alarm.
  • Fjord Studio renews in 88 days and has an open ticket, but its invoices are paid.
  • Mosaic Retail has an open ticket and an unpaid invoice, but the invoice isn't due yet and the renewal is six months away.

That's the point of combining conditions: each one alone is noise; together, they're a signal.

Show the SQL for step 3
WITH params AS (
  SELECT DATE '2026-10-15' AS today         -- use CURRENT_DATE in production
),
links AS (                                  -- only confirmed identity counts
  SELECT customer_id, source, source_id
  FROM customer_source_ids
  WHERE status = 'confirmed'
),
renewal AS (                                -- definition: renewal date from the CRM contract record
  SELECT l.customer_id, min(c.renewal_date) AS renewal_date, min(c.owner) AS owner
  FROM links l
  JOIN crm_companies c ON l.source = 'crm' AND c.id = l.source_id
  WHERE c.renewal_date IS NOT NULL
  GROUP BY l.customer_id
),
overdue AS (                                -- definition: unpaid and past its due date
  SELECT l.customer_id,
         sum(i.amount_eur)          AS overdue_eur,
         max(p.today - i.due_date)  AS days_overdue,
         string_agg(i.id, ', ')     AS invoices
  FROM links l
  JOIN billing_invoices i ON l.source = 'billing' AND i.customer_id = l.source_id
  CROSS JOIN params p
  WHERE i.status = 'open' AND i.due_date < p.today
  GROUP BY l.customer_id
),
open_tickets AS (                           -- definition: waiting on us or on the customer
  SELECT l.customer_id,
         count(*)                    AS open_tickets,
         max(p.today - t.created_at) AS oldest_days,
         string_agg(t.id, ', ')      AS tickets
  FROM links l
  JOIN support_tickets t ON l.source = 'support' AND t.org_id = l.source_id
  CROSS JOIN params p
  WHERE t.status IN ('open', 'pending')
  GROUP BY l.customer_id
)
SELECT cu.customer_id,
       cu.canonical_name             AS customer,
       r.owner,
       r.renewal_date - p.today      AS renews_in_days,
       o.overdue_eur,
       o.days_overdue,
       t.open_tickets,
       t.oldest_days                 AS oldest_ticket_days,
       o.invoices                    AS evidence_invoices,
       t.tickets                     AS evidence_tickets
FROM customers cu
CROSS JOIN params p
JOIN renewal r      ON r.customer_id = cu.customer_id
JOIN overdue o      ON o.customer_id = cu.customer_id
JOIN open_tickets t ON t.customer_id = cu.customer_id
WHERE r.renewal_date BETWEEN p.today AND p.today + 90   -- definition: renewal window
ORDER BY renews_in_days;

The date is fixed so you get the same result as this use case. In production, use CURRENT_DATE.

Step 4: Show the evidence, not just a score

The most important part of the result is the evidence column. A list that says "Atlas & Rowe: high risk" invites the reply "says who?". A list that says "INV-2042, €3,000, 23 days overdue, and ticket T-4180 about SSO login, open for a week" invites a phone call.

In practice:

Link every ID back to its source. INV-2042 should open the invoice in billing; T-4180 the ticket in support.

Send it to the person who owns the account, not to a shared channel nobody reads.

Resist turning it into a single number. A score hides the reason, and the reason is what the account owner needs to act.

A team that trusts the list will act on it. A team that doesn't will quietly go back to opening five tabs.

What breaks in production

The query runs in milliseconds. Keeping it right for a year is the real work:

Syncs fail silently. A token expires, a job stops, and the list looks reassuringly short. Add a check that warns you when data hasn't refreshed in 24 hours.

Fields change. Someone renames "Renewal date" in the CRM, or a new billing plan stores amounts differently. Nothing errors; the answers just become wrong.

The review queue fills up. Every new record in billing or support lands in step 1's queue or unlinked list. If nobody works it, new customers quietly fall out of every answer.

Definitions drift. Finance starts excluding disputed invoices; CS doesn't. Two numbers again.

The person who built it leaves. The most common way internal builds die. If it isn't documented and running on infrastructure the company controls, it leaves with them.

Building it is a project. Maintaining it is a responsibility. Decide upfront who owns the second part.

Three ways to implement it

1. Do it yourself. If someone on your team is comfortable with SQL and your data is already synced into a database, this use case gets you most of the way in a few days, most of them spent on step 1. With fewer than about 50 customers, a well-kept spreadsheet with a customer ID column can honestly be enough.

2. Use your CRM as the central layer. Many teams sync billing and support into HubSpot or Salesforce and treat the CRM as the system of record. That works well when the integrations exist and sync cleanly. The matching and definitions don't disappear, though; they move into the CRM.

3. A separate reporting layer, maintained by someone. A small database holding the linked customer records, the definitions and the checks, with someone responsible for keeping it right. The most robust option, and the one that needs the most ongoing care. It's what we build and maintain at Seamline, but plenty of teams run it in-house.

There's no universal answer. It depends on how many tools you have, how clean they are, and who has time to own it.

Appendix: sample data to run this yourself

Run this first in an empty Postgres database (a free Supabase project works), then the SQL from steps 1 and 3 in order. Tested on Postgres 16.

-- CRM (HubSpot-style)
CREATE TABLE crm_companies (
  id text PRIMARY KEY, name text, domain text, renewal_date date, owner text);
INSERT INTO crm_companies VALUES
 ('hs_101','Northwind Analytics','northwind.io','2026-12-18','Sanne'),
 ('hs_102','Atlas & Rowe','atlasrowe.com','2026-11-25','Daan'),
 ('hs_103','Atlas and Rowe',NULL,NULL,'Daan'),
 ('hs_104','Kestrel Logistics','kestrel-logistics.eu','2027-03-14','Sanne'),
 ('hs_105','Fjord Studio','fjordstudio.no','2027-01-11','Daan'),
 ('hs_106','Mosaic Retail','mosaicretail.eu','2027-04-13','Sanne');

-- Billing (Stripe/Chargebee-style)
CREATE TABLE billing_customers (id text PRIMARY KEY, name text, email text);
INSERT INTO billing_customers VALUES
 ('cus_A1','NORTHWIND ANALYTICS B.V.','finance@northwind.io'),
 ('cus_A2','ATLAS & ROWE BV','ap@atlasrowe.com'),
 ('cus_A3','Kestrel Logistics Ltd','billing@kestrel-logistics.eu'),
 ('cus_A4','Fjord Studio AS','post@fjordstudio.no'),
 ('cus_A5','Mosaic Retail','jan@gmail.com');

CREATE TABLE billing_invoices (id text PRIMARY KEY, customer_id text, amount_eur numeric, due_date date, status text);
INSERT INTO billing_invoices VALUES
 ('INV-2041','cus_A1',1950,'2026-09-28','open'),
 ('INV-2042','cus_A2',3000,'2026-09-22','open'),
 ('INV-2043','cus_A3',1200,'2026-10-06','open'),
 ('INV-2044','cus_A4',800,'2026-10-01','paid'),
 ('INV-2045','cus_A5',750,'2026-10-20','open');

-- Support (Zendesk/Freshdesk-style)
CREATE TABLE support_orgs (id text PRIMARY KEY, name text, domain text);
INSERT INTO support_orgs VALUES
 ('zd_9','northwind.io','northwind.io'),
 ('zd_10','Atlas & Rowe',NULL),
 ('zd_11','Fjord Studio','fjordstudio.no'),
 ('zd_12','Mosaic Retail','mosaicretail.eu');

CREATE TABLE support_tickets (id text PRIMARY KEY, org_id text, status text, created_at date, subject text);
INSERT INTO support_tickets VALUES
 ('T-4170','zd_9','open','2026-09-30','Export keeps timing out'),
 ('T-4171','zd_9','pending','2026-10-03','Export keeps timing out (again)'),
 ('T-4172','zd_9','solved','2026-08-12','Invite new user'),
 ('T-4180','zd_10','open','2026-10-08','SSO login fails'),
 ('T-4181','zd_11','open','2026-10-02','Downgrade question'),
 ('T-4182','zd_12','open','2026-10-09','CSV import mapping');

Want this done for your data? See how we work → /services/crm-data-cleansing

Want to see what this looks like on your own data?

Pick one question your team currently answers by opening three or four different systems. We'll map where the data lives, how the records connect, and what it would take to answer it reliably.

Book a revenue data diagnostic →