← back to Filemaker Mcp

scripts/new-invoice-monitor.mjs

89 lines

// Per-sale Slack ping → #new-order, sourced from the FileMaker invoice DB.
// Polls today's invoices; posts one card per NEW invoice (number > last seen).
// First run seeds the cursor without posting a backlog.
//
//   node scripts/new-invoice-monitor.mjs            # poll + post new invoices
//   node scripts/new-invoice-monitor.mjs --dry-run  # print what it WOULD post

import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { findRecords } from '../src/fm-client.js';
import { loadEnv, ptYMD, num, money } from '../lib/fm-script-helpers.js';

loadEnv(import.meta.url);

const __dir = dirname(fileURLToPath(import.meta.url));

const DB = 'invoice';
const LAYOUT = 'GRAND TOTAL SALES';
const CHANNEL = process.env.SALES_SUMMARY_CHANNEL || 'C06D9C2PG1K'; // #new-order
const DRY_RUN = process.argv.includes('--dry-run');
const CURSOR = join(__dir, '..', 'data', '.last-invoice');
const EXCLUDE = new Set(['999999']);

async function post(text) {
  const token = process.env.SLACK_BOT_TOKEN;
  if (!token) throw new Error('SLACK_BOT_TOKEN not set.');
  const res = await fetch('https://slack.com/api/chat.postMessage', {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json; charset=utf-8' },
    body: JSON.stringify({ channel: CHANNEL, text, unfurl_links: false }),
  });
  const j = await res.json();
  if (!j.ok) throw new Error(`Slack post failed: ${j.error}`);
  return j.ts;
}

async function main() {
  const { y, m, d } = ptYMD(new Date());
  // Poll today's invoices (sales come in same-day); sort by invoice number ascending.
  const { records } = await findRecords(
    DB, LAYOUT,
    [{ Date: `${m}/${d}/${y}` }],
    { limit: 500, sort: [{ fieldName: 'Invoice', sortOrder: 'ascend' }] },
  );

  // Numeric invoices only, excluding test records.
  const rows = records
    .map((r) => r.fieldData || {})
    .filter((f) => /^\d+$/.test(String(f['Invoice'] || '').trim()) && !EXCLUDE.has(String(f['Invoice']).trim()))
    .map((f) => ({ n: parseInt(f['Invoice'], 10), f }))
    .sort((a, b) => a.n - b.n);

  const lastSeen = existsSync(CURSOR) ? parseInt(readFileSync(CURSOR, 'utf8').trim(), 10) || 0 : null;

  if (lastSeen === null) {
    const max = rows.length ? rows[rows.length - 1].n : 0;
    if (!DRY_RUN) writeFileSync(CURSOR, String(max));
    console.log(`[seed] no cursor — set last-invoice=${max} (${rows.length} today), no posts.`);
    return;
  }

  const fresh = rows.filter((r) => r.n > lastSeen && num(r.f['GRAND TOTAL 2']) !== 0); // skip empty shells
  if (!fresh.length) { console.log(`No new invoices (last=${lastSeen}, ${rows.length} today).`); return; }

  let maxPosted = lastSeen;
  for (const { n, f } of fresh) {
    const client = String(f['Sold Company Name Lookup'] || 'Customer').trimEnd();
    const item = String(f['DETAIL 1(1)'] || f['DETAIL 1'] || '').trimEnd().slice(0, 80);
    const rep = f['SALES PERSON'] ? ` · ${f['SALES PERSON']}` : '';
    // Booked sale = payment received; otherwise it's an open quote (don't call it a sale).
    const paid = String(f['PAID ON ACCOUNT'] ?? '').trim();
    const booked = paid !== '' && num(paid) !== 0;
    // Card total: for a booked sale use PAID ON ACCOUNT (the true paid total — it
    // sums BOTH line rows; GRAND TOTAL 2 is a line-1-only calc that undercounts a
    // 2-line order). Unpaid quotes fall back to GRAND TOTAL 2 (the estimate).
    const total = booked ? num(paid) : num(f['GRAND TOTAL 2']);
    const tag = !booked ? '📝 *New quote* (no payment yet)' : (total < 0 ? '↩️ *Refund*' : '🛎️ *New sale*');
    const text = `${tag} — ${client} · Invoice #${n} · *${money(total)}*${rep}${item ? `\n• ${item}` : ''}`;
    if (DRY_RUN) console.log('[would post] ' + text.replace(/\n/g, '  '));
    else await post(text);
    if (n > maxPosted) maxPosted = n;
  }
  if (!DRY_RUN) writeFileSync(CURSOR, String(maxPosted));
  console.log(`${DRY_RUN ? '[dry] ' : ''}posted ${fresh.length} new invoice(s); cursor → ${maxPosted}.`);
}

main().catch((e) => { console.error('new-invoice-monitor error:', e.message); process.exit(1); });