invoist 1.0.1

Official Rust client for the Invoist API: extract invoices, read a household's bills, verify webhooks.
Documentation
//! Options for the calls that take them. Every field is optional: start from
//! `Default::default()` (or `new()`) and set what you need, as fields or with
//! the chainable setters.

use std::time::Duration;

use serde::Serialize;

use crate::Month;
use crate::models::{DocumentStatus, ReviewStatus};

/// Query parameters, in order, with empty values left out.
#[derive(Default)]
pub(crate) struct Query(pub(crate) Vec<(&'static str, String)>);

impl Query {
    fn put(&mut self, key: &'static str, value: Option<impl ToString>) {
        if let Some(v) = value {
            let v = v.to_string();
            if !v.is_empty() {
                self.0.push((key, v));
            }
        }
    }
}

/// Options for [`Client::extract`](crate::Client::extract).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExtractOptions {
    /// A key you generate per logical upload. Sending the same key again
    /// replays the first response, with no new document and no new charge;
    /// sending it with different bytes fails with a 422. Covers re-sending
    /// logically-the-same but byte-different content, which duplicate
    /// detection alone can't. Keys expire after 24 hours.
    pub idempotency_key: Option<String>,
}

impl ExtractOptions {
    /// No options.
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets [`idempotency_key`](ExtractOptions::idempotency_key).
    pub fn idempotency_key(mut self, key: impl Into<String>) -> Self {
        self.idempotency_key = Some(key.into());
        self
    }
}

/// How long [`Client::wait_for_extraction`](crate::Client::wait_for_extraction)
/// waits, and how often it asks.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WaitOptions {
    /// Give up after this long. Default 120 seconds.
    pub timeout: Duration,
    /// Time between polls. Default 2 seconds.
    pub poll_interval: Duration,
}

impl Default for WaitOptions {
    fn default() -> Self {
        WaitOptions {
            timeout: Duration::from_secs(120),
            poll_interval: Duration::from_secs(2),
        }
    }
}

impl WaitOptions {
    /// The defaults: poll every 2 seconds for up to 120 seconds.
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets [`timeout`](WaitOptions::timeout).
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Sets [`poll_interval`](WaitOptions::poll_interval).
    pub fn poll_interval(mut self, interval: Duration) -> Self {
        self.poll_interval = interval;
        self
    }
}

/// Filters for [`Client::list_documents`](crate::Client::list_documents).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ListDocumentsParams {
    /// Maximum rows to return. The API defaults to 50 and caps at 200.
    pub limit: Option<u32>,
    /// Rows to skip, for paging.
    pub offset: Option<u32>,
    /// Only documents with this status.
    pub status: Option<DocumentStatus>,
    /// Only documents with review items waiting for a person.
    pub flagged: bool,
    /// Case-insensitive match on filename, vendor, invoice number, or reference.
    pub q: Option<String>,
    /// A category ID, or `none` for uncategorized documents only.
    pub category: Option<String>,
}

impl ListDocumentsParams {
    /// No filters.
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets [`limit`](ListDocumentsParams::limit).
    pub fn limit(mut self, limit: u32) -> Self {
        self.limit = Some(limit);
        self
    }

    /// Sets [`offset`](ListDocumentsParams::offset).
    pub fn offset(mut self, offset: u32) -> Self {
        self.offset = Some(offset);
        self
    }

    /// Sets [`status`](ListDocumentsParams::status).
    pub fn status(mut self, status: DocumentStatus) -> Self {
        self.status = Some(status);
        self
    }

    /// Sets [`flagged`](ListDocumentsParams::flagged).
    pub fn flagged(mut self, flagged: bool) -> Self {
        self.flagged = flagged;
        self
    }

    /// Sets [`q`](ListDocumentsParams::q).
    pub fn q(mut self, q: impl Into<String>) -> Self {
        self.q = Some(q.into());
        self
    }

    /// Sets [`category`](ListDocumentsParams::category).
    pub fn category(mut self, category: impl Into<String>) -> Self {
        self.category = Some(category.into());
        self
    }

    pub(crate) fn query(&self) -> Query {
        let mut q = Query::default();
        q.put("limit", self.limit);
        q.put("offset", self.offset);
        q.put("status", self.status.as_ref());
        q.put("flagged", self.flagged.then_some("true"));
        q.put("q", self.q.as_ref());
        q.put("category", self.category.as_ref());
        q
    }
}

/// Filters for [`Client::list_review_queue`](crate::Client::list_review_queue).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ListReviewQueueParams {
    /// Only items with this status; all when `None`.
    pub status: Option<ReviewStatus>,
}

impl ListReviewQueueParams {
    /// No filters.
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets [`status`](ListReviewQueueParams::status).
    pub fn status(mut self, status: ReviewStatus) -> Self {
        self.status = Some(status);
        self
    }

    pub(crate) fn query(&self) -> Query {
        let mut q = Query::default();
        q.put("status", self.status.as_ref());
        q
    }
}

/// How [`Client::resolve_review_item`](crate::Client::resolve_review_item)
/// closes an item.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReviewResolution {
    /// Checked and dealt with. The default.
    #[default]
    Resolved,
    /// Not a real problem.
    Dismissed,
}

/// A webhook endpoint to register with
/// [`Client::create_webhook_endpoint`](crate::Client::create_webhook_endpoint).
///
/// ```
/// use invoist::{CreateWebhookEndpoint, webhooks::events};
///
/// let endpoint = CreateWebhookEndpoint::new("https://example.com/hooks/invoist")
///     .events([events::EXTRACTION_COMPLETED, events::EXTRACTION_FAILED]);
/// ```
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct CreateWebhookEndpoint {
    /// The HTTP or HTTPS URL that receives events.
    pub url: String,
    /// A note to tell endpoints apart.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Event types to send, or `["*"]` for everything. Empty means
    /// everything. See [`webhooks::events`](crate::webhooks::events).
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub events: Vec<String>,
}

impl CreateWebhookEndpoint {
    /// An endpoint at `url` that receives every event.
    pub fn new(url: impl Into<String>) -> Self {
        CreateWebhookEndpoint {
            url: url.into(),
            ..Self::default()
        }
    }

    /// Sets [`description`](CreateWebhookEndpoint::description).
    pub fn description(mut self, description: impl Into<String>) -> Self {
        self.description = Some(description.into());
        self
    }

    /// Sets [`events`](CreateWebhookEndpoint::events).
    pub fn events<I, S>(mut self, events: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.events = events.into_iter().map(Into::into).collect();
        self
    }
}

/// Filters for [`Bills::list`](crate::Bills::list).
///
/// ```
/// use invoist::{ListBillsParams, Month};
///
/// let unpaid = ListBillsParams { paid: Some(false), ..Default::default() };
/// let october = ListBillsParams::new().month(Month::new(2026, 10)?).sender("Northwind");
/// # Ok::<(), invoist::Error>(())
/// ```
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ListBillsParams {
    /// `Some(true)` for bills marked paid, `Some(false)` for bills still to
    /// pay, `None` for both.
    pub paid: Option<bool>,
    /// Only this month's bills, by due date (or the bill's own date when it
    /// has none).
    pub month: Option<Month>,
    /// Only this sender's bills, matched without regard to case or spacing.
    pub sender: Option<String>,
    /// Case-insensitive match on filename, sender, bill number, or
    /// reference. Searches every month.
    pub q: Option<String>,
    /// Maximum rows to return. The API defaults to 50 and caps at 200.
    pub limit: Option<u32>,
    /// Rows to skip, for paging.
    pub offset: Option<u32>,
}

impl ListBillsParams {
    /// No filters.
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets [`paid`](ListBillsParams::paid).
    pub fn paid(mut self, paid: bool) -> Self {
        self.paid = Some(paid);
        self
    }

    /// Sets [`month`](ListBillsParams::month).
    pub fn month(mut self, month: Month) -> Self {
        self.month = Some(month);
        self
    }

    /// Sets [`sender`](ListBillsParams::sender).
    pub fn sender(mut self, sender: impl Into<String>) -> Self {
        self.sender = Some(sender.into());
        self
    }

    /// Sets [`q`](ListBillsParams::q).
    pub fn q(mut self, q: impl Into<String>) -> Self {
        self.q = Some(q.into());
        self
    }

    /// Sets [`limit`](ListBillsParams::limit).
    pub fn limit(mut self, limit: u32) -> Self {
        self.limit = Some(limit);
        self
    }

    /// Sets [`offset`](ListBillsParams::offset).
    pub fn offset(mut self, offset: u32) -> Self {
        self.offset = Some(offset);
        self
    }

    pub(crate) fn query(&self) -> Query {
        let mut q = Query::default();
        q.put("paid", self.paid);
        q.put("month", self.month);
        q.put("sender", self.sender.as_ref());
        q.put("q", self.q.as_ref());
        q.put("limit", self.limit);
        q.put("offset", self.offset);
        q
    }
}

/// Options for [`Bills::summary`](crate::Bills::summary).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BillsSummaryParams {
    /// The household's own date (`YYYY-MM-DD`), within two days of the
    /// server's. Defaults to the server's.
    pub today: Option<String>,
    /// How far ahead "due soon" reaches, 1 to 366. The API defaults to 30.
    pub days: Option<u32>,
}

impl BillsSummaryParams {
    /// The API's defaults.
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets [`today`](BillsSummaryParams::today).
    pub fn today(mut self, today: impl Into<String>) -> Self {
        self.today = Some(today.into());
        self
    }

    /// Sets [`days`](BillsSummaryParams::days).
    pub fn days(mut self, days: u32) -> Self {
        self.days = Some(days);
        self
    }

    pub(crate) fn query(&self) -> Query {
        let mut q = Query::default();
        q.put("today", self.today.as_ref());
        q.put("days", self.days);
        q
    }
}

/// Options for [`Bills::stats`](crate::Bills::stats).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BillsStatsParams {
    /// The month. Defaults to the current one.
    pub month: Option<Month>,
    /// Narrow the totals and the trend to one sender.
    pub sender: Option<String>,
}

impl BillsStatsParams {
    /// The current month, every sender.
    pub fn new() -> Self {
        Self::default()
    }

    /// Sets [`month`](BillsStatsParams::month).
    pub fn month(mut self, month: Month) -> Self {
        self.month = Some(month);
        self
    }

    /// Sets [`sender`](BillsStatsParams::sender).
    pub fn sender(mut self, sender: impl Into<String>) -> Self {
        self.sender = Some(sender.into());
        self
    }

    pub(crate) fn query(&self) -> Query {
        let mut q = Query::default();
        q.put("month", self.month);
        q.put("sender", self.sender.as_ref());
        q
    }
}