// @ts-nocheck — converted to .cts (runs via Node type-stripping); rich typing deferred (large, dynamic subscription-catalog matcher). TODO: type incrementally.
'use strict';

const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService.cts');
const { localDateString, todayLocal } = require('../utils/dates.mts');
const { roundMoney, sumMoney, mulMoney, fromCents } = require('../utils/money.mts');

const SUBSCRIPTION_TYPES = [
  'streaming',
  'software',
  'cloud',
  'music',
  'news',
  'fitness',
  'gaming',
  'utilities',
  'insurance',
  'food',
  'education',
  'shopping',
  'security',
  'other',
];

const MONTHLY_FACTORS = {
  weekly: 52 / 12,
  biweekly: 26 / 12,
  monthly: 1,
  quarterly: 1 / 3,
  annual: 1 / 12,
  annually: 1 / 12,
  irregular: 1,
};

// Transactions that are clearly not subscriptions — skip before grouping
const SKIP_MERCHANT_RE =
  /\b(atm|withdrawal|transfer|deposit|zelle|venmo|wire|refund|rebate|interest charge)\b/;

// Fallback keyword list used when catalog lookup finds no match
const TYPE_KEYWORDS = [
  [
    'streaming',
    [
      'netflix',
      'hulu',
      'disney',
      'max',
      'paramount',
      'peacock',
      'youtube tv',
      'sling',
      'espn',
      'fubo',
      'starz',
      'crunchyroll',
      'dazn',
    ],
  ],
  [
    'music',
    ['spotify', 'apple music', 'tidal', 'pandora', 'siriusxm', 'soundcloud', 'deezer', 'iheart'],
  ],
  [
    'software',
    [
      'adobe',
      'microsoft',
      'github',
      'notion',
      'figma',
      'canva',
      'openai',
      'chatgpt',
      'grammarly',
      'zoom',
      'slack',
      'cursor',
      'ynab',
    ],
  ],
  ['cloud', ['dropbox', 'icloud', 'google one', 'google storage', 'backblaze', 'box storage']],
  [
    'news',
    [
      'nyt',
      'new york times',
      'economist',
      'athletic',
      'washington post',
      'wsj',
      'bloomberg',
      'substack',
      'patreon',
      'medium',
    ],
  ],
  [
    'fitness',
    [
      'peloton',
      'planet fitness',
      'gym',
      'fitbit',
      'strava',
      'headspace',
      'calm',
      'noom',
      'classpass',
      'whoop',
    ],
  ],
  [
    'gaming',
    ['xbox', 'playstation', 'steam', 'nintendo', 'roblox', 'discord nitro', 'ea play', 'ubisoft'],
  ],
  [
    'utilities',
    ['verizon', 'at t', 'att', 'comcast', 'xfinity', 'spectrum', 'tmobile', 't mobile'],
  ],
  ['insurance', ['insurance', 'geico', 'progressive', 'state farm', 'allstate']],
  [
    'food',
    [
      'hellofresh',
      'blue apron',
      'doordash',
      'instacart',
      'uber eats',
      'grubhub',
      'factor',
      'hungryroot',
    ],
  ],
  [
    'education',
    [
      'duolingo',
      'masterclass',
      'coursera',
      'skillshare',
      'audible',
      'kindle unlimited',
      'blinkist',
    ],
  ],
  ['shopping', ['amazon prime', 'walmart plus', 'costco', 'target circle', 'chewy']],
  ['security', ['nordvpn', 'expressvpn', '1password', 'dashlane', 'norton', 'mcafee', 'surfshark']],
];

const AMBIGUOUS_CATALOG_TERMS = new Map([
  ['amazon', 'Amazon charges may be retail orders, Prime, media, Kindle, or subscriptions.'],
  ['amzn', 'Amazon charges may be retail orders, Prime, media, Kindle, or subscriptions.'],
  ['apple', 'Apple charges may be app purchases, iCloud, Apple One, media, or hardware.'],
  ['google', 'Google charges may be storage, YouTube, app purchases, ads, or domains.'],
  ['microsoft', 'Microsoft charges may be software, Xbox, cloud, or marketplace purchases.'],
  ['walmart', 'Walmart charges may be retail orders, delivery, Walmart+, or pharmacy purchases.'],
  ['target', 'Target charges may be retail orders, Circle, delivery, or pharmacy purchases.'],
  ['disney', 'Disney charges may be streaming, parks, retail, or bundle charges.'],
  ['max', 'Max is a short descriptor and can be confused with unrelated merchant text.'],
]);

// ── Catalog ───────────────────────────────────────────────────────────────────

function loadCatalog(db, userId) {
  try {
    const catalog = db
      .prepare(
        'SELECT id, rank, name, category, subscription_type, domain, website, starting_monthly_usd, starting_annual_usd, price_notes FROM subscription_catalog ORDER BY rank ASC',
      )
      .all();
    if (!catalog.length) return [];

    // Attach bank descriptors and slang terms if the table exists (v0.95+)
    let descriptors = [];
    try {
      descriptors = db
        .prepare(
          'SELECT catalog_id, descriptor, descriptor_type FROM subscription_catalog_descriptors',
        )
        .all();
    } catch {
      /* pre-v0.95 */
    }

    // Merge user-specific custom descriptors (v0.96+)
    if (userId) {
      try {
        const userDescs = db
          .prepare('SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?')
          .all(userId);
        for (const d of userDescs) {
          descriptors.push({
            catalog_id: d.catalog_id,
            descriptor: d.descriptor,
            descriptor_type: 'user_bank',
          });
        }
      } catch {
        /* pre-v0.96 */
      }
    }

    const byId = new Map(catalog.map((c) => [c.id, { ...c, bankDescs: [], slangTerms: [] }]));
    for (const d of descriptors) {
      const entry = byId.get(d.catalog_id);
      if (!entry) continue;
      const normalized = normalizeMerchant(d.descriptor);
      if (d.descriptor_type === 'bank' || d.descriptor_type === 'user_bank') {
        entry.bankDescs.push({ value: normalized, source: d.descriptor_type });
      } else entry.slangTerms.push(normalized);
    }
    return [...byId.values()];
  } catch {
    return [];
  }
}

// Build a normalized-name → subscription_type map from the full catalog so
// inferType can use all 290 known services, not just the hardcoded keyword list.
function buildCatalogTypeMap(catalog) {
  const map = new Map();
  for (const entry of catalog) {
    if (!entry.subscription_type || entry.subscription_type === 'other') continue;
    const key = normalizeCatalogName(entry.name);
    if (key.length >= 3 && !map.has(key)) map.set(key, entry.subscription_type);
  }
  return map;
}

function compactCatalogKey(value) {
  return normalizeCatalogName(value).replace(/\s+/g, '');
}

function hostFromUrl(value) {
  if (!value) return '';
  try {
    return new URL(String(value).startsWith('http') ? String(value) : `https://${value}`).hostname;
  } catch {
    return String(value || '');
  }
}

function catalogDomainKeys(entry) {
  const keys = new Set();
  const candidates = [entry.domain, hostFromUrl(entry.website)].filter(Boolean);
  for (const candidate of candidates) {
    const host = String(candidate)
      .toLowerCase()
      .replace(/^www\./, '')
      .replace(/\/.*$/, '');
    const labels = host.split('.').filter(Boolean);
    if (labels.length >= 2) {
      keys.add(labels.join(' '));
      keys.add(labels.slice(-2).join(' '));
      if (labels[0].length >= 5) keys.add(labels[0]);
    }
  }
  return [...keys].filter((key) => key.length >= 4);
}

function normalizeCatalogName(value) {
  return String(value || '')
    .toLowerCase()
    .replace(/\+/g, ' plus ') // "Walmart+" → "walmart plus" so it only matches "walmart plus" transactions
    .replace(/[^a-z0-9]+/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
}

// Given a normalized merchant string, find the best matching catalog entry and
// the kind of evidence that won. Identity confidence is handled separately so
// weak name/domain matches do not look as trustworthy as statement descriptors.
function lookupCatalogMatch(catalog, merchantText) {
  if (!catalog.length || !merchantText) return null;
  let best = null;
  let bestScore = 0;
  let bestMatch = null;
  const normalized = normalizeMerchant(merchantText);
  const compact = compactCatalogKey(merchantText);

  for (const entry of catalog) {
    // 1. Bank statement descriptors — normalized against our own normalizeMerchant so
    //    "NETFLIX.COM LOS GATOS CA" and raw payee "netflix" both reduce to comparable forms.
    for (const desc of entry.bankDescs || []) {
      const value = desc.value || desc;
      if (value.length >= 4 && (normalized.includes(value) || value.includes(normalized))) {
        const containment = normalized.includes(value)
          ? 'transaction_contains_descriptor'
          : 'descriptor_contains_transaction';
        const score = (desc.source === 'user_bank' ? 2200 : 2000) + value.length;
        if (score > bestScore) {
          best = entry;
          bestScore = score;
          bestMatch = {
            type: desc.source === 'user_bank' ? 'user_descriptor' : 'bank_descriptor',
            label: desc.source === 'user_bank' ? 'custom bank descriptor' : 'known bank descriptor',
            descriptor: value,
            containment,
          };
        }
      }
    }

    // 2. Name / domain / slang match.
    const nameKey = normalizeCatalogName(entry.name);
    const nameCompact = compactCatalogKey(entry.name);
    const nameScore = 1000 + nameKey.length;
    if (
      nameKey.length >= 3 &&
      (normalized.includes(nameKey) ||
        (nameCompact.length >= 5 && compact.includes(nameCompact))) &&
      nameScore > bestScore
    ) {
      best = entry;
      bestScore = nameScore;
      bestMatch = { type: 'name', label: 'service name', descriptor: nameKey };
    }
    for (const domainKey of catalogDomainKeys(entry)) {
      const domainCompact = domainKey.replace(/\s+/g, '');
      const domainScore = 500 + domainKey.length;
      if (
        (normalized.includes(domainKey) ||
          (domainCompact.length >= 5 && compact.includes(domainCompact))) &&
        domainScore > bestScore
      ) {
        best = entry;
        bestScore = domainScore;
        bestMatch = { type: 'domain', label: 'service domain', descriptor: domainKey };
      }
    }
    for (const slang of entry.slangTerms || []) {
      const slangCompact = slang.replace(/\s+/g, '');
      const slangScore = 300 + slang.length;
      if (
        slang.length >= 4 &&
        (normalized.includes(slang) ||
          (slangCompact.length >= 5 && compact.includes(slangCompact))) &&
        slangScore > bestScore
      ) {
        best = entry;
        bestScore = slangScore;
        bestMatch = { type: 'slang', label: 'known alternate name', descriptor: slang };
      }
    }
  }
  return best
    ? { entry: best, match: bestMatch || { type: 'unknown', label: 'catalog match' } }
    : null;
}

function lookupCatalog(catalog, merchantText) {
  return lookupCatalogMatch(catalog, merchantText)?.entry || null;
}

// ── Helpers ───────────────────────────────────────────────────────────────────

function normalizeMerchant(value) {
  return String(value || '')
    .toLowerCase()
    .replace(/\+/g, ' plus ') // preserve "+" so "WALMART+" matches catalog "Walmart+" → "walmart plus"
    .replace(/&/g, '') // "&" joins words — "AT&T" → "att", not "at t"
    .replace(/['’]/g, '') // collapse apostrophes — "Sam's Club" → "sams club", "McDonald's" → "mcdonalds"
    .replace(/[^a-z0-9\s]/g, ' ')
    .replace(/\b(pos|debit|card|payment|purchase|recurring|online|inc|llc|co|www)\b/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
}

function titleCase(value) {
  return String(value || 'Subscription')
    .split(/\s+/)
    .filter(Boolean)
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ');
}

function inferType(merchantText, catalogEntry, catalogTypeMap = null) {
  if (catalogEntry?.subscription_type) return catalogEntry.subscription_type;
  const haystack = normalizeMerchant(merchantText);
  if (catalogTypeMap) {
    for (const [nameKey, type] of catalogTypeMap.entries()) {
      if (haystack.includes(nameKey)) return type;
    }
  }
  for (const [type, words] of TYPE_KEYWORDS) {
    if (words.some((word) => haystack.includes(word))) return type;
  }
  return 'other';
}

function catalogMatchPayload(catalogEntry) {
  return catalogEntry
    ? {
        id: catalogEntry.id,
        name: catalogEntry.name,
        category: catalogEntry.category,
        subscription_type: catalogEntry.subscription_type || 'other',
        website: catalogEntry.website || null,
        starting_monthly_usd: catalogEntry.starting_monthly_usd ?? null,
        starting_annual_usd: catalogEntry.starting_annual_usd ?? null,
        price_notes: catalogEntry.price_notes || null,
      }
    : null;
}

function identityEvidence(match) {
  const type = match?.type || 'unknown';
  const table = {
    user_descriptor: { score: 84, label: 'Matched your custom bank descriptor' },
    bank_descriptor: { score: 82, label: 'Matched a known bank descriptor' },
    name: { score: 74, label: 'Matched the service name' },
    domain: { score: 70, label: 'Matched the service domain' },
    slang: { score: 66, label: 'Matched a known alternate name' },
    unknown: { score: 60, label: 'Matched the service catalog' },
  };
  return {
    type,
    descriptor: match?.descriptor || null,
    containment: match?.containment || null,
    ...(table[type] || table.unknown),
  };
}

function priceClose(amount, expected) {
  if (!amount || !expected) return null;
  const delta = Math.abs(amount - expected);
  const pct = delta / expected;
  return { delta, pct };
}

function amountEvidence(amount, cycleType, catalogEntry) {
  const monthly = Number(catalogEntry?.starting_monthly_usd || 0);
  const annual = Number(catalogEntry?.starting_annual_usd || 0) || (monthly ? monthly * 12 : 0);
  if (!amount || (!monthly && !annual)) {
    return { score: 0, label: null, match: 'unknown', inferred_cycle_type: null };
  }

  const monthlyClose = priceClose(amount, monthly);
  const annualClose = priceClose(amount, annual);
  const annualLike = annual && annualClose && (annualClose.delta <= 2 || annualClose.pct <= 0.12);
  const monthlyLike =
    monthly && monthlyClose && (monthlyClose.delta <= 1 || monthlyClose.pct <= 0.08);
  const plausibleMonthly =
    monthly && amount >= monthly * 0.7 && amount <= Math.max(monthly * 4, monthly + 25);
  const plausibleAnnual = annual && amount >= annual * 0.7 && amount <= annual * 1.35;

  if (
    cycleType === 'annual' ||
    annualLike ||
    (!monthlyLike && plausibleAnnual && amount >= monthly * 8)
  ) {
    if (annualLike) {
      return {
        score: 13,
        label: `Amount aligns with catalog annual pricing near $${annual.toFixed(2)}`,
        match: 'annual_close',
        inferred_cycle_type: 'annual',
      };
    }
    if (plausibleAnnual) {
      return {
        score: 9,
        label: `Amount is plausible for annual pricing near $${annual.toFixed(2)}`,
        match: 'annual_plausible',
        inferred_cycle_type: 'annual',
      };
    }
  }

  if (monthlyLike) {
    return {
      score: 12,
      label: `Amount aligns with catalog pricing from $${monthly.toFixed(2)}/mo`,
      match: 'monthly_close',
      inferred_cycle_type: 'monthly',
    };
  }
  if (plausibleMonthly) {
    return {
      score: 8,
      label: `Amount is plausible for catalog pricing from $${monthly.toFixed(2)}/mo`,
      match: 'monthly_plausible',
      inferred_cycle_type: null,
    };
  }
  return {
    score: -10,
    label: `Amount is unusual for catalog pricing from ${monthly ? `$${monthly.toFixed(2)}/mo` : `$${annual.toFixed(2)}/yr`}`,
    match: 'unusual',
    inferred_cycle_type: null,
  };
}

function cadenceEvidence(sorted, cycleType, avgGap, maxDelta, averageAmount) {
  if (sorted.length < 2) {
    return {
      score: 0,
      label: 'One matching bank transaction',
      stable: true,
      recurring: false,
    };
  }
  const stable = maxDelta <= Math.max(1, averageAmount * 0.08);
  const score =
    8 + Math.min(15, sorted.length * 3) + (stable ? 8 : 0) + (cycleType !== 'weekly' ? 8 : 0);
  return {
    score,
    label: `${sorted.length} similar charges about ${Math.round(avgGap)} days apart`,
    stable,
    recurring: true,
  };
}

function catalogTextForAmbiguity(merchant, catalogEntry, identityInfo) {
  return normalizeMerchant(
    [
      merchant,
      catalogEntry?.name,
      catalogEntry?.domain,
      catalogEntry?.website,
      identityInfo?.descriptor,
    ]
      .filter(Boolean)
      .join(' '),
  );
}

function ambiguityEvidence({ merchant, catalogEntry, identityInfo, amountInfo, cadenceInfo }) {
  const identityType = identityInfo?.type || 'unknown';
  const compactMerchant = normalizeMerchant(merchant).replace(/\s+/g, '');
  const descriptorContainsShortTransaction =
    identityInfo?.containment === 'descriptor_contains_transaction' &&
    compactMerchant.length > 0 &&
    compactMerchant.length <= 4;
  const haystack = catalogTextForAmbiguity(merchant, catalogEntry, identityInfo);
  const tokens = new Set(haystack.split(/\s+/).filter(Boolean));
  const hit = [...AMBIGUOUS_CATALOG_TERMS.entries()].find(([term]) => tokens.has(term));
  const descriptorTokens = new Set(
    normalizeMerchant(identityInfo?.descriptor).split(/\s+/).filter(Boolean),
  );
  const genericBroadBankDescriptor =
    identityType === 'bank_descriptor' &&
    !!hit &&
    ['bill', 'billing', 'payment', 'payments', 'store', 'mktplace', 'marketplace'].some((term) =>
      descriptorTokens.has(term),
    );
  const weakIdentity =
    !['user_descriptor', 'bank_descriptor'].includes(identityType) ||
    descriptorContainsShortTransaction ||
    genericBroadBankDescriptor;
  if (!weakIdentity) {
    return { ambiguous: false, penalty: 0, label: null, reasons: [] };
  }

  const compactDescriptor = String(identityInfo?.descriptor || '').replace(/\s+/g, '');
  const shortDescriptor = compactDescriptor.length > 0 && compactDescriptor.length <= 4;

  if (!hit && !shortDescriptor) {
    return { ambiguous: false, penalty: 0, label: null, reasons: [] };
  }

  const recurringStrong = !!cadenceInfo?.recurring && amountInfo?.score >= 8;
  const penalty = recurringStrong ? 6 : 16;
  const reasons = [];
  if (hit) reasons.push(hit[1]);
  if (genericBroadBankDescriptor)
    reasons.push('The bank descriptor is a generic billing label for a broad merchant.');
  if (shortDescriptor)
    reasons.push('The matched service text is very short, so false positives are more likely.');
  if (!cadenceInfo?.recurring)
    reasons.push('Only one bank transaction supports this recommendation.');
  if (amountInfo?.match === 'unusual')
    reasons.push('The amount is outside the catalog price range.');

  return {
    ambiguous: true,
    penalty,
    label: 'Broad match - review before tracking',
    reasons: Array.from(new Set(reasons)),
  };
}

function feedbackEvidence({ feedback, merchant, catalogEntry }) {
  const catalogKey = catalogEntry?.id ? String(catalogEntry.id) : null;
  const merchantKey = normalizeMerchant(merchant);
  const rows = [
    ...(catalogKey ? feedback.byCatalog.get(catalogKey) || [] : []),
    ...(merchantKey ? feedback.byMerchant.get(merchantKey) || [] : []),
  ];
  if (!rows.length) return { score: 0, label: null, positive_count: 0, negative_count: 0 };

  let positive = 0;
  let negative = 0;
  for (const row of rows) {
    if (
      ['accept_track_new', 'link_existing_bill', 'catalog_relink', 'add_descriptor'].includes(
        row.action,
      )
    )
      positive++;
    if (['decline', 'catalog_unlink', 'delete_descriptor'].includes(row.action)) negative++;
  }

  const score = Math.max(-18, Math.min(8, positive * 3 - negative * 8));
  const label =
    score > 0
      ? 'Boosted by your past subscription matching choices'
      : score < 0
        ? 'Reduced by your past subscription matching choices'
        : null;
  return { score, label, positive_count: positive, negative_count: negative };
}

function scoreKnownServiceRecommendation({
  match,
  merchant,
  catalogEntry,
  amountInfo,
  cadenceInfo,
  feedbackInfo = null,
}) {
  const identity = identityEvidence(match);
  const ambiguity = ambiguityEvidence({
    merchant,
    catalogEntry,
    identityInfo: identity,
    amountInfo,
    cadenceInfo,
  });
  const confidence = Math.min(
    99,
    Math.max(
      0,
      identity.score +
        amountInfo.score +
        cadenceInfo.score -
        ambiguity.penalty +
        (feedbackInfo?.score || 0),
    ),
  );
  return { confidence, identity, ambiguity };
}

function monthlyEquivalent(amount, cycleType, billingCycle) {
  const key = String(cycleType || billingCycle || 'monthly').toLowerCase();
  const fallback =
    String(billingCycle || '').toLowerCase() === 'quarterly'
      ? 'quarterly'
      : String(billingCycle || '').toLowerCase() === 'annually'
        ? 'annual'
        : key;
  const factor = MONTHLY_FACTORS[key] ?? MONTHLY_FACTORS[fallback] ?? 1;
  return mulMoney(Number(amount || 0), factor);
}

function nextDueDate(bill, now = new Date()) {
  const dueDay = Math.min(Math.max(Number(bill.due_day) || 1, 1), 31);
  const cycle = String(bill.cycle_type || bill.billing_cycle || 'monthly').toLowerCase();
  let date = new Date(now.getFullYear(), now.getMonth(), dueDay);
  if (date < new Date(now.getFullYear(), now.getMonth(), now.getDate())) {
    date = new Date(now.getFullYear(), now.getMonth() + 1, dueDay);
  }
  if (cycle === 'quarterly' || cycle === 'annual') {
    const startMonth = Math.min(Math.max(Number(bill.cycle_day) || 1, 1), 12) - 1;
    const step = cycle === 'quarterly' ? 3 : 12;
    date = new Date(now.getFullYear(), startMonth, dueDay);
    while (date < new Date(now.getFullYear(), now.getMonth(), now.getDate())) {
      date = new Date(date.getFullYear(), date.getMonth() + step, dueDay);
    }
  }
  return localDateString(date);
}

function decorateSubscription(bill) {
  const expectedAmount = fromCents(bill.expected_amount);
  const monthly = monthlyEquivalent(expectedAmount, bill.cycle_type, bill.billing_cycle);
  return {
    ...bill,
    expected_amount: expectedAmount,
    current_balance: fromCents(bill.current_balance),
    minimum_payment: fromCents(bill.minimum_payment),
    is_subscription: !!bill.is_subscription,
    active: !!bill.active,
    monthly_equivalent: monthly,
    yearly_equivalent: mulMoney(monthly, 12),
    next_due_date: nextDueDate(bill),
    subscription_type:
      bill.subscription_type || inferType(`${bill.name} ${bill.category_name || ''}`, null),
  };
}

function getSubscriptions(db, userId) {
  return db
    .prepare(
      `
    SELECT b.*, b.catalog_id, c.name AS category_name,
      CASE WHEN EXISTS(
        SELECT 1 FROM bill_merchant_rules WHERE bill_id = b.id AND user_id = b.user_id
      ) THEN 1 ELSE 0 END AS has_merchant_rule,
      CASE WHEN EXISTS(
        SELECT 1 FROM transactions WHERE matched_bill_id = b.id AND match_status = 'matched'
      ) THEN 1 ELSE 0 END AS has_linked_transactions
    FROM bills b
    LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
    WHERE b.user_id = ?
      AND b.deleted_at IS NULL
      AND b.is_subscription = 1
    ORDER BY b.active DESC,
      CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END,
      b.sort_order ASC,
      b.due_day ASC,
      b.name COLLATE NOCASE ASC
  `,
    )
    .all(userId)
    .map(decorateSubscription);
}

function getSubscriptionSummary(subscriptions) {
  const active = subscriptions.filter((item) => item.active);
  const monthlyTotal = sumMoney(active, (item) => item.monthly_equivalent);
  const typeTotals = new Map();
  for (const item of active) {
    const type = item.subscription_type || 'other';
    typeTotals.set(type, (typeTotals.get(type) || 0) + Number(item.monthly_equivalent || 0));
  }
  const topType = [...typeTotals.entries()].sort((a, b) => b[1] - a[1])[0] || null;
  return {
    active_count: active.length,
    paused_count: subscriptions.length - active.length,
    monthly_total: roundMoney(monthlyTotal),
    yearly_total: mulMoney(monthlyTotal, 12),
    top_type: topType ? { type: topType[0], monthly_total: roundMoney(topType[1]) } : null,
  };
}

function existingBillNames(db, userId) {
  return db
    .prepare('SELECT name FROM bills WHERE user_id = ? AND deleted_at IS NULL')
    .all(userId)
    .map((row) => normalizeMerchant(row.name))
    .filter(Boolean);
}

function dollarsFromTransactionAmount(amount) {
  return roundMoney(Math.abs(Number(amount || 0)) / 100);
}

function recommendationAccountLabel(item) {
  const accountName = item.account_name || '';
  const orgName = item.account_org_name || '';
  if (accountName && orgName && accountName !== orgName) return `${orgName} · ${accountName}`;
  return accountName || orgName || item.data_source_name || '';
}

function loadRecommendationFeedback(db, userId) {
  const empty = { byCatalog: new Map(), byMerchant: new Map() };
  try {
    const rows = db
      .prepare(
        `
      SELECT catalog_id, merchant, action, confidence, descriptor, created_at
      FROM subscription_recommendation_feedback
      WHERE user_id = ?
      ORDER BY created_at DESC
      LIMIT 500
    `,
      )
      .all(userId);
    for (const row of rows) {
      if (row.catalog_id) {
        const key = String(row.catalog_id);
        if (!empty.byCatalog.has(key)) empty.byCatalog.set(key, []);
        empty.byCatalog.get(key).push(row);
      }
      const merchant = normalizeMerchant(row.merchant);
      if (merchant) {
        if (!empty.byMerchant.has(merchant)) empty.byMerchant.set(merchant, []);
        empty.byMerchant.get(merchant).push(row);
      }
    }
  } catch {
    /* pre-v0.97 */
  }
  return empty;
}

function recordSubscriptionFeedback(db, userId, payload = {}) {
  const action = String(payload.action || '').trim();
  if (!action) return;
  try {
    const catalogId = Number(payload.catalog_id);
    const billId = Number(payload.bill_id);
    const metadata = payload.metadata === undefined ? null : JSON.stringify(payload.metadata);
    db.prepare(
      `
      INSERT INTO subscription_recommendation_feedback
        (user_id, catalog_id, bill_id, merchant, action, confidence, descriptor, metadata_json)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?)
    `,
    ).run(
      userId,
      Number.isInteger(catalogId) && catalogId > 0 ? catalogId : null,
      Number.isInteger(billId) && billId > 0 ? billId : null,
      payload.merchant ? normalizeMerchant(payload.merchant) : null,
      action,
      Number.isInteger(Number(payload.confidence)) ? Number(payload.confidence) : null,
      payload.descriptor ? String(payload.descriptor).slice(0, 100) : null,
      metadata,
    );
  } catch {
    /* pre-v0.97 */
  }
}

function existingBillsForRecommendation(db, userId) {
  return db
    .prepare(
      `
    SELECT b.id, b.name, b.expected_amount, b.due_day, b.cycle_type, b.billing_cycle,
           b.is_subscription, b.active, b.catalog_id, c.name AS category_name,
           CASE WHEN EXISTS(
             SELECT 1 FROM bill_merchant_rules r
             WHERE r.bill_id = b.id AND r.user_id = b.user_id
           ) THEN 1 ELSE 0 END AS has_merchant_rule
    FROM bills b
    LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
    WHERE b.user_id = ?
      AND b.deleted_at IS NULL
  `,
    )
    .all(userId);
}

function dayDelta(a, b) {
  const left = Math.min(Math.max(Number(a) || 1, 1), 31);
  const right = Math.min(Math.max(Number(b) || 1, 1), 31);
  return Math.abs(left - right);
}

function existingBillMatch(existingBills, { merchant, catalogEntry, averageAmount, lastDate }) {
  const merchantKey = normalizeCatalogName(merchant);
  const catalogKey = normalizeCatalogName(catalogEntry?.name);
  const dueDay = Number(String(lastDate || '').slice(8, 10)) || 1;
  let best = null;

  for (const bill of existingBills) {
    const billKey = normalizeCatalogName(bill.name);
    if (!billKey) continue;
    const reasons = [];
    let score = 0;

    if (catalogEntry?.id && Number(bill.catalog_id) === Number(catalogEntry.id)) {
      score += 90;
      reasons.push('Bill is already linked to this catalog service');
    } else if (catalogKey && billKey === catalogKey) {
      score += 72;
      reasons.push('Bill name exactly matches the known service');
    } else if (merchantKey && billKey === merchantKey) {
      score += 66;
      reasons.push('Bill name exactly matches the bank merchant');
    } else if (catalogKey && (billKey.includes(catalogKey) || catalogKey.includes(billKey))) {
      score += 48;
      reasons.push('Bill name closely matches the known service');
    } else if (
      merchantKey &&
      merchantKey.length >= 4 &&
      (billKey.includes(merchantKey) || merchantKey.includes(billKey))
    ) {
      score += 42;
      reasons.push('Bill name closely matches the bank merchant');
    }

    if (score === 0) continue;

    const expected = fromCents(bill.expected_amount) || 0;
    const amountDelta = expected ? Math.abs(expected - averageAmount) : null;
    if (amountDelta !== null) {
      const pct = expected ? amountDelta / expected : 1;
      if (amountDelta <= 1 || pct <= 0.08) {
        score += 20;
        reasons.push('Bill amount matches this charge');
      } else if (amountDelta <= 5 || pct <= 0.2) {
        score += 8;
        reasons.push('Bill amount is near this charge');
      }
    }

    const dueDelta = dayDelta(bill.due_day, dueDay);
    if (dueDelta === 0) {
      score += 12;
      reasons.push('Bill due day matches the latest charge date');
    } else if (dueDelta <= 2) {
      score += 9;
      reasons.push('Bill due day is close to the latest charge date');
    } else if (dueDelta <= 5) {
      score += 5;
      reasons.push('Bill due day is near the latest charge date');
    }

    if (bill.is_subscription) score += 5;
    if (bill.has_merchant_rule) score += 3;

    if (score >= 65 && (!best || score > best.score)) {
      best = {
        id: bill.id,
        name: bill.name,
        expected_amount: expected || null,
        due_day: bill.due_day || null,
        active: !!bill.active,
        is_subscription: !!bill.is_subscription,
        catalog_id: bill.catalog_id || null,
        category_name: bill.category_name || null,
        has_merchant_rule: !!bill.has_merchant_rule,
        score,
        strong: score >= 90 || (amountDelta !== null && amountDelta <= 1 && dueDelta <= 2),
        amount_delta: amountDelta === null ? null : roundMoney(amountDelta),
        due_day_delta: dueDelta,
        reasons,
      };
    }
  }

  return best;
}

// ── Decline store ─────────────────────────────────────────────────────────────

function getDeclinedKeys(db, userId) {
  try {
    const rows = db
      .prepare('SELECT decline_key FROM declined_subscription_hints WHERE user_id = ?')
      .all(userId);
    return new Set(rows.map((r) => r.decline_key));
  } catch {
    return new Set();
  }
}

function declineRecommendation(db, userId, declineKey) {
  db.prepare(
    `
    INSERT INTO declined_subscription_hints (user_id, decline_key)
    VALUES (?, ?)
    ON CONFLICT(user_id, decline_key) DO NOTHING
  `,
  ).run(userId, declineKey);
}

// ── Recommendations ───────────────────────────────────────────────────────────

function getSubscriptionRecommendations(db, userId) {
  const catalog = loadCatalog(db, userId);
  const catalogTypeMap = buildCatalogTypeMap(catalog);
  const existingBills = existingBillsForRecommendation(db, userId);
  const feedback = loadRecommendationFeedback(db, userId);
  const declined = getDeclinedKeys(db, userId);

  const rows = db
    .prepare(
      `
    SELECT
      t.id, t.amount, t.currency, t.description, t.payee, t.memo, t.category,
      COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) AS tx_date,
      ds.provider AS data_source_provider,
      ds.name     AS data_source_name,
      fa.name     AS account_name,
      fa.org_name AS account_org_name
    FROM transactions t
    LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
    LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
    WHERE t.user_id = ?
      AND t.ignored = 0
      AND t.match_status = 'unmatched'
      AND t.amount < 0
      AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
      AND COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= date('now', '-420 days')
    ORDER BY tx_date ASC
  `,
    )
    .all(userId);

  // Group by merchant + amount bucket — consistent amounts are the foundation of
  // subscription detection. Catalog lookup names the service and boosts confidence
  // but does not change the grouping; deduplication at the end ensures one entry
  // per known service.
  const groups = new Map();
  for (const tx of rows) {
    const merchant = normalizeMerchant(tx.payee || tx.description || tx.memo);
    if (!merchant || merchant.length < 3) continue;
    if (SKIP_MERCHANT_RE.test(merchant)) continue;
    const amount = dollarsFromTransactionAmount(tx.amount);
    if (amount < 1) continue;
    const key = `${merchant}:${Math.round(amount)}`;
    if (!groups.has(key)) {
      groups.set(key, { merchant, items: [], catalogMatch: null });
    }
    const group = groups.get(key);
    group.items.push({ ...tx, amount_dollars: amount });
    if (!group.catalogMatch) group.catalogMatch = lookupCatalogMatch(catalog, merchant);
  }

  const recommendations = [];

  for (const group of groups.values()) {
    const { merchant } = group;
    const catalogEntry = group.catalogMatch?.entry || null;
    const catalogIdentityMatch = group.catalogMatch?.match || null;
    if (!catalogEntry) continue;
    const declineKey = catalogEntry ? `catalog:${catalogEntry.id}` : `merchant:${merchant}`;

    if (declined.has(declineKey)) continue;

    const sorted = group.items
      .filter((item) => item.tx_date)
      .sort((a, b) => String(a.tx_date).localeCompare(String(b.tx_date)));
    if (sorted.length === 0) continue;

    const averageAmount = sumMoney(sorted, (item) => item.amount_dollars) / sorted.length;
    const maxDelta =
      sorted.length > 1
        ? Math.max(...sorted.map((item) => Math.abs(item.amount_dollars - averageAmount)))
        : 0;
    const last = sorted[sorted.length - 1];

    // Tier 1: known-service match with 1 occurrence. Exact bank descriptors can
    // still be 90+, but weaker name/domain hits need recurrence or stronger amount
    // evidence before they appear as recommendations.
    if (catalogEntry && sorted.length === 1) {
      let cycleType = 'monthly';
      let amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry);
      if (amountInfo.inferred_cycle_type) {
        cycleType = amountInfo.inferred_cycle_type;
        amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry);
      }
      const cadenceInfo = cadenceEvidence(sorted, cycleType, 30, maxDelta, averageAmount);
      const feedbackInfo = feedbackEvidence({ feedback, merchant, catalogEntry });
      const scored = scoreKnownServiceRecommendation({
        match: catalogIdentityMatch,
        merchant,
        catalogEntry,
        amountInfo,
        cadenceInfo,
        feedbackInfo,
      });
      if (scored.confidence < 90) continue;
      const billMatch = existingBillMatch(existingBills, {
        merchant,
        catalogEntry,
        averageAmount,
        lastDate: last.tx_date,
      });
      recommendations.push(
        buildRecommendation({
          merchant,
          catalogEntry,
          sorted,
          averageAmount,
          maxDelta,
          last,
          cycleType,
          avgGap: 30,
          confidence: scored.confidence,
          tier: 'known_service',
          declineKey,
          catalogTypeMap,
          identityInfo: scored.identity,
          amountInfo,
          cadenceInfo,
          ambiguityInfo: scored.ambiguity,
          existingBillMatch: billMatch,
          feedbackInfo,
        }),
      );
      continue;
    }

    if (sorted.length < 2) continue;

    const gaps = [];
    for (let i = 1; i < sorted.length; i++) {
      gaps.push(
        Math.round(
          (new Date(`${sorted[i].tx_date}T00:00:00`) -
            new Date(`${sorted[i - 1].tx_date}T00:00:00`)) /
            86400000,
        ),
      );
    }
    const avgGap = gaps.reduce((sum, g) => sum + g, 0) / gaps.length;
    const cycleType =
      avgGap >= 320
        ? 'annual'
        : avgGap >= 75
          ? 'quarterly'
          : avgGap >= 10 && avgGap <= 18
            ? 'biweekly'
            : avgGap <= 9
              ? 'weekly'
              : 'monthly';

    if (cycleType === 'monthly' && (avgGap < 24 || avgGap > 38)) continue;
    if (cycleType === 'quarterly' && (avgGap < 75 || avgGap > 105)) continue;
    if (cycleType === 'weekly') continue;
    if (maxDelta > Math.max(3, averageAmount * 0.18)) continue;

    const amountInfo = amountEvidence(averageAmount, cycleType, catalogEntry);
    const cadenceInfo = cadenceEvidence(sorted, cycleType, avgGap, maxDelta, averageAmount);
    const feedbackInfo = feedbackEvidence({ feedback, merchant, catalogEntry });
    const scored = scoreKnownServiceRecommendation({
      match: catalogIdentityMatch,
      merchant,
      catalogEntry,
      amountInfo,
      cadenceInfo,
      feedbackInfo,
    });
    if (scored.confidence < 90) continue;
    const billMatch = existingBillMatch(existingBills, {
      merchant,
      catalogEntry,
      averageAmount,
      lastDate: last.tx_date,
    });

    recommendations.push(
      buildRecommendation({
        merchant,
        catalogEntry,
        sorted,
        averageAmount,
        maxDelta,
        last,
        cycleType,
        avgGap,
        confidence: scored.confidence,
        tier: 'confirmed',
        declineKey,
        catalogTypeMap,
        identityInfo: scored.identity,
        amountInfo,
        cadenceInfo,
        ambiguityInfo: scored.ambiguity,
        existingBillMatch: billMatch,
        feedbackInfo,
      }),
    );
  }

  // Deduplicate by catalog entry — if multiple amount buckets matched the same
  // known service, keep only the highest-confidence one.
  const seen = new Map();
  const deduped = [];
  for (const rec of recommendations.sort(
    (a, b) =>
      Number(!!b.existing_bill_match?.strong) - Number(!!a.existing_bill_match?.strong) ||
      Number(!!b.existing_bill_match) - Number(!!a.existing_bill_match) ||
      b.confidence - a.confidence ||
      b.occurrence_count - a.occurrence_count,
  )) {
    const key = rec.catalog_match ? `catalog:${rec.catalog_match.id}` : `merchant:${rec.merchant}`;
    if (!seen.has(key)) {
      seen.set(key, true);
      deduped.push(rec);
    }
  }
  return deduped.slice(0, 20);
}

function buildRecommendation({
  merchant,
  catalogEntry,
  sorted,
  averageAmount,
  maxDelta,
  last,
  cycleType,
  avgGap,
  confidence,
  tier,
  declineKey,
  catalogTypeMap,
  identityInfo = null,
  amountInfo = null,
  cadenceInfo = null,
  ambiguityInfo = null,
  existingBillMatch = null,
  feedbackInfo = null,
}) {
  const name = catalogEntry ? catalogEntry.name : titleCase(merchant);
  const subscriptionType = inferType(merchant, catalogEntry, catalogTypeMap);
  const accounts = Array.from(new Set(sorted.map(recommendationAccountLabel).filter(Boolean)));

  const reasons = [];
  if (catalogEntry) reasons.push(`Matches known service: ${catalogEntry.name}`);
  if (identityInfo?.label) reasons.push(identityInfo.label);
  if (amountInfo?.label) reasons.push(amountInfo.label);
  if (cadenceInfo?.recurring && cadenceInfo.label) reasons.push(cadenceInfo.label);
  if (ambiguityInfo?.ambiguous && ambiguityInfo.label) reasons.push(ambiguityInfo.label);
  if (feedbackInfo?.label) reasons.push(feedbackInfo.label);
  if (existingBillMatch)
    reasons.push(`Best action: link to existing bill "${existingBillMatch.name}"`);
  reasons.push(`${last.currency || 'USD'} ${averageAmount.toFixed(2)} average`);

  const recommendedAction = existingBillMatch ? 'link_existing_bill' : 'track_new';
  return {
    id: Buffer.from(`${merchant}:${Math.round(averageAmount)}:${last.tx_date}`).toString(
      'base64url',
    ),
    name,
    subscription_type: subscriptionType,
    expected_amount: roundMoney(averageAmount),
    monthly_equivalent: monthlyEquivalent(averageAmount, cycleType, cycleType),
    cycle_type: cycleType,
    billing_cycle: billingCycleForCycleType(cycleType),
    due_day: Number(String(last.tx_date).slice(8, 10)) || 1,
    last_seen_date: last.tx_date,
    occurrence_count: sorted.length,
    confidence,
    tier,
    recommended_action: recommendedAction,
    action_priority: existingBillMatch?.strong
      ? 'link_strong'
      : existingBillMatch
        ? 'link_possible'
        : 'track_new',
    existing_bill_match: existingBillMatch,
    catalog_match: catalogMatchPayload(catalogEntry),
    evidence: {
      identity: identityInfo,
      amount: amountInfo
        ? {
            match: amountInfo.match,
            label: amountInfo.label,
            score: amountInfo.score,
          }
        : null,
      cadence: cadenceInfo
        ? {
            recurring: cadenceInfo.recurring,
            stable: cadenceInfo.stable,
            label: cadenceInfo.label,
            score: cadenceInfo.score,
          }
        : null,
      amount_range:
        sorted.length > 1
          ? {
              min: Math.min(...sorted.map((item) => item.amount_dollars)),
              max: Math.max(...sorted.map((item) => item.amount_dollars)),
              max_delta: roundMoney(maxDelta),
            }
          : null,
      ambiguity: ambiguityInfo
        ? {
            ambiguous: !!ambiguityInfo.ambiguous,
            label: ambiguityInfo.label,
            reasons: ambiguityInfo.reasons || [],
            penalty: ambiguityInfo.penalty || 0,
          }
        : { ambiguous: false, label: null, reasons: [], penalty: 0 },
      feedback: feedbackInfo
        ? {
            score: feedbackInfo.score || 0,
            label: feedbackInfo.label,
            positive_count: feedbackInfo.positive_count || 0,
            negative_count: feedbackInfo.negative_count || 0,
          }
        : { score: 0, label: null, positive_count: 0, negative_count: 0 },
    },
    transaction_ids: sorted.map((item) => item.id),
    transactions: sorted.map((item) => ({
      id: item.id,
      date: item.tx_date,
      amount: item.amount_dollars,
      currency: item.currency || 'USD',
      payee: item.payee || null,
      description: item.description || null,
      memo: item.memo || null,
      account: recommendationAccountLabel(item) || null,
    })),
    merchant,
    decline_key: declineKey,
    source: last.data_source_name || 'Transaction history',
    accounts,
    reasons,
  };
}

function searchSubscriptionTransactions(db, userId, query = {}) {
  const q = String(query.q || '').trim();
  if (q.length < 2) return [];
  const limit = Math.max(1, Math.min(parseInt(query.limit || '50', 10) || 50, 100));
  const like = `%${q}%`;
  const catalog = loadCatalog(db, userId);

  const rows = db
    .prepare(
      `
    SELECT
      t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id,
      t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount,
      t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id,
      t.match_status, t.ignored, t.created_at, t.updated_at,
      ds.type AS data_source_type, ds.provider AS data_source_provider,
      ds.name AS data_source_name, ds.status AS data_source_status,
      fa.name AS account_name, fa.org_name AS account_org_name,
      fa.account_type AS account_type,
      b.name AS matched_bill_name
    FROM transactions t
    LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
    LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
    LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
    WHERE t.user_id = ?
      AND t.ignored = 0
      AND t.amount < 0
      AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
      AND (t.description LIKE ? OR t.payee LIKE ? OR t.memo LIKE ? OR t.category LIKE ?)
    ORDER BY
      CASE WHEN t.match_status = 'unmatched' THEN 0 ELSE 1 END,
      COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at) DESC,
      t.id DESC
    LIMIT ?
  `,
    )
    .all(userId, like, like, like, like, limit);

  return rows
    .map((row) => {
      const merchant = normalizeMerchant(row.payee || row.description || row.memo);
      const catalogEntry = lookupCatalog(catalog, merchant);
      return {
        ...row,
        amount_dollars: dollarsFromTransactionAmount(row.amount),
        merchant,
        is_known_subscription: !!catalogEntry,
        catalog_match: catalogMatchPayload(catalogEntry),
      };
    })
    .sort((a, b) => Number(b.is_known_subscription) - Number(a.is_known_subscription));
}

function createSubscriptionFromRecommendation(db, userId, payload = {}) {
  const seenDate = payload.last_seen_date || todayLocal();
  const source = payload.catalog_match ? 'catalog_match' : 'simplefin_recommendation';

  const draft = {
    name: payload.name,
    category_id: payload.category_id || null,
    due_day: payload.due_day,
    expected_amount: payload.expected_amount,
    billing_cycle: billingCycleForCycleType(payload.cycle_type || 'monthly'),
    cycle_type: payload.cycle_type || 'monthly',
    cycle_day:
      payload.cycle_type === 'annual' || payload.cycle_type === 'quarterly'
        ? String(new Date(`${seenDate}T00:00:00`).getMonth() + 1)
        : payload.cycle_type === 'weekly' || payload.cycle_type === 'biweekly'
          ? 'monday'
          : String(payload.due_day || 1),
    is_subscription: 1,
    subscription_type: SUBSCRIPTION_TYPES.includes(payload.subscription_type)
      ? payload.subscription_type
      : 'other',
    reminder_days_before: 3,
    subscription_source: source,
    subscription_detected_at: new Date().toISOString(),
    notes: payload.merchant ? `Detected from recurring merchant: ${payload.merchant}` : null,
  };

  const validation = validateBillData(draft);
  if (validation.errors.length > 0) {
    const err = new Error(validation.errors[0].message);
    err.field = validation.errors[0].field;
    err.status = 400;
    throw err;
  }

  const created = insertBill(db, userId, validation.normalized);

  // Persist catalog link so the catalog browser can show this bill as "matched"
  const catalogId = payload.catalog_match?.id;
  if (catalogId) {
    try {
      db.prepare('UPDATE bills SET catalog_id = ? WHERE id = ?').run(catalogId, created.id);
      created.catalog_id = catalogId;
    } catch {
      /* catalog_id column may not exist pre-v0.96 — safe to ignore */
    }
  }

  const ids = Array.isArray(payload.transaction_ids)
    ? payload.transaction_ids
        .map((id) => Number(id))
        .filter(Number.isInteger)
        .slice(0, 50)
    : [];
  if (ids.length > 0) {
    const placeholders = ids.map(() => '?').join(',');
    const txRows = db
      .prepare(
        `
      SELECT id, amount, posted_date, transacted_at
      FROM transactions
      WHERE user_id = ? AND id IN (${placeholders}) AND ignored = 0
    `,
      )
      .all(userId, ...ids);

    const updateTx = db.prepare(`
      UPDATE transactions
      SET matched_bill_id = ?, match_status = 'matched', updated_at = CURRENT_TIMESTAMP
      WHERE id = ? AND user_id = ? AND ignored = 0 AND match_status != 'matched'
    `);
    const insertPayment = db.prepare(`
      INSERT OR IGNORE INTO payments (bill_id, amount, paid_date, payment_source, transaction_id)
      VALUES (?, ?, ?, 'auto_match', ?)
    `);

    db.transaction(() => {
      for (const tx of txRows) {
        const paidDate =
          tx.posted_date || (tx.transacted_at ? String(tx.transacted_at).slice(0, 10) : null);
        const amount = Math.round(Math.abs(tx.amount)); // tx.amount and payments.amount are both cents
        updateTx.run(created.id, tx.id, userId);
        if (paidDate) insertPayment.run(created.id, amount, paidDate, tx.id);
      }
    })();
  }

  return decorateSubscription(created);
}

module.exports = {
  SUBSCRIPTION_TYPES,
  createSubscriptionFromRecommendation,
  declineRecommendation,
  decorateSubscription,
  getSubscriptionRecommendations,
  getSubscriptionSummary,
  getSubscriptions,
  lookupCatalog,
  loadCatalog,
  monthlyEquivalent,
  normalizeMerchant,
  recordSubscriptionFeedback,
  searchSubscriptionTransactions,
};
