Skip to content

dancolta/payup

Repository files navigation

PayUp

PayUp

Chase overdue invoices from Slack. PayUp reads QuickBooks or Wave, drafts polite escalating reminders, and sends them via Gmail only after you approve.

CI License: MIT Claude Code plugin

Install: add the plugin in Claude Code and run /payup-setup · Operate: from Slack

PayUp in Slack: show overdue, draft, send after approval

What PayUp is not: auto-dunning, a collections bot, a money-mover, or a mass-emailer. Nothing leaves your outbox until you approve it in Slack, the accounting side is read-only, and the firm "final" reminder is blocked from using legal or collections language.

PayUp does the part of invoice-chasing you hate (noticing who is overdue, writing the nudge) and leaves the part you want to keep (deciding what actually goes to a client). It is a human-in-the-loop alternative to Bill.com-style dunning, built for solo founders, freelancers, small agencies, and bookkeepers who already use QuickBooks or Wave plus Gmail plus Slack.

Contents: How it works · Slack commands · Install · Config · How it compares · Guardrails · FAQ

How it works

  1. Find. Once a day (or on demand) PayUp queries QuickBooks or Wave for invoices that are overdue and still unpaid, and posts the batch to your #payup Slack channel. Nothing is sent.
  2. Approve. You reply in plain language: send 1 and 3, skip Delta, or draft all. Tone escalates by age and history: gentle, then firm, then final.
  3. Send or draft. send emails the reminder from your Gmail; draft drops it in your Gmail Drafts so you can edit and send it yourself. When the invoice is marked paid, it disappears from the next batch.

You can read the exact reminder copy before anything goes out. The shipped tone is restrained on purpose:

Gentle: "Just a friendly heads up that invoice #1042 was due on the 13th and looks still open on our side. It may have slipped through."

Final: "This is a final reminder that invoice #1042 remains unpaid. Please arrange payment, or reply with a firm date so we can close this out."

Slack commands

You type (@PayUp ...) What happens
show overdue posts the current overdue batch (sends nothing)
send all / send 1 and 3 emails those reminders after your approval
draft all / draft 2 saves those reminders as Gmail drafts to edit and send yourself
skip Delta drops a row from this batch
edit template walks you through changing the reminder wording: pick gentle/firm/final, paste the new copy. Validated, sends nothing
help lists the commands

Anything ambiguous (a question, a negation, an unclear target) is treated as "do nothing" and PayUp asks you to clarify. It never sends on a guess.

Install

PayUp is set up once inside Claude Code, then operated from Slack.

pip install -e '.[bot]'

Then add the repo as a local plugin marketplace in Claude Code and run /payup-setup. It walks you through the three connections below. You need a QuickBooks (or Wave) token, a Gmail OAuth consent, and a Slack app.

QuickBooks (default source)

Create a free developer account and a sandbox company at developer.intuit.com, mint an access token from the OAuth Playground (scope com.intuit.quickbooks.accounting), and put QBO_ACCESS_TOKEN + QBO_REALM_ID in .env. Keep PAYUP_SOURCE=quickbooks. For an always-on bot, also set QBO_REFRESH_TOKEN + QBO_CLIENT_ID + QBO_CLIENT_SECRET so PayUp renews the hourly token itself. Set PAYUP_QBO_ENV=production only when you point it at a real company.

Wave (US/Canada alternative)

Set PAYUP_SOURCE=wave and provide a full-access GraphQL WAVE_API_TOKEN + WAVE_BUSINESS_ID.

Gmail (the only outbound send)

Create an OAuth client (Desktop app) in Google Cloud Console, save the JSON to config/oauth_client.json, and run python engine/scripts/gmail_oauth_setup.py once. PayUp requests three Gmail scopes only: send, readonly (to dedupe against your Sent mail), and compose (to save drafts). No full-mailbox access.

Slack (Socket Mode, no public URL)

Create an app at api.slack.com/apps, enable Socket Mode, add bot scopes chat:write + app_mentions:read + channels:history, install it, invite it to your channel, and put SLACK_BOT_TOKEN (xoxb) + SLACK_APP_TOKEN (xapp) in .env.

PAYUP_LIVE=0 python -m bot.app     # dry-run: drafts but never sends

Flip PAYUP_LIVE=1 when the dry-run batch looks right. For always-on hosting, see the Fly.io guide further down.

Config

Copy .env.example to .env and fill it in. The knobs you will actually touch:

Variable What it does Default
PAYUP_SOURCE quickbooks or wave quickbooks
PAYUP_LIVE 1 to send for real, 0 for dry-run 0
PAYUP_MIN_GAP_DAYS never re-chase the same invoice within this many days 7
PAYUP_TEMPLATES_CONFIG path to your custom reminder templates config/templates.yml

Edit the reminder wording in your own voice

From Slack, say @PayUp edit template. PayUp asks which reminder (gentle, firm, or final), shows you the current copy, and you paste the new version in your own words. It keeps the {placeholders} working, checks it against the guardrails, and applies it on the next show overdue. Nothing is sent, and you can say "cancel" anytime.

Want all three rewritten to sound like you in one go? Run /payup-edit-template in Claude Code: describe the tone you want (or paste a message you have already written) and PayUp rewrites the gentle/firm/final copy, then checks it against the guardrails before saving. Prefer to hand-edit? Change the templates in config/templates.yml directly, then run:

python -m payup.cli validate-templates

to confirm your copy still passes (subject keeps {invoice_number}, no collections language, no em dashes) before the bot loads it. The guardrails apply to your custom copy either way (see below).

How it compares

PayUp Chaser / Upflow Bill.com QuickBooks reminders Manual
You approve every send
Never moves money (read-only) partial partial
Cost ~$0 (MIT) $$/mo $$/mo bundled free
Runs in your stack (Slack + Gmail)
No new database
Anti-spam gap + dedupe + resolve-on-paid partial partial
Save drafts you edit before sending
Collections language blocked by design n/a n/a

PayUp wins every control-and-ownership row and deliberately concedes the "fully automated, do it all for me" rows. That trade is the point.

Guardrails against spamming your clients

These are enforced by tests, not just promised in docs:

  • Human-in-the-loop. Nothing sends without an explicit send command. The daily timer only posts the batch.
  • Never moves money. The engine only reads from the accounting source. There are no payment, transfer, or refund code paths.
  • No-nag gap. An invoice chased in the last min_gap_days (default 7) is held out of the batch. show overdue lists those held invoices in a separate "already nudged" section, with the date you last sent each one, so the ready-to-chase list and the wait-it-out list are never confused.
  • Dedupe from your Sent mail. PayUp counts prior reminders by searching your Gmail Sent for the invoice number, so it cannot double-count or re-nag, even across restarts.
  • Resolve-on-paid. A paid invoice drops out of the overdue query automatically. No manual cleanup.
  • Final tier never threatens. Legal and collections language is blacklisted and blocked at render time, including in your custom templates.

What it will never do

No payment rails. No moving money. No collections or legal escalation. No sending on a schedule you cannot see. The "final" tier is firm but never a threat. PayUp chases; it never pays, and it never sends without you.

Architecture

State-light by design: there is no database. Your accounting tool and your Gmail Sent history are the source of truth, so PayUp has nothing to keep in sync.

QuickBooks or Wave, read-only, into PayUp, into Slack approval, into Gmail send or draft

The invoice source is pluggable: every connector returns the same Invoice shape, so adding Xero or Stripe is one new connector file, not a new bot. See CLAUDE.md for the contributor contract and the full invariant list.

Deploy (always-on)

Socket Mode means no public URL and no inbound ports, so any always-on host works.

fly launch --no-deploy
fly secrets set QBO_ACCESS_TOKEN=... QBO_REALM_ID=... QBO_REFRESH_TOKEN=... \
                QBO_CLIENT_ID=... QBO_CLIENT_SECRET=... \
                SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... PAYUP_LIVE=1 \
                GMAIL_TOKEN_JSON="$(cat config/token.json)"
fly deploy

Your Gmail token lives in config/token.json, which is deliberately not baked into the image (it is gitignored and dockerignored). Pass its contents as the GMAIL_TOKEN_JSON secret: PayUp loads Gmail credentials from config/token.json when the file exists (local runs) and from GMAIL_TOKEN_JSON otherwise (cloud). Run /payup-setup first so there is a config/token.json to copy from.

Railway, Render, a small VPS, or any machine that stays on works the same way. On a host where you can place config/token.json on disk, you can skip GMAIL_TOKEN_JSON.

FAQ

Does PayUp send invoice reminders automatically without approval?

PayUp never sends a reminder without explicit human approval in Slack. Every daily batch is posted for review, and nothing reaches a client until you reply with a command like send all or send 1 and 3. The scheduler only posts the batch; the send step always waits for a human.

How is PayUp different from Bill.com?

PayUp is a human-in-the-loop alternative to Bill.com-style dunning. It drafts the chase and lets you approve each send, using tools you already pay for (QuickBooks, Gmail, Slack), while Bill.com auto-fires reminders and layers in payment processing. PayUp is read-only on accounting data and has no payment rails by design.

What accounting platforms does PayUp support?

PayUp defaults to QuickBooks Online and also supports Wave for US and Canada businesses via PAYUP_SOURCE=wave. Both connectors expose the same invoice model, so switching sources is a config change, and adding a new provider like Xero is one connector file.

How does PayUp avoid nagging clients repeatedly?

PayUp searches your Gmail Sent mail keyed on the invoice number to count prior reminders and enforce a configurable no-nag gap, and it automatically drops any invoice the accounting source marks as paid. Chasing resolves itself with no manual removal step.

Is PayUp safe to use with real client data?

PayUp's guardrails are enforced by tests: no money-movement code paths exist, the final reminder tier is blocked from legal or collections language, and no secrets or PII are committed to the repo. It is MIT-licensed and the full source is public.

What if PayUp stops responding in Slack?

PayUp goes quiet only when it cannot reach your accounting source, almost always an expired access token. It replies in Slack with a clear "could not reach your accounting source" message rather than failing silently. Refresh your QuickBooks or Wave token (re-run /payup-setup, or re-mint from the OAuth Playground) and try show overdue again.

Development

pip install -e '.[dev]'
ruff check engine bot
pytest          # fully mocked, runs in well under 2 seconds

Architecture and the non-negotiable invariants are documented in CLAUDE.md and AGENTS.md.

License

MIT. See LICENSE.

Contributing

Issues and pull requests welcome. New behavior ships with tests, and the guardrail suite must stay green.

About

Open-source Slack bot that chases overdue invoices: reads QuickBooks or Wave, drafts gentle-to-firm reminders, and sends via Gmail only after you approve. No database, no payment rails, no auto-send. A human-in-the-loop alternative to Bill.com-style dunning for founders, freelancers, and bookkeepers. MIT.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors