← back to Filemaker Mcp

lib/sync-core.js

159 lines

// Shopify order -> FileMaker Clients sync core.
// Maps a Shopify order's customer to a FileMaker Clients record, but only
// creates one when NO similar client already exists. Dedup is checked in strict
// priority order — EMAIL first, then COMPANY name, then CLIENT (contact) name —
// per Steve's rule (2026-07-16). Company = business name, Name = contact person.
import * as fm from '../src/fm-client.js';

const LAYOUT = 'wALLPAPER oPTIONS'; // Clients layout exposing the client header fields
const digits = (s) => (s || '').toString().replace(/\D/g, '');
const last10 = (s) => digits(s).slice(-10);

export function mapOrder(o) {
  const c = o.customer || {};
  const s = o.shipping_address || o.billing_address || {};
  const fullName = ((c.first_name || '') + ' ' + (c.last_name || '')).trim();
  return {
    orderNumber: o.order_number,
    orderId: o.id,
    fields: {
      // FileMaker CLIENT record: Company = the BUSINESS name, Name = the CONTACT
      // person. Previously these were reversed (fullName went into Company,
      // company into Name), which put e.g. "Denny Wilson" in Company and
      // "Wilson Construction" in Name. Fixed 2026-07-16 (Steve flagged).
      // Fall back to the person's name for Company only when there is no
      // business name (individual buyer), preserving the old behavior there.
      'Company': s.company || fullName || '',
      'Name': fullName || '',
      'Address': s.address1 || '',
      'City': s.city || '',
      'State': s.province_code || '',
      'Zip': s.zip || '',
      'Phone': s.phone || o.phone || c.phone || '',
      'Email address': o.email || c.email || '',
    },
  };
}

// Returns an array of matching existing clients (empty = safe to create).
//
// Steve's dedup rule (2026-07-16): ALWAYS check in priority order —
//   1) Email address   2) Company name   3) Client (contact) name
// We check tier by tier and return the FIRST tier that produces a match, so a
// client already on file is never entered twice. Phone rides with the email
// tier as an equally-strong identity signal. Matching the CORRECT fields is
// essential: the old code searched Company against the person's name (because
// the field mapping was reversed), which silently missed real dupes and let
// second records get created.
export async function findDuplicates({ fields }) {
  const email = fields['Email address'];
  const phone10 = last10(fields['Phone']);
  const company = fields['Company']; // business name
  const person = fields['Name'];     // contact person

  const tiers = [
    { queries: [
        ...(email ? [{ 'Email address': '==' + email }] : []),
        ...(phone10 ? [{ 'Phone': '*' + phone10 + '*' }] : []), // contains last-10
      ] },
    { queries: company ? [{ 'Company': '==' + company }] : [] }, // exact company
    { queries: person ? [{ 'Name': '==' + person }] : [] },      // exact contact name
  ];

  for (const tier of tiers) {
    const hits = new Map(); // recordId -> {acct, company, why[]}
    for (const q of tier.queries) {
      let recs = [];
      try { recs = (await fm.findRecords('Clients', LAYOUT, q, { limit: 10 })).records; }
      catch (e) {
        // 401 = "no records match" (a genuine empty result) → keep looking.
        // ANY other error means the dedup could not run reliably; surface it
        // rather than silently returning no-match (which would double-enter).
        if (String(e.fmCode) === '401') continue;
        throw new Error(`dedup find failed (${Object.keys(q)[0]}): ${e.message}`);
      }
      const why = Object.keys(q)[0];
      for (const r of recs) {
        // confirm phone match by normalized last-10 to avoid wildcard false positives
        if (why === 'Phone' && last10(r.fieldData['Phone']) !== phone10) continue;
        const entry = hits.get(r.recordId) || { recordId: r.recordId, acct: r.fieldData['Account Number'], company: r.fieldData['Company'], why: [] };
        if (!entry.why.includes(why)) entry.why.push(why);
        hits.set(r.recordId, entry);
      }
    }
    if (hits.size) return [...hits.values()]; // first tier with a match wins
  }
  return [];
}

// Process one order end-to-end: ensure the client exists (create if new, dedup
// by email/phone/name otherwise), create the FileMaker invoice, then post a
// new-order alert to Slack. Every order gets an invoice + Slack ping regardless
// of whether the client was new or already on file.
export async function syncOrder(order, { commit = true } = {}) {
  const mapped = mapOrder(order);
  if (!mapped.fields['Email address'] && !mapped.fields['Phone'] && !mapped.fields['Company']) {
    return { orderNumber: mapped.orderNumber, action: 'skipped', reason: 'no identifying fields' };
  }

  // 1) ensure client + resolve account number
  let clientAction, accountNumber;
  const dupes = await findDuplicates(mapped);
  if (dupes.length) {
    accountNumber = dupes[0].acct;
    clientAction = 'existing';
    // refresh the existing client's contact info from this order BEFORE invoicing
    if (commit && dupes[0].recordId) {
      const refresh = {};
      for (const k of ['Address', 'City', 'State', 'Zip', 'Phone', 'Email address']) {
        if (mapped.fields[k]) refresh[k] = mapped.fields[k];
      }
      try { await fm.updateRecord('Clients', LAYOUT, dupes[0].recordId, refresh, { dryRun: false }); clientAction = 'updated'; } catch { /* best-effort refresh — do not block invoicing */ }
    }
  } else if (commit) {
    const res = await fm.createRecord('Clients', LAYOUT, mapped.fields, { dryRun: false });
    try { accountNumber = (await fm.getRecord('Clients', LAYOUT, res.recordId))?.fieldData?.['Account Number']; } catch {}
    clientAction = 'created';
  } else {
    clientAction = 'would-create';
  }

  // 2) invoice — HARD RULES (Steve 2026-07-03):
  //   (a) CLIENT-ACCOUNT-FIRST: no invoice may be created without a client account
  //       first. Only a client account number can BEGIN an invoice. If we could not
  //       resolve/create an account number above, DO NOT create the invoice.
  //   (b) NO SECOND INVOICE: the per-SKU tracking records (createSkuRecords) that
  //       duplicated the SKU number are removed — not needed.
  if (commit && !String(accountNumber || '').trim()) {
    return {
      orderNumber: mapped.orderNumber,
      action: 'skipped',
      reason: 'no client account number — invoice not created (client-account-first rule)',
      client: clientAction,
      company: mapped.fields['Company'],
      email: mapped.fields['Email address'],
    };
  }
  const { createInvoiceForOrder } = await import('./invoice.js');
  const inv = await createInvoiceForOrder(order, accountNumber, { commit });
  const skuRecs = null; // second invoice (per-SKU tracking records) intentionally removed

  // 3) Slack alert (only on real commit)
  let slack = null;
  if (commit) {
    const { postOrderToSlack } = await import('./notify.js');
    try { slack = await postOrderToSlack({ order, invoiceNumber: inv.invoiceNumber, total: inv.grandTotal, clientName: mapped.fields['Company'] }); }
    catch (e) { slack = { ok: false, error: e.message }; }
  }

  return {
    orderNumber: mapped.orderNumber,
    action: commit ? 'processed' : 'would-process',
    client: clientAction, accountNumber, company: mapped.fields['Company'], email: mapped.fields['Email address'],
    invoiceNumber: inv.invoiceNumber, invoiceTotal: inv.grandTotal,
    skuRecords: skuRecs?.created?.length ?? 0,
    slack: slack?.ok ? 'posted' : (slack?.error || 'n/a'),
    matchedOn: dupes.length ? [...new Set(dupes.flatMap((d) => d.why))] : undefined,
  };
}