GraphQL Invoices

This page documents GraphQL operations for managing sales invoices in Crane Ledger.

Invoice Queries

Get Invoice by ID

Retrieve a specific invoice with full details.

query GetInvoice($id: ID!) {
  invoice(id: $id) {
    id
    invoiceNumber
    contactId
    invoiceDate
    dueDate
    status
    subtotal
    taxAmount
    total
    currency {
      code
      name
      symbol
      decimalPlaces
    }
    items {
      id
      description
      quantity
      unitPrice
      lineTotal
      taxAmount
    }
    notes
    createdAt
    updatedAt
    metadata
  }
}

Arguments:

  • id: ID! - Invoice unique identifier

Returns:

  • Complete invoice information
  • Line items with pricing
  • Tax calculations
  • Status and dates

Get Invoice PDF

Get PDF download URL for an invoice.

query GetInvoicePDF($id: ID!) {
  invoice(id: $id) {
    id
    invoiceNumber
    pdfUrl
  }
}

Get Invoice Payments

Get payment history for an invoice.

query GetInvoicePayments($id: ID!) {
  invoice(id: $id) {
    id
    invoiceNumber
    total
    payments {
      id
      amount
      paymentDate
      paymentMethod
      reference
      notes
      createdAt
    }
  }
}

List Organization Invoices

Get all invoices for the organization.

query GetAllInvoices {
  organization {
    invoices {
      id
      invoiceNumber
      contactId
      invoiceDate
      dueDate
      status
      total
      currency {
        code
        symbol
      }
    }
  }
}

Filter Invoices by Status

Get invoices with specific statuses.

query GetUnpaidInvoices {
  organization {
    invoices(where: { status: { in: [SENT, VIEWED, OVERDUE] } }) {
      id
      invoiceNumber
      total
      dueDate
      status
    }
  }
}

query GetPaidInvoices {
  organization {
    invoices(where: { status: { eq: PAID } }) {
      id
      invoiceNumber
      total
      paidDate: createdAt # Approximation
    }
  }
}

Get Contact Invoices

Get all invoices for a specific contact.

query GetContactInvoices($contactId: ID!) {
  contact(id: $contactId) {
    id
    name
    invoices {
      id
      invoiceNumber
      invoiceDate
      dueDate
      status
      total
      currency {
        code
        symbol
      }
    }
  }
}

Invoice Mutations

Create Invoice

Create a new sales invoice.

mutation CreateInvoice(
  $contactId: ID!
  $invoiceDate: DateTime!
  $dueDate: DateTime!
  $items: [InvoiceItemInput!]!
  $notes: String
) {
  createInvoice(
    contactId: $contactId
    invoiceDate: $invoiceDate
    dueDate: $dueDate
    items: $items
    notes: $notes
  ) {
    id
    invoiceNumber
    contactId
    invoiceDate
    dueDate
    status
    subtotal
    taxAmount
    total
    items {
      id
      description
      quantity
      unitPrice
      lineTotal
      taxAmount
    }
  }
}

Required Fields:

  • contactId: Customer contact ID
  • invoiceDate: Invoice creation date
  • dueDate: Payment due date
  • items: Array of line items

InvoiceItemInput Structure:

{
  description: "Product/Service description"
  quantity: 2.0
  unitPrice: 50.00
  itemId: "ITEM_123" // Optional reference to item catalog
}

Record Invoice Payment

Record a payment toward an invoice.

mutation RecordInvoicePayment(
  $invoiceId: ID!
  $amount: Decimal!
  $paymentDate: DateTime!
  $paymentMethod: String!
  $reference: String
  $notes: String
) {
  recordInvoicePayment(
    invoiceId: $invoiceId
    amount: $amount
    paymentDate: $paymentDate
    paymentMethod: $paymentMethod
    reference: $reference
    notes: $notes
  ) {
    id
    documentId
    amount
    paymentDate
    paymentMethod
    reference
    notes
  }
}

Invoice Status Management

Invoice Status Types

enum InvoiceStatus {
  DRAFT     # Invoice is being prepared
  SENT      # Invoice has been sent to customer
  VIEWED    # Customer has viewed the invoice
  PAID      # Invoice is fully paid
  OVERDUE   # Invoice is past due date
  CANCELLED # Invoice has been cancelled
}

Status Transitions

const INVOICE_STATUS_FLOW = {
  DRAFT: ['SENT', 'CANCELLED'],
  SENT: ['VIEWED', 'PAID', 'OVERDUE', 'CANCELLED'],
  VIEWED: ['PAID', 'OVERDUE', 'CANCELLED'],
  PAID: [], // Terminal state
  OVERDUE: ['PAID', 'CANCELLED'],
  CANCELLED: [] // Terminal state
};

const canTransitionStatus = (fromStatus, toStatus) => {
  return INVOICE_STATUS_FLOW[fromStatus]?.includes(toStatus) || false;
};

Invoice Calculations

Subtotal Calculation

const calculateInvoiceSubtotal = (items) => {
  return items.reduce((total, item) => {
    return total + (parseFloat(item.quantity) * parseFloat(item.unitPrice));
  }, 0);
};

Tax Calculation

const calculateInvoiceTax = (subtotal, taxRate = 0.10) => {
  return subtotal * taxRate;
};

const calculateInvoiceTotal = (subtotal, taxAmount) => {
  return subtotal + taxAmount;
};

Line Item Totals

const calculateLineTotal = (quantity, unitPrice) => {
  return parseFloat(quantity) * parseFloat(unitPrice);
};

const calculateLineTax = (lineTotal, taxRate = 0.10) => {
  return lineTotal * taxRate;
};

Invoice Validation

Date Validation

const validateInvoiceDates = (invoiceDate, dueDate) => {
  const invoice = new Date(invoiceDate);
  const due = new Date(dueDate);
  const today = new Date();

  // Invoice date cannot be in the future
  if (invoice > today) {
    throw new Error('Invoice date cannot be in the future');
  }

  // Due date must be after invoice date
  if (due <= invoice) {
    throw new Error('Due date must be after invoice date');
  }

  // Reasonable due date (not more than 1 year)
  const oneYearFromInvoice = new Date(invoice);
  oneYearFromInvoice.setFullYear(oneYearFromInvoice.getFullYear() + 1);

  if (due > oneYearFromInvoice) {
    throw new Error('Due date cannot be more than 1 year from invoice date');
  }

  return true;
};

Amount Validation

const validateInvoiceAmounts = (items) => {
  if (!items || items.length === 0) {
    throw new Error('Invoice must have at least one item');
  }

  for (const item of items) {
    if (parseFloat(item.quantity) <= 0) {
      throw new Error('Item quantity must be positive');
    }

    if (parseFloat(item.unitPrice) < 0) {
      throw new Error('Item unit price cannot be negative');
    }

    if (!item.description || item.description.trim().length === 0) {
      throw new Error('Item description is required');
    }
  }

  const subtotal = calculateInvoiceSubtotal(items);
  if (subtotal <= 0) {
    throw new Error('Invoice subtotal must be positive');
  }

  return true;
};

Contact Validation

const validateInvoiceContact = async (contactId) => {
  const contact = await getContact(contactId);

  if (!contact) {
    throw new Error('Contact not found');
  }

  if (contact.contactType !== 'CUSTOMER') {
    throw new Error('Invoices can only be created for customers');
  }

  if (contact.status !== 'ACTIVE') {
    throw new Error('Cannot create invoice for inactive contact');
  }

  return true;
};

Invoice Numbering

Automatic Invoice Numbers

Invoice numbers are automatically generated:

const generateInvoiceNumber = async () => {
  const currentYear = new Date().getFullYear();
  const lastInvoice = await getLastInvoiceNumber(currentYear);

  if (lastInvoice) {
    // Extract sequence number and increment
    const sequence = parseInt(lastInvoice.split('-')[1]) + 1;
    return `${currentYear}-${sequence.toString().padStart(4, '0')}`;
  } else {
    // First invoice of the year
    return `${currentYear}-0001`;
  }
};

Custom Invoice Numbers

For custom numbering schemes:

const validateCustomInvoiceNumber = (number) => {
  // Check format: YYYY-NNNN
  const invoiceRegex = /^\d{4}-\d{4}$/;
  if (!invoiceRegex.test(number)) {
    throw new Error('Invoice number must be in format YYYY-NNNN');
  }

  // Check year is current or recent
  const year = parseInt(number.split('-')[0]);
  const currentYear = new Date().getFullYear();

  if (year < currentYear - 1 || year > currentYear + 1) {
    throw new Error('Invoice number year is not valid');
  }

  return true;
};

Invoice Templates

Standard Invoice Creation

const createStandardInvoice = async (customerId, items, dueDays = 30) => {
  const invoiceDate = new Date();
  const dueDate = new Date(invoiceDate);
  dueDate.setDate(dueDate.getDate() + dueDays);

  // Validate inputs
  await validateInvoiceContact(customerId);
  validateInvoiceDates(invoiceDate.toISOString(), dueDate.toISOString());
  validateInvoiceAmounts(items);

  // Calculate totals
  const subtotal = calculateInvoiceSubtotal(items);
  const taxAmount = calculateInvoiceTax(subtotal);
  const total = calculateInvoiceTotal(subtotal, taxAmount);

  // Create invoice
  const mutation = `
    mutation CreateInvoice($input: CreateInvoiceInput!) {
      createInvoice(input: $input) {
        id
        invoiceNumber
        total
        status
      }
    }
  `;

  const variables = {
    input: {
      contactId: customerId,
      invoiceDate: invoiceDate.toISOString(),
      dueDate: dueDate.toISOString(),
      items: items,
      notes: `Invoice created on ${invoiceDate.toLocaleDateString()}`
    }
  };

  return await graphql.request(mutation, variables);
};

Recurring Invoice Creation

const createRecurringInvoice = async (customerId, items, frequency) => {
  const invoiceDate = new Date();

  // Calculate due date based on frequency
  const dueDate = new Date(invoiceDate);
  switch (frequency) {
    case 'monthly':
      dueDate.setMonth(dueDate.getMonth() + 1);
      break;
    case 'quarterly':
      dueDate.setMonth(dueDate.getMonth() + 3);
      break;
    case 'yearly':
      dueDate.setFullYear(dueDate.getFullYear() + 1);
      break;
  }

  return await createStandardInvoice(customerId, items, dueDate);
};

Payment Processing

Payment Methods

Common payment methods:

const PAYMENT_METHODS = [
  'Check',
  'Wire Transfer',
  'Credit Card',
  'Cash',
  'ACH',
  'PayPal',
  'Stripe',
  'Other'
];

Payment Validation

const validatePayment = (invoice, paymentAmount) => {
  if (paymentAmount <= 0) {
    throw new Error('Payment amount must be positive');
  }

  // Check if payment exceeds outstanding balance
  const outstanding = parseFloat(invoice.total) - getTotalPayments(invoice);
  if (paymentAmount > outstanding) {
    throw new Error(`Payment amount cannot exceed outstanding balance of ${outstanding}`);
  }

  return true;
};

Overpayment Handling

const handleOverpayment = (invoice, paymentAmount) => {
  const outstanding = calculateOutstandingBalance(invoice);
  const overpayment = paymentAmount - outstanding;

  if (overpayment > 0) {
    // Could create credit note or refund
    console.warn(`Overpayment of ${overpayment} on invoice ${invoice.invoiceNumber}`);

    // For now, we'll allow it but log the warning
    return {
      paymentAmount: outstanding,
      overpaymentAmount: overpayment,
      note: `Partial payment of ${outstanding}, overpayment of ${overpayment} not applied`
    };
  }

  return { paymentAmount, overpaymentAmount: 0 };
};

Invoice Reporting

Aging Report

Get aging information for outstanding invoices:

query GetInvoiceAging {
  organization {
    invoices(where: { status: { in: [SENT, VIEWED, OVERDUE] } }) {
      id
      invoiceNumber
      contactId
      total
      dueDate
      payments {
        amount
        paymentDate
      }
    }
  }
}
# Process on client to calculate aging buckets

Revenue Report

Get invoiced revenue by period:

query GetRevenueByPeriod($startDate: DateTime!, $endDate: DateTime!) {
  organization {
    invoices(where: {
      invoiceDate: { gte: $startDate, lte: $endDate }
      status: { ne: CANCELLED }
    }) {
      id
      invoiceNumber
      invoiceDate
      total
      status
      payments {
        amount
        paymentDate
      }
    }
  }
}

Error Handling

Invoice Creation Errors

  • NOT_FOUND: Contact not found
  • VALIDATION_ERROR: Invalid dates, amounts, or items
  • FORBIDDEN: Insufficient permissions

Payment Recording Errors

  • NOT_FOUND: Invoice not found
  • VALIDATION_ERROR: Invalid payment data or overpayment
  • FORBIDDEN: Insufficient permissions

Best Practices

Invoice Creation Workflow

const createInvoiceWorkflow = async (customerId, items) => {
  try {
    // 1. Validate customer
    const customer = await getCustomer(customerId);
    if (!customer) throw new Error('Customer not found');

    // 2. Check for outstanding invoices
    const outstandingInvoices = await getCustomerOutstandingInvoices(customerId);
    if (outstandingInvoices.length > 5) {
      console.warn(`Customer has ${outstandingInvoices.length} outstanding invoices`);
    }

    // 3. Create invoice
    const invoice = await createStandardInvoice(customerId, items);

    // 4. Send notification (could be automated)
    await sendInvoiceNotification(invoice.id);

    // 5. Log activity
    console.log(`Invoice ${invoice.invoiceNumber} created for ${customer.name}`);

    return invoice;

  } catch (error) {
    console.error('Invoice creation failed:', error);
    throw error;
  }
};

Payment Application

const applyPaymentToInvoice = async (invoiceId, paymentData) => {
  try {
    // Get invoice details
    const invoice = await getInvoice(invoiceId);
    if (!invoice) throw new Error('Invoice not found');

    // Validate payment
    validatePayment(invoice, paymentData.amount);

    // Handle overpayments
    const paymentResult = handleOverpayment(invoice, paymentData.amount);

    // Record payment
    const payment = await recordInvoicePayment({
      invoiceId,
      ...paymentData,
      amount: paymentResult.paymentAmount
    });

    // Update invoice status if fully paid
    if (calculateOutstandingBalance(invoice) <= 0) {
      await updateInvoiceStatus(invoiceId, 'PAID');
    }

    return payment;

  } catch (error) {
    console.error('Payment application failed:', error);
    throw error;
  }
};

Invoice Maintenance

const updateOverdueInvoices = async () => {
  const today = new Date();

  // Get sent/viewed invoices
  const invoices = await getInvoicesByStatus(['SENT', 'VIEWED']);

  for (const invoice of invoices) {
    const dueDate = new Date(invoice.dueDate);

    if (today > dueDate && invoice.status !== 'PAID') {
      // Mark as overdue
      await updateInvoiceStatus(invoice.id, 'OVERDUE');

      // Send reminder notification
      await sendOverdueReminder(invoice.id);
    }
  }
};

Need help?

Create a free account to access our support portal. Once signed in, use the Support tab in your dashboard to submit a support ticket — our team typically responds within 24 hours.