From 1286777438e613877eb2679ae7a6609afb825c3b Mon Sep 17 00:00:00 2001 From: Dan Gericke Date: Thu, 14 May 2026 12:32:26 +0800 Subject: [PATCH 01/10] INSTALL: remove PropelAuth mentions, add Claude.ai connector section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customers never experience PropelAuth as a brand — they see a ShiftControl sign-in. Rewriting the intro paragraph and the Claude Desktop first-use note to match what the user actually sees. Also adds a new Claude.ai (web + mobile) section using the Customize → Connectors flow, between the Claude Desktop and Claude Code sections. Mirrors the section added to shiftcontrol.io/llms-install.txt. --- INSTALL.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 9cc446f..617d0bf 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -2,7 +2,7 @@ ShiftControl uses the [Model Context Protocol (MCP)](https://modelcontextprotocol.io) — an open standard for connecting AI assistants to applications. To use any of the skills in this repo, you first install the **ShiftControl MCP server**, then install the skill(s) in whichever way your AI tool supports. -You'll authenticate to ShiftControl once via PropelAuth (single sign-on, the same login you use for ShiftControl itself). The MCP server uses your account's permissions — anything you can do in ShiftControl, the AI can do on your behalf via this skill. +You'll sign in to ShiftControl once in your browser — the same login you use for ShiftControl itself. After that, your AI assistant works with your actual ShiftControl data using your existing permissions; it can't do anything you can't already do yourself. --- @@ -29,7 +29,19 @@ Add the `shiftcontrol` server inside `mcpServers`: } ``` -Restart Claude Desktop. The first time you use a ShiftControl tool, a browser tab opens for PropelAuth login. Approve once. +Restart Claude Desktop. The first time you use a ShiftControl tool, a browser tab opens for ShiftControl sign-in. Approve once. + +### Claude.ai (web + mobile) + +In Claude on the web ([claude.ai](https://claude.ai)) or in the Claude mobile app: + +1. Open **Customize → Connectors** +2. Click the **+ Add** button and select **Add custom connector** +3. Name: `shiftcontrol` +4. Remote MCP Server URL: `https://mcp.shiftcontrol.io/mcp` +5. Click **Add** + +A browser tab opens for ShiftControl sign-in on first use. Approve once. ### Claude Code From bafb40a28efcefcbda7247f8c1982f5a7840b750 Mon Sep 17 00:00:00 2001 From: Dan Gericke Date: Thu, 14 May 2026 12:36:18 +0800 Subject: [PATCH 02/10] Remove internal 'audit log' terminology across the repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customers don't think in 'audit log' terms — that's internal vocabulary. The practice of appending a short note to the app's 'notes' field on each change stays (it's useful context for whoever looks at the record next), but the framing shifts from 'audit trail' to 'short context note for the next reader.' Concrete changes: - CONTRIBUTING.md: drop the 'Audit trail in notes' rule entirely (Dan's call — it was over-prescribed; the skills naturally do the right thing without elevating it to a hard rule). Remaining rules renumber 1, 2, 3. - README.md: 'audit-logged' / 'audit-friendly' / 'audit notes' → context-note framing focused on the next-human-to-read-this-app. - SKILL.md: 'audit log' → 'change history'; 'audit line' → 'new line'; the notes-line example no longer includes 'via skill vX.Y.Z' — humans reading the record don't care about the skill version. - proposal-format.md: '{audit string}' → '{note string}'; 'audit reviewer' → 'someone reviewing the record later'. - vendor-name-mapping.md: 'auditable' → 'leaves a clear record'. Kept (user-facing verbs, not internal jargon): - README 'audit spend' — user-facing verb. - SKILL.md trigger phrase 'Audit our subscription data' — that's how users actually phrase the request. --- CONTRIBUTING.md | 3 +-- README.md | 6 +++--- skills/refresh-subscription-info/SKILL.md | 10 +++++----- .../references/proposal-format.md | 6 +++--- .../references/vendor-name-mapping.md | 2 +- 5 files changed, 13 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 043fc65..1d649da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,8 +6,7 @@ Thanks for your interest in contributing. This repo holds customer-facing AI ski 1. **Skills propose, humans approve.** Every skill that performs a write MUST present a concrete diff (before → after) and require explicit user approval before calling the MCP server. Never auto-confirm. 2. **No invented identifiers.** Skills MUST source every UUID from a prior `list_*` or `get_*` tool call. Guessing or constructing UUIDs is rejected at review. -3. **Audit trail in notes.** Any write that doesn't already produce an audit-log entry on the backend MUST append a `notes` value identifying the skill, version, and source data (e.g. `"Updated via refresh-subscription-info v0.1.0 from invoice dated 2026-03-15"`). -4. **Stay within the documented MCP tool surface.** If you need a new tool, file an MCP server change first; don't work around the API. +3. **Stay within the documented MCP tool surface.** If you need a new tool, file an MCP server change first; don't work around the API. ## Commit signing — required diff --git a/README.md b/README.md index 68148fe..328fdda 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## What you can do today -- **Stop chasing invoices through your inbox.** Ask your AI to *"refresh my subscription info from my recent invoices"* and it'll search your email, match each invoice to an app in ShiftControl, show you exactly what would change (e.g. Slack $8/user/mo → $7/user/mo, contract through 2027-03-15), and update only what you approve. Every change is audit-logged. +- **Stop chasing invoices through your inbox.** Ask your AI to *"refresh my subscription info from my recent invoices"* and it'll search your email, match each invoice to an app in ShiftControl, show you exactly what would change (e.g. Slack $8/user/mo → $7/user/mo, contract through 2027-03-15), and update only what you approve. Each change records a short note in the app explaining where the new values came from. - **Use the AI tool you already use.** Claude Desktop, Claude Code, Cursor, Windsurf, Cline, Continue.dev, ChatGPT, Gemini — see [INSTALL.md](INSTALL.md) for your tool. - **Sign in once.** Connecting takes a single OAuth click through your normal ShiftControl login. Your AI gets exactly the permissions you already have — nothing more. @@ -18,7 +18,7 @@ | Skill | What it does | Status | |---|---|---| -| [`refresh-subscription-info`](skills/refresh-subscription-info/) | Finds recent SaaS invoices in the user's email and proposes ShiftControl subscription updates (cost, billing frequency, contract terms, audit notes). | v0.1.0 | +| [`refresh-subscription-info`](skills/refresh-subscription-info/) | Finds recent SaaS invoices in the user's email and proposes ShiftControl subscription updates (cost, billing frequency, contract terms, notes). | v0.1.0 | More coming. See [open skill proposals](https://github.com/ShiftControl-io/skills/issues?q=label%3Askill-request). @@ -30,7 +30,7 @@ Every skill in this repo follows three rules: - **Assumes the ShiftControl MCP server is installed and authenticated** — see INSTALL.md - **Proposes changes for human approval before any write** — no silent mutations -- **Logs an audit-friendly `notes` value on every change** — the change is traceable back to the skill, version, and source data +- **Records a short note on each change** — so the next person to look at the app's record can see where the updated values came from ## Contributing diff --git a/skills/refresh-subscription-info/SKILL.md b/skills/refresh-subscription-info/SKILL.md index 15959ea..ad797b0 100644 --- a/skills/refresh-subscription-info/SKILL.md +++ b/skills/refresh-subscription-info/SKILL.md @@ -101,11 +101,11 @@ Found invoices for 10 of your 23 ShiftControl apps. Proposed updates: cost: $8.00/user/month → $7.00/user/month billingFrequency: month → year contractEndDate: (not set) → 2027-03-15 - note will be added: "Updated from Slack invoice dated 2026-03-15 via refresh-subscription-info v0.1.0" + note will be added: "Updated from Slack invoice dated 2026-03-15" 2. Notion cost: $12.00/user/month → $10.00/user/month - note will be added: "Updated from Notion invoice dated 2026-03-08 via refresh-subscription-info v0.1.0" + note will be added: "Updated from Notion invoice dated 2026-03-08" [... more ...] @@ -148,7 +148,7 @@ For each approved (app, changes) pair, call `update_app_subscription` with: - `appId`: the UUID from Step 1. - `confirm: true` — set this only because you just obtained the user's explicit approval. - **Only the fields that actually changed** (omit unchanged ones — they keep their current value on the backend). -- `notes`: append a line in the form `"Updated from invoice dated via refresh-subscription-info v0.1.0"` — read the current notes from Step 1's snapshot and append, don't overwrite. +- `notes`: append a short line in the form `"Updated from invoice dated "` so the next person to look at the record can see where these values came from. Read the current notes from Step 1's snapshot and append; don't overwrite. Process each app **sequentially** (not parallel) so errors are clearly attributable. After all writes, report back: @@ -159,7 +159,7 @@ Applied 8 of 8 approved changes: ✓ Figma — updated [...] -You can review the full audit log in ShiftControl → Apps → → History. +You can review the full change history in ShiftControl → Apps → → History. ``` If any write fails, report which one and why, but keep going with the rest. **Don't roll back successful writes** — incremental progress is more valuable than atomicity here. @@ -169,7 +169,7 @@ If any write fails, report which one and why, but keep going with the rest. **Do - ❌ Calling `update_app_subscription` with `confirm: true` because "the user is asking for updates". They're asking for a **proposal**, not blanket approval. Always present the diff first. - ❌ Constructing an `appId` from a name. The UUID must come from `list_apps`. - ❌ Inferring `costStructure` when the invoice is ambiguous. If you can't tell whether it's per-seat or flat, leave that field out of the proposal and let the user decide. -- ❌ Overwriting `notes` instead of appending. Read current notes; append the audit line. +- ❌ Overwriting `notes` instead of appending. Read current notes; append the new line. - ❌ Proposing changes for apps where no invoice was found, based on "you probably renewed at the same rate". This skill is **invoice-driven**: no invoice → no change. - ❌ Creating new apps. If an invoice doesn't match a tracked app, surface it as "not tracked" and stop there. diff --git a/skills/refresh-subscription-info/references/proposal-format.md b/skills/refresh-subscription-info/references/proposal-format.md index d03de1a..20de7f2 100644 --- a/skills/refresh-subscription-info/references/proposal-format.md +++ b/skills/refresh-subscription-info/references/proposal-format.md @@ -24,7 +24,7 @@ or "show details for N" to see the full invoice context for change N. {field}: {current value} → {proposed value} {field}: {current value} → {proposed value} ... - note will be added: "{audit string}" + note will be added: "{note string}" ``` Rules: @@ -34,7 +34,7 @@ Rules: - Show `(not set)` for currently-null fields rather than blanks. - Format costs with **currency symbol AND unit**: `$8.00/user/month`, `$300.00/year flat`, `€50.00/user/year`. - Format dates as ISO 8601 date only: `2026-03-15`. -- Include the `note will be added:` line on every block — transparency about the audit trail is non-negotiable. +- Include the `note will be added:` line on every block — showing the user that the change will also write a short context note to the app is part of "no surprises". ## No-change blocks @@ -85,6 +85,6 @@ Three audiences read it: 1. **The user** — wants to scan quickly, see what's changing, decide. 2. **The agent** — wants unambiguous item numbers to reference in approval messages. -3. **The audit reviewer (later)** — wants to reconstruct what was proposed vs what was approved. +3. **Someone reviewing the record later** — wants to reconstruct what was proposed vs what was approved. The format serves all three. Don't deviate without good reason. diff --git a/skills/refresh-subscription-info/references/vendor-name-mapping.md b/skills/refresh-subscription-info/references/vendor-name-mapping.md index f7ae685..25e85c7 100644 --- a/skills/refresh-subscription-info/references/vendor-name-mapping.md +++ b/skills/refresh-subscription-info/references/vendor-name-mapping.md @@ -91,4 +91,4 @@ Should I apply each line item to its respective app? (yes / no / let me decide e ## Why not LLM-judge every match -LLM-based matching is appealing but flaky and non-deterministic. The deterministic rules above produce a smaller, higher-quality match set. If the rules don't match, the right move is **"ask the user"**, not "have the LLM guess harder". Asking is auditable; guessing isn't. +LLM-based matching is appealing but flaky and non-deterministic. The deterministic rules above produce a smaller, higher-quality match set. If the rules don't match, the right move is **"ask the user"**, not "have the LLM guess harder". Asking leaves a clear record of why each match was made; guessing doesn't. From cb2925c67ccf83f9d3244c8793e54deaabd74a89 Mon Sep 17 00:00:00 2001 From: Dan Gericke Date: Thu, 14 May 2026 12:37:58 +0800 Subject: [PATCH 03/10] =?UTF-8?q?refresh-subscription-info:=20default=20se?= =?UTF-8?q?arch=20window=2090=20days=20=E2=86=92=2018=20months?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annual contracts dominate SaaS pricing. A 90-day default window risks missing the most recent renewal invoice for any app billed annually — i.e. half the user's apps, in practice. 18 months covers every cadence (monthly / quarterly / annual) plus a buffer to confirm you have the latest invoice. If the user wants a narrower window ('just this month', 'last quarter'), the skill honors that. Wider than 18 months stays rare and explicit, since old invoices are usually superseded. Updates: - SKILL.md Step 2: 90 days → 18 months, with rationale. - SKILL.md sample proposal: 'no invoice in the last 90 days' → '... 18 months'. - references/invoice-detection.md: rewritten Time-Window section reflecting the new default and dropping the '400 days for annual contracts' clause (now subsumed by the 18-month default). --- skills/refresh-subscription-info/SKILL.md | 4 ++-- .../references/invoice-detection.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/skills/refresh-subscription-info/SKILL.md b/skills/refresh-subscription-info/SKILL.md index ad797b0..fe6aed4 100644 --- a/skills/refresh-subscription-info/SKILL.md +++ b/skills/refresh-subscription-info/SKILL.md @@ -47,7 +47,7 @@ This is your authoritative "what's currently recorded" baseline. ### Step 2 — Define the search window -By default, search the user's email for invoices received in the last **90 days**. If the user asks for a wider or narrower window, honor it. +By default, search the user's email for invoices received in the last **18 months**. Annual contracts are common in SaaS, and a one-year window risks missing the most recent renewal invoice for any app that bills annually — 18 months gives you the current annual invoice plus a buffer to confirm you have the latest one. If the user asks for a narrower window ("just the last quarter", "this month only"), honor it. ### Step 3 — Search the email inbox @@ -109,7 +109,7 @@ Found invoices for 10 of your 23 ShiftControl apps. Proposed updates: [... more ...] -Apps with no invoice in the last 90 days (13): [list] +Apps with no invoice in the last 18 months (13): [list] Invoices found for apps not in ShiftControl (2): [list of vendor names] Reply "approve N" (e.g. "approve 1, 3, 5") to apply specific changes, diff --git a/skills/refresh-subscription-info/references/invoice-detection.md b/skills/refresh-subscription-info/references/invoice-detection.md index 73f8dfe..99efcc5 100644 --- a/skills/refresh-subscription-info/references/invoice-detection.md +++ b/skills/refresh-subscription-info/references/invoice-detection.md @@ -29,9 +29,9 @@ Refine when too noisy by adding excluded senders the user has explicitly flagged ## Time window guidance -- **Default: 90 days** — typically covers monthly billing cycles plus quarterly cadence. -- **For annual-only contracts:** extend to **400 days** if the user asks for a comprehensive sweep. -- Don't pull older than **18 months** unless the user explicitly requests it — old invoices are usually superseded. +- **Default: 18 months** — covers every billing cadence (monthly, quarterly, annual) plus enough buffer to catch the most recent annual renewal invoice. Annual contracts dominate in SaaS; a window shorter than 12 months risks missing the latest invoice for half the user's apps. +- **Narrower windows when the user asks:** "this month only", "last quarter", "since the last review" — honor whatever the user specifies. A monthly review of just the most recent month is a legitimate workflow. +- **Wider than 18 months:** rare. Old invoices are usually superseded by more recent ones, and pulling beyond 18 months risks proposing changes based on stale pricing. Only extend if the user explicitly asks (e.g. "go back two years to find the original contract"). ## What to do when the inbox is huge From a3dba2af3a5ecc53cfe68dbd9a4c6afc2c61cb04 Mon Sep 17 00:00:00 2001 From: Dan Gericke Date: Thu, 14 May 2026 12:40:46 +0800 Subject: [PATCH 04/10] Step 4: classify invoices as full vs incremental before extracting cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mid-period invoices (seat additions, plan upgrades, prorated adjustments) are partial charges. Computing per-user cost from them produces silently- wrong numbers. The skill now classifies invoice type first, then extracts only the fields that are reliable for that type. SKILL.md Step 4 changes: - New 'Classify the invoice type first' subsection at the top - Incremental signals listed inline (subject/body text, amount, service period, position relative to a renewal invoice) - Per-unit cost and billing frequency are explicitly marked 'Full only' - Plan/tier and contract end date marked as STILL usable from incrementals (they are often the BEST source for plan changes specifically) - 'Only incrementals for this vendor' is now its own surfacing path invoice-detection.md adds: - Full vs incremental classification section with both sides' tells - Field-by-field reliability matrix - Multi-invoice combining rules (full + incrementals → use each for its strongest signal; only-incrementals → skip cost, propose plan + dates) - Annual-vs-monthly cost normalization note (separate but adjacent gotcha where an annual invoice quotes $84/user but storage is $7 + 'year') --- skills/refresh-subscription-info/SKILL.md | 34 +++++++--- .../references/invoice-detection.md | 67 ++++++++++++++++++- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/skills/refresh-subscription-info/SKILL.md b/skills/refresh-subscription-info/SKILL.md index fe6aed4..c3206eb 100644 --- a/skills/refresh-subscription-info/SKILL.md +++ b/skills/refresh-subscription-info/SKILL.md @@ -58,22 +58,38 @@ Use the email-search MCP to find candidate invoice emails. See [references/invoi - Filter OUT emails where the user is the **sender** (those are outbound invoices to their own customers). - Filter OUT clearly non-SaaS receipts (Amazon shopping, ride-share, restaurants, hardware). -### Step 4 — Extract structured data from each candidate +### Step 4 — Classify, then extract structured data -For each candidate invoice, read the body and extract: +#### First — classify the invoice type + +Not every invoice reflects the standard subscription cost. Two shapes show up: + +- **Full / renewal invoice** — the standard charge for the whole billing period (monthly, quarterly, annual). Source of truth for per-unit cost, billing frequency, and total seats. +- **Incremental / prorated invoice** — a mid-period adjustment for seat additions, plan upgrades, downgrades, or vendor-side price changes. The amount is partial; computing per-unit cost from it produces wrong values. + +**Incremental tells** (any of these): +- Subject or body mentions "prorated", "pro-rated", "seat update", "plan change", "adjustment", "true-up", "for the remainder of", "credit memo", "mid-cycle" +- Amount is much smaller than the vendor's typical invoice in the search window +- Service period is shorter than the billing cycle (e.g. "Mar 15 – Mar 31" inside a monthly subscription) +- The invoice falls inside a billing period that already had a renewal invoice from the same vendor + +For **full invoices**, every field below is fair game. For **incrementals**, treat them as confirming evidence — **skip per-unit cost and billing frequency** (the math is unreliable), but contract end date, plan/tier, and "new total seats after the change" are still usable. Incrementals are often the *best* source for "they upgraded to Business plan on date X" or "renewal date is now 2027-03-15". See [references/invoice-detection.md](references/invoice-detection.md) for the full reliability matrix and the multi-invoice combining rules (e.g. when both a full invoice and incrementals exist for the same vendor in the search window). + +#### Then — extract these fields from each candidate - **Vendor** — the SaaS company billing for the service (From-address domain or body header). +- **Invoice type** — `full` or `incremental` (per the classification above). - **Invoice date** — when the invoice was issued. - **Service period** — what the charge covers (e.g. "Jan 1 – Jan 31, 2026"). -- **Per-unit cost** — per-user or per-seat cost, as a decimal string (e.g. `"5.00"`). -- **Cost structure** — `user` (per-seat), `flat` (fixed), or `tiered`. +- **Per-unit cost** — per-user or per-seat cost, as a decimal string (e.g. `"5.00"`). **Full invoices only** — skip on incrementals. +- **Cost structure** — `user` (per-seat), `flat` (fixed), or `tiered`. Skip if ambiguous on an incremental. - **Currency** — ISO 4217 code (USD, EUR, etc.). -- **Billing frequency** — `month`, `quarter`, or `year`, inferred from the service period. -- **Total amount + seats** — useful for cross-checking per-unit cost. -- **Contract renewal/end date** — if mentioned ("renews on…", "auto-renews", "contract through…"). -- **Plan/tier** — the plan name on the invoice ("Pro", "Business", "Enterprise"). +- **Billing frequency** — `month`, `quarter`, or `year`, inferred from the service period. **Full invoices only** — the service period on an incremental is partial. +- **Total seats after the change** — useful for both types when stated ("Your subscription now includes 50 seats"). +- **Contract renewal/end date** — if mentioned ("renews on…", "auto-renews", "contract through…"). Incrementals often confirm or update this. +- **Plan/tier** — the plan name on the invoice ("Pro", "Business", "Enterprise"). If a recent incremental announces a plan change, trust it over an older full invoice. -If a candidate's vendor is unclear, has no extractable cost, or otherwise doesn't yield enough structured data, mark it **uncertain** and exclude it from the proposal. Surface it to the user separately: "I saw this invoice but couldn't extract enough to propose an update." +If a candidate has an unclear vendor, no reliable extractable data, or is only an incremental for a vendor with no full invoice in the window, mark it **uncertain** and exclude that vendor's cost fields from the proposal. Surface it to the user separately: "I only found mid-period adjustment invoices for — I can update the plan tier and contract date, but the per-user cost is too noisy to propose." ### Step 5 — Match invoices to ShiftControl apps diff --git a/skills/refresh-subscription-info/references/invoice-detection.md b/skills/refresh-subscription-info/references/invoice-detection.md index 99efcc5..1319f25 100644 --- a/skills/refresh-subscription-info/references/invoice-detection.md +++ b/skills/refresh-subscription-info/references/invoice-detection.md @@ -41,7 +41,72 @@ Most real inboxes are noisy. To keep within reasonable LLM context: - Use the email-search MCP's filter capabilities before reading bodies. - Read only the first ~1000 characters of each candidate; pull the full body only if structured fields aren't already extracted. -## Failure modes worth flagging +## Full vs incremental invoices + +A single vendor often produces multiple invoices in an 18-month window: + +- A **full / renewal invoice** at the start of each billing period — the actual subscription charge for the upcoming period. +- One or more **incremental / prorated invoices** for mid-period changes — seat additions, plan upgrades, downgrades-with-credit, vendor-side price adjustments. + +The two types carry very different reliability for the fields this skill cares about. Misclassifying a prorated charge as a full charge produces silently-wrong per-user costs in ShiftControl. + +### Classification signals + +**Incremental invoice tells**: + +- Subject line contains: "Seat update", "Plan change", "Adjusted billing", "Prorated charge", "Account update", "True-up", "Mid-cycle adjustment", "Credit memo", "Upgrade confirmation" +- Body text contains: "prorated", "pro-rated", "for the remainder of", "partial billing period", "partial month", "adjustment", "true-up", "added X seats", "upgraded to", "downgraded from" +- Amount is significantly smaller than the vendor's typical invoice in the search window +- Service period is shorter than the billing cycle (e.g. "Mar 15 – Mar 31" inside a monthly subscription, or "Mar – Aug" inside an annual one) +- The invoice falls inside a billing period that already had a renewal invoice from the same vendor + +**Full / renewal invoice tells**: + +- Subject: "Invoice", "Subscription renewed", "Receipt for your payment", "Auto-renewal", "Annual subscription" +- Body shows a service period equal to the full billing cycle (full month / full quarter / full year) +- Amount aligns with `per-unit cost × seats × billing-frequency` +- No "prorated" / "adjustment" / "credit" language + +### Field-extraction reliability matrix + +| Field | Full invoice | Incremental | +|---|---|---| +| Vendor | ✅ reliable | ✅ reliable | +| Invoice date | ✅ reliable | ✅ reliable | +| Per-unit cost | ✅ reliable | ❌ **DO NOT use** — prorated math will be wrong | +| Cost structure (user / flat / tiered) | ✅ reliable | ⚠️ OK if explicitly stated | +| Currency | ✅ reliable | ✅ reliable | +| Billing frequency | ✅ derivable from service period | ❌ service period is partial | +| Total seats (after this invoice) | ✅ reliable | ✅ reliable when stated ("Your subscription now includes N seats") | +| Contract end date | ✅ reliable when stated | ✅ reliable when stated — incrementals often confirm renewal date | +| Plan / tier | ✅ reliable | ✅ **best source** — plan changes are exactly what incrementals announce | +| Net seat change (+3, -5) | — not on full invoices | ✅ uniquely available on incrementals | + +### Combining multiple invoices from the same vendor + +If you find both a full invoice AND incrementals for the same vendor in the search window: + +1. Use the **full invoice** as the source for per-unit cost, billing frequency, currency, cost structure. +2. Use the **most recent incremental** for current seat count (more up-to-date than the full invoice). +3. Use either type for contract end date — both reliably state it when present; prefer the most recent mention. +4. If they disagree on plan tier (full says "Pro", a later incremental says "upgraded to Business"), trust the most recent incremental. + +If you find **only incrementals** for a vendor in the search window, treat it as low-confidence: + +- Skip the per-unit cost field in the proposal for that vendor. +- Still propose plan tier and contract end date updates if you found them. +- Surface a note in the proposal: *"Only mid-period adjustment invoices for in the window — I can update plan tier and contract date, but the per-user cost is too noisy to propose."* + +### Annual-vs-monthly cost normalization + +A separate but adjacent gotcha: full invoices can be quoted at different cadences. An annual invoice may show `$84/user/year`, which is `$7/user/month billed annually`. The skill normalizes: + +- Invoice says `$84/user` over a 1-year service period → store `cost = "7.00"`, `billingFrequency = "year"`, proposal shows it as `"$7.00/user/month billed annually"`. +- Invoice says `$8/user` over a 1-month service period → store `cost = "8.00"`, `billingFrequency = "month"`. + +ShiftControl stores per-unit cost AND billing frequency separately, so the normalization always preserves both pieces. Don't multiply / divide cost without also updating billingFrequency. + +## Other failure modes worth flagging - **Vendor consolidator** — emails from Stripe billing the user on behalf of MANY upstream SaaS vendors. The "From" is Stripe but the actual vendor is in the body. Look for "On behalf of" or "Pay ". - **Reseller invoicing** — organizations that buy SaaS through a reseller (CDW, Insight, Carahsoft, etc.). Vendor on the invoice is the reseller; the actual SaaS product is in line items. From 21c9790935689d7a1ec699d41e5432abb02b8622 Mon Sep 17 00:00:00 2001 From: Dan Gericke Date: Thu, 14 May 2026 12:47:45 +0800 Subject: [PATCH 05/10] Notes replace-not-append, reseller annotation, PDF + web-hosted invoice handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five changes Dan flagged: 1. Notes are REPLACED, not appended. The skill runs repeatedly; one 'Updated from X dated Y' line per app at all times. Match pattern is 'line starts with "Updated from " and contains "invoice dated "'; user-written content in the notes is preserved. 2. Reseller annotation in the note string. Slack-via-Salesforce and Google-Workspace-via-ShiftControl are the canonical examples — note becomes 'Updated from Slack invoice dated 2026-03-15 (billed via Salesforce)' so the next reader understands why the From-address didn't match the product. 3. Slack-via-Salesforce (and Heroku-via-Salesforce, future Figma-via-Adobe) added to vendor-name-mapping.md as 'acquired-product domain rebinds'. Body content is authoritative; From-domain can mislead. 4. PDF invoices now PARSED (not skipped). Most email MCPs return attachment contents; most AI assistants can read PDFs natively. Removed PDF from the 'v0.1.0 doesn't do' list. Brief mention in SKILL.md Step 4; details in invoice-detection.md. 5. Web-hosted invoices: skill ASKS the user before following 'click here to view your invoice' links. Suggested phrasing is in the doc. If the user agrees AND the assistant has a fetch capability, fetch + parse. Removed from 'v0.1.0 doesn't do' list. Display change in the proposal block: 'note will be added:' → 'notes update:'. The new phrasing is more honest about replace-not-append semantics and is intentional per proposal-format.md. Reseller line-item matching gets its own subsection in vendor-name-mapping.md (rule 4a) — one reseller invoice with Google Workspace + JumpCloud line items becomes two separate ShiftControl updates. --- skills/refresh-subscription-info/SKILL.md | 13 +++-- .../references/invoice-detection.md | 47 +++++++++++++++---- .../references/proposal-format.md | 5 +- .../references/vendor-name-mapping.md | 19 ++++++++ 4 files changed, 69 insertions(+), 15 deletions(-) diff --git a/skills/refresh-subscription-info/SKILL.md b/skills/refresh-subscription-info/SKILL.md index c3206eb..b821ea3 100644 --- a/skills/refresh-subscription-info/SKILL.md +++ b/skills/refresh-subscription-info/SKILL.md @@ -89,6 +89,10 @@ For **full invoices**, every field below is fair game. For **incrementals**, tre - **Contract renewal/end date** — if mentioned ("renews on…", "auto-renews", "contract through…"). Incrementals often confirm or update this. - **Plan/tier** — the plan name on the invoice ("Pro", "Business", "Enterprise"). If a recent incremental announces a plan change, trust it over an older full invoice. +**PDF attachments:** if the email body says "Your invoice is attached" but lacks the cost details, the data is in the PDF. Most email MCPs return attachment contents — fetch and parse the PDF the same way as an inline-bodied invoice. If your email MCP can't return attachments, mark the invoice uncertain. + +**Web-hosted invoices:** if the invoice details are behind a "Click here to view your invoice" link rather than in the body or an attachment, **ask the user** before following the link: *"I found invoices from where the details are behind a 'view invoice' link — should I follow those links to extract the cost details?"* If the user agrees AND your assistant can fetch URLs, fetch and parse. Otherwise mark uncertain. + If a candidate has an unclear vendor, no reliable extractable data, or is only an incremental for a vendor with no full invoice in the window, mark it **uncertain** and exclude that vendor's cost fields from the proposal. Surface it to the user separately: "I only found mid-period adjustment invoices for — I can update the plan tier and contract date, but the per-user cost is too noisy to propose." ### Step 5 — Match invoices to ShiftControl apps @@ -117,11 +121,11 @@ Found invoices for 10 of your 23 ShiftControl apps. Proposed updates: cost: $8.00/user/month → $7.00/user/month billingFrequency: month → year contractEndDate: (not set) → 2027-03-15 - note will be added: "Updated from Slack invoice dated 2026-03-15" + notes update: "Updated from Slack invoice dated 2026-03-15 (billed via Salesforce)" 2. Notion cost: $12.00/user/month → $10.00/user/month - note will be added: "Updated from Notion invoice dated 2026-03-08" + notes update: "Updated from Notion invoice dated 2026-03-08" [... more ...] @@ -164,7 +168,7 @@ For each approved (app, changes) pair, call `update_app_subscription` with: - `appId`: the UUID from Step 1. - `confirm: true` — set this only because you just obtained the user's explicit approval. - **Only the fields that actually changed** (omit unchanged ones — they keep their current value on the backend). -- `notes`: append a short line in the form `"Updated from invoice dated "` so the next person to look at the record can see where these values came from. Read the current notes from Step 1's snapshot and append; don't overwrite. +- `notes`: write a single line in the form `"Updated from invoice dated "`. If the invoice came via a reseller (e.g. Slack billed by Salesforce, Google Workspace billed by ShiftControl, JumpCloud billed by a partner), append `(billed via )` — e.g. `"Updated from Slack invoice dated 2026-03-15 (billed via Salesforce)"`. **Replace** any previous skill-written line of the same form (this skill runs repeatedly; we don't want notes to accumulate one line per run). Match for replacement using the pattern: line begins with `Updated from ` and contains `invoice dated `. **Preserve every other line** the user (or any other source) put in the notes field — only the skill's own previous "Updated from..." line gets replaced. If no such previous line exists, add the new one at the end. Process each app **sequentially** (not parallel) so errors are clearly attributable. After all writes, report back: @@ -185,7 +189,8 @@ If any write fails, report which one and why, but keep going with the rest. **Do - ❌ Calling `update_app_subscription` with `confirm: true` because "the user is asking for updates". They're asking for a **proposal**, not blanket approval. Always present the diff first. - ❌ Constructing an `appId` from a name. The UUID must come from `list_apps`. - ❌ Inferring `costStructure` when the invoice is ambiguous. If you can't tell whether it's per-seat or flat, leave that field out of the proposal and let the user decide. -- ❌ Overwriting `notes` instead of appending. Read current notes; append the new line. +- ❌ Wiping out user-written notes. The skill replaces ONLY its own previous line (matching `Updated from invoice dated ...`). Any other content in the notes — vendor contact, negotiation history, owner email, manual annotations — must be preserved. +- ❌ Accumulating one new note line every time the skill runs. The skill is designed to be re-run regularly; replace the prior skill line, don't pile on. - ❌ Proposing changes for apps where no invoice was found, based on "you probably renewed at the same rate". This skill is **invoice-driven**: no invoice → no change. - ❌ Creating new apps. If an invoice doesn't match a tracked app, surface it as "not tracked" and stop there. diff --git a/skills/refresh-subscription-info/references/invoice-detection.md b/skills/refresh-subscription-info/references/invoice-detection.md index 1319f25..5f91979 100644 --- a/skills/refresh-subscription-info/references/invoice-detection.md +++ b/skills/refresh-subscription-info/references/invoice-detection.md @@ -106,20 +106,49 @@ A separate but adjacent gotcha: full invoices can be quoted at different cadence ShiftControl stores per-unit cost AND billing frequency separately, so the normalization always preserves both pieces. Don't multiply / divide cost without also updating billingFrequency. +## Vendor identity beyond the "From" address + +The vendor billing the user isn't always the vendor named on the invoice. Two big patterns: + +### Vendor renames and acquisitions + +When a SaaS company is acquired, billing often moves to the parent. The product name in ShiftControl stays the same; the invoice arrives from a different domain. + +| Product | Bills as | Notes | +|---|---|---| +| Slack | Salesforce (`noreply@salesforce.com`, `slackinvoices@salesforce.com`) | Slack was acquired by Salesforce in 2021; invoices migrated. The body still says "Slack" — only the From address changed. | +| Heroku | Salesforce | Same story (Salesforce-Heroku acquisition). | +| Figma | Adobe (post-acquisition close) | Watch for this if/when the acquisition closes. | +| MongoDB Atlas | mongodb.com (no change) | Listed because it's the most common "is this really MongoDB?" question — yes, Atlas billing is direct from mongodb.com. | + +When matching to a ShiftControl app, **rely on the body content** (subject line, "Pay " line, product names in line items) over the From address for these cases. Annotate the note with `(billed via )` so the user can see why the invoice came from an unexpected sender. + +### Resellers (bundled and consolidator billing) + +A reseller buys SaaS in bulk and re-bills the customer. The invoice is from the RESELLER; the actual SaaS product is in line items. Examples: + +- **ShiftControl itself** — for Google Workspace and JumpCloud customers, ShiftControl bills the customer directly; the customer doesn't see Google or JumpCloud invoices. The ShiftControl invoice line items name the underlying product. +- **CDW, Insight, Carahsoft, Crayon, SoftwareOne** — large IT resellers; one invoice may cover dozens of SaaS line items. +- **Stripe (as consolidator)** — Stripe sometimes bills the customer on behalf of multiple upstream SaaS vendors. Look for "On behalf of " or "Pay " in the body. + +When the invoice is from a reseller, the skill: + +1. Reads the line items to find which ShiftControl-tracked apps the invoice covers. +2. Creates a SEPARATE proposed update per app (one ShiftControl invoice covering Google Workspace + JumpCloud → two diff blocks in the proposal, one per app). +3. Annotates the note with `(billed via )` for each affected app — e.g. `"Updated from Google Workspace invoice dated 2026-03-15 (billed via ShiftControl)"`. + +If a reseller invoice has line items that don't match any ShiftControl app, treat each unmatched line the same way an unmatched standalone invoice would be — surface to the user as "found a Adobe Creative Cloud line on the ShiftControl invoice, but you don't track Adobe in ShiftControl yet." + ## Other failure modes worth flagging -- **Vendor consolidator** — emails from Stripe billing the user on behalf of MANY upstream SaaS vendors. The "From" is Stripe but the actual vendor is in the body. Look for "On behalf of" or "Pay ". -- **Reseller invoicing** — organizations that buy SaaS through a reseller (CDW, Insight, Carahsoft, etc.). Vendor on the invoice is the reseller; the actual SaaS product is in line items. - **Currency conversion** — invoice in USD but user's org defaults to EUR/SGD. Surface the invoice currency; don't auto-convert. Let the user decide whether to store in invoice currency or org default. -- **PDF-only invoices** — the email body is "Your invoice is attached" with no structured data; the actual numbers are in the PDF. v0.1.0 does NOT parse PDF attachments — mark these as uncertain. -- **Web-hosted invoices** — "Click here to view your invoice" with the actual numbers behind a link. v0.1.0 does NOT follow these links — mark these as uncertain. +- **PDF invoices** — many SaaS vendors send the invoice details as a PDF attachment with only a short summary in the email body ("Your invoice is attached"). **Parse the PDF**: most email MCPs return attachment contents, and most AI assistants can read PDFs natively. Extract the same fields (vendor, amounts, service period, seats, plan tier) from the PDF as you would from an inline body. If your email MCP can't return attachments OR your assistant can't read PDFs, mark the invoice uncertain and surface to the user. +- **Web-hosted invoices** — "Click here to view your invoice" with the actual numbers behind a link rather than in the body or attachment. **Ask the user before following the link** — most will say yes, some prefer not to follow links from their inbox. Phrasing: *"I found invoices from where the details are behind a 'view invoice' link — should I follow those links to extract the cost?"* If the user agrees and your assistant has a web-fetch capability, fetch and parse. Otherwise mark uncertain. ## What this skill does NOT do (yet) These are explicitly out-of-scope for v0.1.0 and tracked for v0.2.0+: -- Parse PDF attachments -- Follow links to web-hosted invoices -- Integrate with Xero, QuickBooks, Brex, Ramp, or other finance systems -- Multi-currency normalization -- Automatic vendor → ShiftControl-app addition (creating apps from invoices) +- Integrate with Xero, QuickBooks, Brex, Ramp, or other finance systems (v0.2.0 adds these as alternate sources alongside email) +- Multi-currency normalization (skill surfaces the invoice currency; user decides whether to store in invoice currency or org default) +- Automatic vendor → ShiftControl-app addition (skill never creates apps from invoices; only updates existing ones) diff --git a/skills/refresh-subscription-info/references/proposal-format.md b/skills/refresh-subscription-info/references/proposal-format.md index 20de7f2..914ac75 100644 --- a/skills/refresh-subscription-info/references/proposal-format.md +++ b/skills/refresh-subscription-info/references/proposal-format.md @@ -24,7 +24,7 @@ or "show details for N" to see the full invoice context for change N. {field}: {current value} → {proposed value} {field}: {current value} → {proposed value} ... - note will be added: "{note string}" + notes update: "{note string}" ``` Rules: @@ -34,7 +34,8 @@ Rules: - Show `(not set)` for currently-null fields rather than blanks. - Format costs with **currency symbol AND unit**: `$8.00/user/month`, `$300.00/year flat`, `€50.00/user/year`. - Format dates as ISO 8601 date only: `2026-03-15`. -- Include the `note will be added:` line on every block — showing the user that the change will also write a short context note to the app is part of "no surprises". +- Include the `notes update:` line on every block — showing the user that the change will also write a short context note to the app is part of "no surprises". The phrasing `notes update` (not `notes added`) is intentional: the skill replaces its own previous note line rather than accumulating new ones every time it runs. +- For reseller / parent-billed invoices, include the source in the note string: `"Updated from Slack invoice dated 2026-03-15 (billed via Salesforce)"`. ## No-change blocks diff --git a/skills/refresh-subscription-info/references/vendor-name-mapping.md b/skills/refresh-subscription-info/references/vendor-name-mapping.md index 25e85c7..e774e72 100644 --- a/skills/refresh-subscription-info/references/vendor-name-mapping.md +++ b/skills/refresh-subscription-info/references/vendor-name-mapping.md @@ -52,6 +52,25 @@ If the invoice's From-address domain matches a known vendor, accept that match e Build a domain → product mapping as you go: when you successfully match a vendor by name, remember its domain for the rest of the session. +**Acquired-product domain rebinds** — the From-domain doesn't always match the product because the parent company took over billing post-acquisition. Body content (subject, line items, "Pay " references) is authoritative when the From-domain is the parent's: + +- `noreply@salesforce.com` / `slackinvoices@salesforce.com` with "Slack" in body → match the user's Slack app +- `noreply@salesforce.com` with "Heroku" in body → match the user's Heroku app (if tracked) +- Future Adobe-Figma billing — same pattern once that closes + +Annotate these in the audit note as `(billed via Salesforce)` / `(billed via Adobe)` so the next reader understands the From-address. + +### 4a. Reseller line-item matching + +When the invoice is from a reseller (ShiftControl-as-reseller, CDW, Insight, Carahsoft, Stripe-as-consolidator), the reseller's name is on the envelope but the products are in line items. Match each line item separately: + +1. Read the line items in the body. +2. For each line item, run matching rules 1-3 above against the line-item text (e.g. "Google Workspace Business Plus — 50 seats") against the user's app list. +3. Create one proposed update per matched line item. +4. Annotate each note with `(billed via )` — e.g. `"Updated from Google Workspace invoice dated 2026-03-15 (billed via ShiftControl)"`. + +If a reseller line item doesn't match any ShiftControl app, surface it as a not-tracked entry separately (same path as a standalone unmatched invoice). + ### 5. Fuzzy match Edit distance ≤ 2 on normalized strings, but only for app names ≥ 6 characters (avoids false matches on short names like "Box" → "Bot"). From c160598b8756a0955687968de2abf6aba84eced7 Mon Sep 17 00:00:00 2001 From: Dan Gericke Date: Thu, 14 May 2026 12:48:21 +0800 Subject: [PATCH 06/10] =?UTF-8?q?Add=20CODEOWNERS=20=E2=80=94=20@shiftcont?= =?UTF-8?q?rol-dan=20as=20default=20reviewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without CODEOWNERS, branch protection's PR-review requirement enforces '1 approver' but doesn't route the request to a specific reviewer. With this file, GitHub auto-requests review from @shiftcontrol-dan on every PR that touches anything in the repo. The 'require code owner reviews' flag on the branch protection rule was intentionally left off in the initial setup (since CODEOWNERS didn't exist). Now that CODEOWNERS lands, that flag can be flipped on if you want to require code-owner approval specifically (rather than 'any 1 approver'). --- CODEOWNERS | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..e041d2b --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,16 @@ +# Code owners for ShiftControl-io/skills +# +# Every PR must be approved by a code owner listed here. +# This file is enforced by GitHub branch protection on `main`. + +# Default — anything in the repo +* @shiftcontrol-dan + +# Skill content — same as default for now; expand as the team grows +skills/ @shiftcontrol-dan + +# Repo tooling and governance +.github/ @shiftcontrol-dan +CONTRIBUTING.md @shiftcontrol-dan +CODEOWNERS @shiftcontrol-dan +LICENSE @shiftcontrol-dan From e9234a278d67445314ee8965986abccf04ca2146 Mon Sep 17 00:00:00 2001 From: Dan Gericke Date: Thu, 14 May 2026 12:53:38 +0800 Subject: [PATCH 07/10] Refine invoice detection from real-world Gmail data Mined 18 months of a real ShiftControl invoice inbox to verify and refine the detection / matching guidance. Three corrections and several additions. CORRECTIONS: - Slack-via-Salesforce was overstated. Real accounts continue receiving Slack invoices from feedback@slack.com even post-acquisition. Removed the hard claim; phrased as 'pattern varies per acquisition and per account, dont assume, read the body.' ADDITIONS to vendor-name-mapping.md: - Real-world vendor-to-sender catalog: Slack, Notion, GitHub, Granola, Framer, Anthropic, 1Password, JumpCloud, Cloudflare, Zoom, AWS. Includes typical subject patterns and format (inline / PDF / Stripe-template). - Stripe-template receipts recognized as a vendor-cluster pattern (Granola, Framer, Anthropic all share 'Your receipt from #'). ADDITIONS to invoice-detection.md: - Dedicated invoice mailbox + forwarding rules: many orgs route invoices to invoices@.io via forwarding. Sender appears as the alias; real vendor is in the body. Skill must read body to identify the vendor in these cases. - False-positive cluster: outbound payments (Workato-paying-you via Coupa Pay is the textbook example, but also customer payments via Stripe / Sequence HQ, professional services, one-time SSL/domain/hardware, telecom, bank / tax / regulatory). - Reseller catalog: Ingram Micro Asia (Imcloudservicedesk.hk@cloud.im), uses 2026SIHK invoice format and sends Credit Memos (reduce balances, do not parse as new charges). AWS Marketplace for third-party SaaS. ADDITION to SKILL.md Step 3: - Outbound-payments filter - Dedicated-mailbox forwarding callout (read body for vendor) --- skills/refresh-subscription-info/SKILL.md | 5 ++- .../references/invoice-detection.md | 35 +++++++++++++++++ .../references/vendor-name-mapping.md | 39 ++++++++++++++++--- 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/skills/refresh-subscription-info/SKILL.md b/skills/refresh-subscription-info/SKILL.md index b821ea3..902fc72 100644 --- a/skills/refresh-subscription-info/SKILL.md +++ b/skills/refresh-subscription-info/SKILL.md @@ -56,7 +56,10 @@ Use the email-search MCP to find candidate invoice emails. See [references/invoi - Subject contains one of: `invoice`, `receipt`, `billing`, `subscription`, `renewal`, `payment confirmation`, `order confirmation`. - From-address matches `billing@*`, `invoices@*`, `no-reply@*`, `accounts@*`, `finance@*`, `payments@*`, OR is a known SaaS vendor domain. - Filter OUT emails where the user is the **sender** (those are outbound invoices to their own customers). -- Filter OUT clearly non-SaaS receipts (Amazon shopping, ride-share, restaurants, hardware). +- Filter OUT **inbound-payments-received** — emails saying things like *", Inc. has sent you a payment"* or *"Coupa Pay has remitted X to your account"*. These represent money coming TO the user (referrals, vendor-side payments) and are not SaaS invoices. +- Filter OUT clearly non-SaaS receipts (Amazon shopping, ride-share, restaurants, hardware, SSL certs, professional services, contractor invoices, telecom bills). + +**Dedicated invoice mailboxes:** if the user has a forwarding rule that routes invoices to a dedicated alias (e.g. `invoices@.io`), the sender on most emails will be the alias itself rather than the original vendor. **Read the body** to identify the actual vendor — the original sender is in the forwarded body, the subject typically still contains the vendor name. Don't skip these; they're often the bulk of useful invoices. ### Step 4 — Classify, then extract structured data diff --git a/skills/refresh-subscription-info/references/invoice-detection.md b/skills/refresh-subscription-info/references/invoice-detection.md index 5f91979..dbddc33 100644 --- a/skills/refresh-subscription-info/references/invoice-detection.md +++ b/skills/refresh-subscription-info/references/invoice-detection.md @@ -139,6 +139,41 @@ When the invoice is from a reseller, the skill: If a reseller invoice has line items that don't match any ShiftControl app, treat each unmatched line the same way an unmatched standalone invoice would be — surface to the user as "found a Adobe Creative Cloud line on the ShiftControl invoice, but you don't track Adobe in ShiftControl yet." +## Dedicated invoice mailboxes and forwarding + +Many organizations route SaaS invoices to a dedicated alias like `invoices@.io` either via mail-server forwarding rules (vendor mail arrives directly at the alias) or via user-level forwarding (a person forwards each invoice manually from their personal inbox). + +When the email-search MCP is reading the dedicated mailbox, the sender field will frequently show **the alias itself** (e.g. `invoices@shiftcontrol.io`) rather than the original vendor's address — because the email arrived via a forwarding rule. The original vendor is in the **body**: + +- Subject line of the forwarded email +- "From: " line near the top of the body +- Stripe-template "Your receipt from " pattern +- Vendor logo / product name in the body header + +**Read the body to identify the vendor whenever the From-address is the user's own organization alias.** Don't skip these — they're often the bulk of the useful invoices. + +A practical heuristic: if the From-domain matches the user's company domain (the same domain ShiftControl runs on for that user's account), treat the email as forwarded and rely on the body for vendor identification. + +## False positives — outbound and unrelated emails + +The dedicated invoice mailbox typically collects more than just SaaS subscription invoices. Filter these OUT: + +- **Outbound payments / referrals received** — emails where the user's organization is RECEIVING money rather than paying. Common phrases: `", Inc. has sent you a payment"`, `"You received a payment of "`, `"Coupa Pay has remitted to your account"`. Workato's referral payments to ShiftControl partners are a textbook example. These look structurally like invoices but represent the OPPOSITE direction of money. +- **Customer payments to YOU** — emails from your own billing system (Stripe, Sequence HQ, etc.) confirming that one of YOUR customers paid YOU. Detect by: the From-domain belongs to the user's billing platform AND the body references *receiving* a payment from a customer. +- **Professional services** — accountant fees, lawyer invoices, contractor / freelancer invoices. These are real money out the door but are NOT SaaS subscriptions. Detect by: vendor doesn't appear in any tracked-app list and the body describes services rather than software access. +- **One-time purchases** — SSL certificates, domain registrations, hardware, marketing services. No recurring subscription relationship. +- **Telecom and utilities** — SIM cards, internet, electricity, office costs. +- **Bank, tax, regulatory** — bank statements, tax filings, government billings. + +These all share a recognizable pattern: vendor doesn't match any tracked ShiftControl app, OR the email describes a one-off transaction rather than a subscription. When in doubt, surface in the "found but not tracked" section of the proposal rather than guessing. + +## Reseller invoices observed in practice + +In addition to the major resellers (CDW, Insight, Carahsoft, Crayon, SoftwareOne) listed earlier, real ShiftControl-customer inboxes commonly include: + +- **Ingram Micro Asia Marketplace** (`Imcloudservicedesk.hk@cloud.im`) — sells Acronis and other cloud services. Subjects: `"Invoice 2026SIHK00"`, `"Credit Memo 2026CNHK"`, `"Payment 2026PRHK has been received"`. Watch for `"Credit Memo"` — those reduce a balance and shouldn't be parsed as a new charge. +- **AWS Marketplace** (`invoicing@aws.com`) — third-party SaaS subscriptions billed through AWS. Subject contains `"AWS Marketplace Statement"` or `"AWS Marketplace Billing Statement"`. Line items name the underlying SaaS product. + ## Other failure modes worth flagging - **Currency conversion** — invoice in USD but user's org defaults to EUR/SGD. Surface the invoice currency; don't auto-convert. Let the user decide whether to store in invoice currency or org default. diff --git a/skills/refresh-subscription-info/references/vendor-name-mapping.md b/skills/refresh-subscription-info/references/vendor-name-mapping.md index e774e72..095a2ec 100644 --- a/skills/refresh-subscription-info/references/vendor-name-mapping.md +++ b/skills/refresh-subscription-info/references/vendor-name-mapping.md @@ -52,13 +52,42 @@ If the invoice's From-address domain matches a known vendor, accept that match e Build a domain → product mapping as you go: when you successfully match a vendor by name, remember its domain for the rest of the session. -**Acquired-product domain rebinds** — the From-domain doesn't always match the product because the parent company took over billing post-acquisition. Body content (subject, line items, "Pay " references) is authoritative when the From-domain is the parent's: +**Acquired-product domain rebinds** — the From-domain doesn't always match the product because the parent company sometimes takes over billing post-acquisition. The pattern varies per acquisition and per account: -- `noreply@salesforce.com` / `slackinvoices@salesforce.com` with "Slack" in body → match the user's Slack app -- `noreply@salesforce.com` with "Heroku" in body → match the user's Heroku app (if tracked) -- Future Adobe-Figma billing — same pattern once that closes +- Some vendors keep their original billing domain post-acquisition (e.g. Slack frequently continues to bill from `feedback@slack.com` even after the Salesforce acquisition). +- Others migrate to the parent (e.g. some Heroku accounts now bill from `noreply@salesforce.com`). +- A few vendors split — some account types stay on the original domain while enterprise accounts move to the parent's billing. -Annotate these in the audit note as `(billed via Salesforce)` / `(billed via Adobe)` so the next reader understands the From-address. +**Don't assume the rebind has happened**; read the body for the product name. If you DO see a clear rebind (the From-domain is one vendor and the body talks about a different product), match to the product named in the body and annotate the note with `(billed via )` so the next reader understands the From-address. + +### Observed vendor → email-sender patterns + +A non-exhaustive catalog of patterns observed in real ShiftControl inboxes. The skill should recognize these but not be limited to them. + +| Product | Typical From | Subject pattern | Format | +|---|---|---|---| +| Slack | `feedback@slack.com` | ", your plan has renewed", ", you're all set" | Inline body + "View Receipt" link | +| Notion | `team@mail.notion.so`, vendor-templated | "Updates to your Notion invoice" (incremental), "Notion reminder: You will be charged in N days" (pre-renewal) | Inline body | +| GitHub | `noreply@github.com` | "GitHub Invoice - " | PDF attached | +| Granola | `notifications@mail.granola.ai` (and Stripe template) | "Your receipt from Granola #" | Stripe-template inline | +| Framer | (Stripe-template sender) | "Your receipt from Framer B.V. #" | Stripe-template inline | +| Anthropic | `invoice+statements@mail.anthropic.com` | "Your receipt from Anthropic, PBC #" | Stripe-template inline | +| 1Password | (varies) | "Your 1Password invoice ()" | Inline body | +| JumpCloud | (varies; often forwarded by an internal alias) | "Invoice from JumpCloud" | PDF attached | +| Cloudflare | (varies) | "Your invoice is attached" OR "Your Cloudflare purchase confirmation" | PDF or "View invoice" link | +| Zoom | (varies) | "Payment Processed for " | PDF attached | +| AWS | `invoicing@aws.com`, `aws-globalreceivables@email.amazon.com` | "Amazon Web Services Billing Statement", "AWS Marketplace Statement" | PDF | + +### Stripe-template receipts are a recognizable shape + +Several vendors (Granola, Framer, Anthropic, others) use Stripe's billing platform, which produces a recognizable email template: + +- Subject: `"Your receipt from #"` +- Body opens with the vendor name and an invoice/receipt number +- A "View receipt" or "Download invoice" link is usually present +- Charge amount and period are inline + +When you see this template, the vendor in the subject IS the vendor you're matching — the From-address may be the vendor's own domain or a Stripe relay. ### 4a. Reseller line-item matching From b778d85287b8ffd9239ad1ee11021b81d601d970 Mon Sep 17 00:00:00 2001 From: Dan Gericke Date: Thu, 14 May 2026 12:58:54 +0800 Subject: [PATCH 08/10] Auto-release on VERSION bump + real-world refinements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things in one commit: 1. AUTO-RELEASE WORKFLOW (.github/workflows/release.yaml + top-level VERSION) Modeled on the google_api_sdk pattern. On push to main where VERSION changes (or workflow_dispatch), the workflow: - Reads VERSION (validates SemVer) - Skips if v tag already exists - Imports a CI signing GPG key (secrets.GPG_PRIVATE_KEY/_PASSPHRASE) - Creates a signed tag v, pushes it - Builds per-skill zips (skills// → -v.zip) - Creates a GitHub release with --generate-notes + zip artifacts VERSION pinned to 0.1.0 to match the existing tag; the workflow will detect the existing tag and skip on first deploy, then activate cleanly on the next bump. CONTRIBUTING.md documents the GPG setup steps (gpg --gen-key, export private to GPG_PRIVATE_KEY, public to GitHub signing keys). 2. SALESFORCE-BILLED SLACK — both patterns coexist Concrete evidence (a real Salesforce invoice .eml for Slack annual): - From: Salesforce APAC Billing - Subject: 'salesforce.com Invoice , ' - Body does NOT mention Slack; product is only in the PDF attachment - PDF filename: 'Salesforce_Invoice_.pdf' Documented as: SMB / monthly contracts continue billing from feedback@slack.com; enterprise / annual contracts move to Salesforce. This is the canonical case for why PDF parsing matters. Other Salesforce-billed products on the same pattern: Heroku, Tableau, MuleSoft, ExactTarget. 3. INGRAM IS A DISTRIBUTOR (direction matters) Real Ingram traffic in a ShiftControl-style inbox is mostly NOT for products the org consumes — it's invoices for products the org SELLS THROUGH Ingram to downstream customers. Only invoices where the org is the listed 'Bill To' / 'Customer' / 'Sold To' represent real subscription costs. Detection signal: parse the body / PDF for the customer-side field. Match the org → propose update. Different name → outbound channel sale → filter out, surface in 'channel / resale invoices skipped' section. Credit Memos and payment-received notifications: don't propose changes from these regardless of direction. They modify balance, not per-unit cost. Mention informationally. 4. CORRECTION: shared billing inboxes are GROUPS, not forwarding rules. Earlier text said the invoices@ alias appears as the SENDER because of forwarding rules — that's wrong. The pattern is: - invoices@.io is a Google Group / DL on the RECEIVER side - Each SaaS subscription's billing-contact email is set to that address - Invoices arrive with the actual vendor as From and the group as To - Gmail's API sometimes shows the group as From when a personal mailbox reads via group membership (a display quirk, not a forwarding rule) SKILL.md Step 3 and invoice-detection.md updated to reflect this and to guide the skill toward 'to:invoices@.io newer_than:18m' as the highest-precision starting search. --- .github/workflows/release.yaml | 101 ++++++++++++++++++ CONTRIBUTING.md | 35 +++++- VERSION | 1 + skills/refresh-subscription-info/SKILL.md | 2 +- .../references/invoice-detection.md | 54 +++++++--- .../references/vendor-name-mapping.md | 10 +- 6 files changed, 183 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/release.yaml create mode 100644 VERSION diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..2107f37 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,101 @@ +name: Release on VERSION bump + +# Auto-tag and auto-release whenever the top-level VERSION file changes on main. +# The workflow imports a GPG signing key (so the tag is signed, matching the +# CONTRIBUTING.md signed-commit policy) and attaches per-skill zip artifacts to +# the GitHub release for claude.ai users who can't install skills from a git URL. + +on: + push: + branches: [main] + paths: + - 'VERSION' + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + name: Tag + release if VERSION bumped + runs-on: ubuntu-latest + steps: + - name: Checkout (full history for tag lookup) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Read VERSION + id: version + run: | + VERSION=$(cat VERSION | tr -d '[:space:]') + if [ -z "$VERSION" ]; then + echo "::error::VERSION file is empty" + exit 1 + fi + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$'; then + echo "::error::VERSION '$VERSION' is not valid SemVer" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + echo "VERSION: $VERSION" + + - name: Skip if tag already exists + id: tag_check + run: | + if git rev-parse "${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then + echo "Tag ${{ steps.version.outputs.tag }} already exists. Nothing to do." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Import signing GPG key + if: steps.tag_check.outputs.skip == 'false' + id: import_gpg + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + git_config_global: true + git_user_signingkey: true + git_tag_gpgsign: true + + - name: Create and push signed tag + if: steps.tag_check.outputs.skip == 'false' + env: + GIT_AUTHOR_NAME: ${{ steps.import_gpg.outputs.name }} + GIT_AUTHOR_EMAIL: ${{ steps.import_gpg.outputs.email }} + GIT_COMMITTER_NAME: ${{ steps.import_gpg.outputs.name }} + GIT_COMMITTER_EMAIL: ${{ steps.import_gpg.outputs.email }} + run: | + TAG="${{ steps.version.outputs.tag }}" + git tag -s "$TAG" -m "Release $TAG" + git push origin "$TAG" + echo "✓ Pushed signed tag $TAG" + + - name: Build per-skill zip artifacts + if: steps.tag_check.outputs.skip == 'false' + id: zips + run: | + mkdir -p /tmp/skill-zips + VERSION="${{ steps.version.outputs.version }}" + for dir in skills/*/; do + name=$(basename "$dir") + ( cd skills && zip -r "/tmp/skill-zips/${name}-v${VERSION}.zip" "$name" -x '*.DS_Store' ) + echo "✓ Built ${name}-v${VERSION}.zip" + done + ls -la /tmp/skill-zips + + - name: Create GitHub release with skill zips + if: steps.tag_check.outputs.skip == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${{ steps.version.outputs.tag }}" + gh release create "$TAG" \ + --title "$TAG" \ + --generate-notes \ + /tmp/skill-zips/*.zip + echo "✓ Created release $TAG" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1d649da..a472a75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,8 +48,39 @@ Same flow. Bump the version when behavior changes meaningfully (new MCP tool dep ## Release process - Merged PRs land on `main`. -- We tag releases as `vMAJOR.MINOR.PATCH` (`v0.1.0`, `v0.2.0`, etc.). -- Each tag triggers a release with zipped skill folders for claude.ai users who can't install from a Git URL directly. +- The top-level `VERSION` file is the source of truth (single SemVer line, e.g. `0.1.0`). +- To cut a release: open a PR that bumps `VERSION`. On merge, `.github/workflows/release.yaml` automatically: + 1. Imports a CI signing GPG key + 2. Creates a signed tag `v` + 3. Pushes the tag + 4. Builds per-skill zip artifacts (one zip per `skills//` folder) + 5. Creates a GitHub release with auto-generated notes from the commit history and attaches the zips +- Tags are `vMAJOR.MINOR.PATCH` (`v0.1.0`, `v0.2.0`, etc.) per [SemVer](https://semver.org). Per-skill versions live in the SKILL.md frontmatter for the moment; repo-level VERSION is the release coordinator. +- The release workflow only fires when `VERSION` itself changes — skill edits without a version bump land on `main` without producing a release. Bump `VERSION` deliberately. + +### CI signing key setup (one-time, by a repo admin) + +The release workflow signs tags with a dedicated CI GPG key. To set this up: + +1. Generate a new GPG key for CI use (separate from contributors' personal keys): + ```bash + gpg --batch --gen-key <, Inc. has sent you a payment"* or *"Coupa Pay has remitted X to your account"*. These represent money coming TO the user (referrals, vendor-side payments) and are not SaaS invoices. - Filter OUT clearly non-SaaS receipts (Amazon shopping, ride-share, restaurants, hardware, SSL certs, professional services, contractor invoices, telecom bills). -**Dedicated invoice mailboxes:** if the user has a forwarding rule that routes invoices to a dedicated alias (e.g. `invoices@.io`), the sender on most emails will be the alias itself rather than the original vendor. **Read the body** to identify the actual vendor — the original sender is in the forwarded body, the subject typically still contains the vendor name. Don't skip these; they're often the bulk of useful invoices. +**Shared billing inboxes:** most organizations set up a shared `invoices@.io`, `billing@.io`, or `finance@.io` address (a Google Group or distribution list) as the billing contact on every SaaS account. Invoices arrive at the *receiver* side at that address — the vendor is still the actual From-address. A search like `to:invoices@.io newer_than:18m` is the highest-precision starting point. Ask the user which shared inbox they use if you're unsure; if there isn't one, fall back to searching the personal inbox. ### Step 4 — Classify, then extract structured data diff --git a/skills/refresh-subscription-info/references/invoice-detection.md b/skills/refresh-subscription-info/references/invoice-detection.md index dbddc33..8868aca 100644 --- a/skills/refresh-subscription-info/references/invoice-detection.md +++ b/skills/refresh-subscription-info/references/invoice-detection.md @@ -139,20 +139,25 @@ When the invoice is from a reseller, the skill: If a reseller invoice has line items that don't match any ShiftControl app, treat each unmatched line the same way an unmatched standalone invoice would be — surface to the user as "found a Adobe Creative Cloud line on the ShiftControl invoice, but you don't track Adobe in ShiftControl yet." -## Dedicated invoice mailboxes and forwarding +## Shared billing inboxes (the `invoices@` / `billing@` / `finance@` pattern) -Many organizations route SaaS invoices to a dedicated alias like `invoices@.io` either via mail-server forwarding rules (vendor mail arrives directly at the alias) or via user-level forwarding (a person forwards each invoice manually from their personal inbox). +Many organizations set up a **shared billing inbox** — typically a Google Group or distribution list at an address like `invoices@.io`, `billing@.io`, or `finance@.io`. They then configure each SaaS subscription's billing-contact email to point at that address, so all renewal / invoice mail lands in one place where finance can see and act on it. -When the email-search MCP is reading the dedicated mailbox, the sender field will frequently show **the alias itself** (e.g. `invoices@shiftcontrol.io`) rather than the original vendor's address — because the email arrived via a forwarding rule. The original vendor is in the **body**: +This means the **To-address** is the shared inbox, not the From-address. The From-address remains the actual vendor (`feedback@slack.com`, `noreply@github.com`, `team@mail.notion.so`, etc.). The skill should treat this pattern as the default, not the exception — most ShiftControl customers will run a shared billing inbox. -- Subject line of the forwarded email -- "From: " line near the top of the body -- Stripe-template "Your receipt from " pattern -- Vendor logo / product name in the body header +### How shared inboxes appear via email-search MCPs -**Read the body to identify the vendor whenever the From-address is the user's own organization alias.** Don't skip these — they're often the bulk of the useful invoices. +How the email surfaces in the search depends on how the user reads the group's mail: -A practical heuristic: if the From-domain matches the user's company domain (the same domain ShiftControl runs on for that user's account), treat the email as forwarded and rely on the body for vendor identification. +- **Reading the group's own mailbox directly** (if the email-search MCP authenticates against `invoices@.io` as a mailbox): From-address is the vendor, exactly as expected. +- **Reading a personal mailbox that's a group member**: Gmail's API sometimes shows the GROUP address as the sender for messages delivered through the group. The actual vendor is still recoverable from the email's standard headers (Reply-To, Sender, body From-line, "Your receipt from " subject), but a naive From-header check can mislead. +- **Manual forwards from a personal inbox**: occasionally someone forwards a stray invoice into the group address. From is the forwarder; the original vendor is in the forwarded body. + +### Practical guidance + +- **Don't rely solely on the From-header to identify the vendor.** Cross-check the subject, the body's vendor name / logo, the Reply-To, and PDF attachment filenames (e.g. `Salesforce_Invoice_36849376.pdf` makes the product context obvious even when the body is sparse). +- If the From-address is the user's own org alias (`invoices@.io`) and the body header / subject names a recognizable SaaS vendor, treat the body as authoritative. +- A search like `to:invoices@.io newer_than:18m` is a high-precision starting point — the user has explicitly routed billing to that address, so almost every result is invoice-shaped (with the false-positive classes documented above still applying). ## False positives — outbound and unrelated emails @@ -167,13 +172,38 @@ The dedicated invoice mailbox typically collects more than just SaaS subscriptio These all share a recognizable pattern: vendor doesn't match any tracked ShiftControl app, OR the email describes a one-off transaction rather than a subscription. When in doubt, surface in the "found but not tracked" section of the proposal rather than guessing. -## Reseller invoices observed in practice +## Distributors and resellers — direction matters -In addition to the major resellers (CDW, Insight, Carahsoft, Crayon, SoftwareOne) listed earlier, real ShiftControl-customer inboxes commonly include: +In addition to the major resellers (CDW, Insight, Carahsoft, Crayon, SoftwareOne) listed earlier, real inboxes commonly include: -- **Ingram Micro Asia Marketplace** (`Imcloudservicedesk.hk@cloud.im`) — sells Acronis and other cloud services. Subjects: `"Invoice 2026SIHK00"`, `"Credit Memo 2026CNHK"`, `"Payment 2026PRHK has been received"`. Watch for `"Credit Memo"` — those reduce a balance and shouldn't be parsed as a new charge. +- **Ingram Micro** (`Imcloudservicedesk.hk@cloud.im`, also `*@ingrammicro.com`) — a large IT **distributor**. Subjects: `"Invoice SI0000"`, `"Credit Memo"`, `"Payment ... has been received"`, `"Your Account ... Was Put on Credit Hold"`. - **AWS Marketplace** (`invoicing@aws.com`) — third-party SaaS subscriptions billed through AWS. Subject contains `"AWS Marketplace Statement"` or `"AWS Marketplace Billing Statement"`. Line items name the underlying SaaS product. +**Critical distinction — distributor invoices flow in both directions:** + +Large distributors like Ingram Micro work as a two-way channel. The same email account can receive: + +1. **Invoices for SaaS the user's organization consumes** (the org is the listed customer; this is a real subscription cost to reconcile). +2. **Invoices for SaaS the user's organization SELLS THROUGH the distributor** to its own downstream customers (the org is the channel / partner, not the consumer; the listed customer is a third party). + +Only #1 should produce a proposed update in ShiftControl. #2 is a billing transaction on the user's own product, not a SaaS subscription they consume. + +**Detection signal — the "Bill To" / "Customer" field:** + +Parse the invoice (body or PDF) for the customer-side fields: + +- `Bill To: ` +- `Customer: ` +- `Sold To: ` +- `Account: ` (less reliable — sometimes the distributor's own customer ID) + +If the listed customer matches the user's organization → real subscription → proceed. +If the listed customer is someone else (a downstream company name unfamiliar to the user) → outbound channel sale → **filter out**. Surface in a "channel / resale invoices skipped" section of the proposal so the user knows you saw them but intentionally didn't act. + +Credit Memos and payment-received notifications also fall under this filter — they're typically not subscription cost changes either way. Mention them in the skipped section so the user can confirm. + +Credit Memos and payment-received notifications, even for direct consumption: don't propose changes from these. They modify a balance, not a per-unit cost. Surface as informational only. + ## Other failure modes worth flagging - **Currency conversion** — invoice in USD but user's org defaults to EUR/SGD. Surface the invoice currency; don't auto-convert. Let the user decide whether to store in invoice currency or org default. diff --git a/skills/refresh-subscription-info/references/vendor-name-mapping.md b/skills/refresh-subscription-info/references/vendor-name-mapping.md index 095a2ec..b9f20f3 100644 --- a/skills/refresh-subscription-info/references/vendor-name-mapping.md +++ b/skills/refresh-subscription-info/references/vendor-name-mapping.md @@ -52,13 +52,13 @@ If the invoice's From-address domain matches a known vendor, accept that match e Build a domain → product mapping as you go: when you successfully match a vendor by name, remember its domain for the rest of the session. -**Acquired-product domain rebinds** — the From-domain doesn't always match the product because the parent company sometimes takes over billing post-acquisition. The pattern varies per acquisition and per account: +**Acquired-product domain rebinds** — the From-domain doesn't always match the product because the parent company sometimes takes over billing post-acquisition. **Both patterns coexist for the SAME vendor depending on the customer's plan**: -- Some vendors keep their original billing domain post-acquisition (e.g. Slack frequently continues to bill from `feedback@slack.com` even after the Salesforce acquisition). -- Others migrate to the parent (e.g. some Heroku accounts now bill from `noreply@salesforce.com`). -- A few vendors split — some account types stay on the original domain while enterprise accounts move to the parent's billing. +- **SMB / monthly contracts** typically keep the original billing domain (e.g. Slack continues to bill from `feedback@slack.com` for month-to-month and small-team accounts). +- **Enterprise / annual contracts** are frequently migrated to the parent's billing platform. Real-world example: Slack annual contracts bill from `Salesforce APAC Billing ` with subject `"salesforce.com Invoice , "`. The Salesforce email body often does NOT say "Slack" — the product name is only in the **attached PDF**. This is the canonical case for why PDF parsing matters. +- Other Salesforce-billed products on the same template: Heroku, Tableau, MuleSoft, ExactTarget / Marketing Cloud. -**Don't assume the rebind has happened**; read the body for the product name. If you DO see a clear rebind (the From-domain is one vendor and the body talks about a different product), match to the product named in the body and annotate the note with `(billed via )` so the next reader understands the From-address. +**Don't assume the rebind has or hasn't happened**; read the body AND parse the PDF for the product name. If the From-domain is `salesforce.com` (or another acquirer) but you can't identify the product from the body alone, the PDF attachment is where the product is named. Match to the product named in the body or PDF, and annotate the note with `(billed via )` so the next reader understands the From-address — e.g. `"Updated from Slack invoice dated 2026-02-17 (billed via Salesforce)"`. ### Observed vendor → email-sender patterns From 5680d5b0aadc27fedeb9f45f0ed299044c40eb8b Mon Sep 17 00:00:00 2001 From: Dan Gericke Date: Thu, 14 May 2026 13:02:33 +0800 Subject: [PATCH 09/10] CONTRIBUTING: GPG signing key is provisioned at org level GPG_PRIVATE_KEY and GPG_PASSPHRASE are configured as ShiftControl-io org-level secrets (already backing release workflows in other repos like google_api_sdk). No per-repo setup needed; both secrets are automatically available to this repo's release.yaml. Replaced the 'CI signing key setup' how-to with a brief note pointing at the org secrets, plus a section for forkers who'd need their own equivalent setup outside ShiftControl-io. --- CONTRIBUTING.md | 33 ++++++++++----------------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a472a75..f25bc6a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,29 +58,16 @@ Same flow. Bump the version when behavior changes meaningfully (new MCP tool dep - Tags are `vMAJOR.MINOR.PATCH` (`v0.1.0`, `v0.2.0`, etc.) per [SemVer](https://semver.org). Per-skill versions live in the SKILL.md frontmatter for the moment; repo-level VERSION is the release coordinator. - The release workflow only fires when `VERSION` itself changes — skill edits without a version bump land on `main` without producing a release. Bump `VERSION` deliberately. -### CI signing key setup (one-time, by a repo admin) - -The release workflow signs tags with a dedicated CI GPG key. To set this up: - -1. Generate a new GPG key for CI use (separate from contributors' personal keys): - ```bash - gpg --batch --gen-key < Date: Thu, 14 May 2026 13:04:26 +0800 Subject: [PATCH 10/10] =?UTF-8?q?CONTRIBUTING:=20drop=20CI=20signing=20key?= =?UTF-8?q?=20section=20=E2=80=94=20not=20contributor-facing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Org-level secrets are an infra concern, not something a skill contributor needs to know about. The release.yaml workflow references the secrets directly; that's enough. --- CONTRIBUTING.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f25bc6a..5fe737a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,17 +58,6 @@ Same flow. Bump the version when behavior changes meaningfully (new MCP tool dep - Tags are `vMAJOR.MINOR.PATCH` (`v0.1.0`, `v0.2.0`, etc.) per [SemVer](https://semver.org). Per-skill versions live in the SKILL.md frontmatter for the moment; repo-level VERSION is the release coordinator. - The release workflow only fires when `VERSION` itself changes — skill edits without a version bump land on `main` without producing a release. Bump `VERSION` deliberately. -### CI signing key (already configured at org level) - -The release workflow signs tags with the ShiftControl-io organization's CI GPG key, which is already provisioned as **organization-level secrets** in the GitHub org: - -- `GPG_PRIVATE_KEY` — the CI signing key in ASCII-armor format -- `GPG_PASSPHRASE` — the matching passphrase (empty if the key was generated unprotected) - -Both are available to this repo automatically — no per-repo setup needed. The same secrets back the release workflows in other ShiftControl-io repos (e.g. `google_api_sdk`), so the same signing identity appears on tags across the org. - -**If you fork this repo** to your own org, you'll need to provision equivalent secrets at your org or repo level — see GitHub's [docs on org-level Actions secrets](https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions#creating-secrets-for-an-organization) and the [GPG-import action](https://github.com/crazy-max/ghaction-import-gpg). Without the secrets, the release workflow's tag-import step will fail and no releases will publish; the repo still accepts signed-commit PRs from contributors regardless. - ## Code of conduct Be kind. Disagree on substance, not people. We follow the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).