Skip to content

Issue #222: Fix Invoice Sending Issues

Status: ✅ Closed Priority: MUST have Labels: F: Invoicing, Bug Created: 2025-10-29 Resolved: 2025-10-29

Problem

Multiple critical bugs prevented invoices from being sent and caused status transitions to fail:

  1. PostgreSQL Function Failures - create_document_with_items() and update_document_with_items() functions failed with "relation 'documents' does not exist" errors due to empty search_path
  2. JSONB Type Mismatches - Address fields were treated as text (->>) instead of JSONB (->) causing type conversion errors
  3. Draft Invoice Validation - Draft invoices failed validation when address fields were NULL, preventing users from creating drafts without complete customer data
  4. Invalid Status Transitions - Draft invoices could be canceled, but the correct action should be deletion (canceled status is for sent invoices that are later canceled)
  5. Event Handler Context Loss - DocumentPersistenceService lost this context when registered as event callback, causing method access failures
  6. Audit Metadata Validation - Audit table inserts failed when metadata field was NULL or undefined
  7. Audit Table Schema Issues - Legacy columns (event, entity_type, target_id) caused migration conflicts

Solution

1. PostgreSQL Function Fixes

Fixed search_path and qualified all table names:

File: database/functions/document_transactions.sql

CREATE OR REPLACE FUNCTION public.create_document_with_items(
  document_data jsonb,
  items_data jsonb[]
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp  -- Explicitly set search_path
AS $$
DECLARE
  new_document_id uuid;
  item jsonb;
BEGIN
  -- Qualified table name: public.documents
  INSERT INTO public.documents (
    id, type, status, service_provider_id,
    customer_id, document_number, invoice_number,

    -- Draft mode: Allow NULL addresses for drafts
    customer_address,
    billing_address,
    shipping_address,

    issue_date, due_date, payment_terms,
    currency, subtotal, tax_amount, total_gross,
    notes, terms_and_conditions, footer_text,
    language, stripe_payment_link
  )
  SELECT
    COALESCE((document_data->>'id')::uuid, gen_random_uuid()),
    document_data->>'type',
    document_data->>'status',
    (document_data->>'service_provider_id')::uuid,
    NULLIF(document_data->>'customer_id', '')::uuid,
    document_data->>'document_number',
    NULLIF(document_data->>'invoice_number', ''),

    -- Use CASE to set NULL for draft status, preserve JSONB type
    CASE WHEN (document_data->>'status') = 'draft'
      THEN NULL
      ELSE document_data->'customer_address'  -- Use -> not ->>
    END,
    CASE WHEN (document_data->>'status') = 'draft'
      THEN NULL
      ELSE document_data->'billing_address'
    END,
    CASE WHEN (document_data->>'status') = 'draft'
      THEN NULL
      ELSE document_data->'shipping_address'
    END,

    COALESCE((document_data->>'issue_date')::timestamp, NOW()),
    NULLIF(document_data->>'due_date', '')::timestamp,
    NULLIF(document_data->>'payment_terms', ''),
    COALESCE(document_data->>'currency', 'EUR'),
    COALESCE((document_data->>'subtotal')::numeric, 0),
    COALESCE((document_data->>'tax_amount')::numeric, 0),
    COALESCE((document_data->>'total_gross')::numeric, 0),
    document_data->>'notes',
    document_data->>'terms_and_conditions',
    document_data->>'footer_text',
    COALESCE(document_data->>'language', 'en'),
    document_data->>'stripe_payment_link'
  RETURNING id INTO new_document_id;

  -- Qualified table name for document_items
  FOR item IN SELECT * FROM unnest(items_data)
  LOOP
    INSERT INTO public.document_items (
      document_id, description, quantity, unit_price,
      tax_rate, amount, product_category, document_id_placeholder
    )
    VALUES (
      new_document_id,
      item->>'description',
      (item->>'quantity')::numeric,
      (item->>'unit_price')::numeric,
      COALESCE((item->>'tax_rate')::numeric, 0),
      COALESCE((item->>'amount')::numeric, 0),
      item->>'product_category',
      item->>'document_id'
    );
  END LOOP;

  -- Return complete document
  RETURN (
    SELECT row_to_json(d.*)
    FROM public.documents d
    WHERE d.id = new_document_id
  );
END;
$$;

Key Changes: - Added SET search_path = public, pg_temp to function definition - Qualified all table names with public. schema prefix - Changed JSONB operators from ->> (text) to -> (JSONB) for address fields - Added CASE statements to allow NULL addresses for draft invoices - Similar fixes applied to update_document_with_items()

2. XState State Machine Updates

Removed CANCEL transition from DRAFT state:

File: apps/web/composables/useInvoiceStatusMachine.ts

[DOCUMENT_STATUS_DRAFT]: {
  on: {
    SEND: {
      target: DOCUMENT_STATUS_SENT,
      guard: "canSend",
    },
    // CANCEL transition removed - draft invoices should be deleted instead
  },
},

Updated Guard:

canCancel: ({ context }) => {
  const status = context.invoice.status;
  // Cannot cancel if draft, paid, overpaid, partially paid, or already canceled
  return ![
    DOCUMENT_STATUS_DRAFT,          // NEW: Added this
    DOCUMENT_STATUS_PAID,
    DOCUMENT_STATUS_OVERPAID,
    DOCUMENT_STATUS_PARTIALLY_PAID,
    DOCUMENT_STATUS_CANCELED,
  ].includes(status);
},

Reasoning: - Draft invoices are work-in-progress and should be deleted if no longer needed - Canceled status is for invoices that were sent to customers but later canceled - This aligns with business logic: you cancel sent invoices, you delete drafts

3. DocumentPersistenceService Context Fix

Fixed context binding for event handlers:

File: apps/api/src/services/DocumentPersistenceService.ts

static initialize() {
  if (this.initialized) {
    return;
  }

  // Bind handler to preserve 'this' context when called as callback
  eventBus.onTypeAndEntity(
    "update",
    "invoice",
    this.handleDocumentUpdate.bind(this)  // .bind(this) added
  );
  eventBus.onTypeAndEntity(
    "update",
    "offer",
    this.handleDocumentUpdate.bind(this)
  );
  eventBus.onTypeAndEntity(
    "update",
    "credit_note",
    this.handleDocumentUpdate.bind(this)
  );

  this.initialized = true;
}

Problem: When handleDocumentUpdate was called as a callback, it lost access to this, causing errors when trying to call static methods.

Solution: Used .bind(this) to preserve the class context.

4. AuditSubscriber Metadata Validation

Fixed metadata handling to avoid NULL validation errors:

File: apps/api/src/subscribers/AuditSubscriber.ts

const auditData = {
  actor_id: event.actor,
  event_type: event.type,
  entity: event.entity,
  entity_id: event.entityId,
  diff: differences || otherDetails,
  ...(event.metadata && { metadata: event.metadata }), // Conditional spread
  created_at: new Date().toISOString(),
};

const { error: auditError } = await systemClient
  .from("audit")
  .insert(auditData);

Key Changes: - Used conditional spread ...(event.metadata && { metadata: event.metadata }) to only include metadata field if it exists - Removed legacy fields (event, entity_type, target_id) that were causing conflicts - This prevents "null value in column 'metadata' violates not-null constraint" errors when metadata is undefined

5. Audit Table Schema Migration

Migration: Removed legacy columns that were no longer used

-- Remove old columns
ALTER TABLE public.audit DROP COLUMN IF EXISTS event;
ALTER TABLE public.audit DROP COLUMN IF EXISTS entity_type;
ALTER TABLE public.audit DROP COLUMN IF EXISTS target_id;

Testing

Unit Tests Created

Created comprehensive test suite for invoice status machine:

File: apps/web/test/unit/useInvoiceStatusMachine.spec.ts

Test Coverage (36 tests):

  1. canSend Guard (8 tests)
  2. Should allow sending draft invoices
  3. Should NOT allow sending already sent invoices
  4. Should NOT allow sending paid invoices
  5. Should NOT allow sending canceled invoices
  6. Should NOT allow sending invoices without customer address
  7. Should NOT allow sending invoices without billing address
  8. Should NOT allow sending invoices without items
  9. Should allow sending draft invoices with all required data

  10. canCancel Guard (7 tests)

  11. Should NOT allow canceling draft invoices (NEW - key fix)
  12. Should allow canceling sent invoices
  13. Should allow canceling overdue invoices
  14. Should NOT allow canceling paid invoices
  15. Should NOT allow canceling overpaid invoices
  16. Should NOT allow canceling partially paid invoices
  17. Should NOT allow canceling already canceled invoices

  18. canMarkAsPaid Guard (5 tests)

  19. Should allow marking sent invoices as paid
  20. Should allow marking overdue invoices as paid
  21. Should allow marking partially paid invoices as paid
  22. Should NOT allow marking draft invoices as paid
  23. Should NOT allow marking already paid invoices as paid

  24. canBookPayment Guard (6 tests)

  25. Should allow booking payment for sent invoices
  26. Should allow booking payment for overdue invoices
  27. Should allow booking payment for partially paid invoices
  28. Should NOT allow booking payment for draft invoices
  29. Should NOT allow booking payment for canceled invoices
  30. Should allow booking payment for paid invoices (overpayment)

  31. getPaymentStatus Function (10 tests)

  32. Should return paid when full payment made
  33. Should return partially_paid when partial payment made
  34. Should return overpaid when payment exceeds total
  35. Should return current status when payment is zero
  36. Should handle multiple partial payments
  37. Should handle exact payment after partial
  38. Should handle overpayment after partial
  39. Should handle edge case: tiny remaining balance
  40. Should handle negative payment amounts (guard responsibility)
  41. Should handle very large payments

Test Results:

✅ All 36 tests passing
✅ 100% code coverage for useInvoiceStatusMachine.ts
✅ All status transitions validated
✅ All guards tested with edge cases

Manual Testing Checklist

  • ✅ Create draft invoice without customer address
  • ✅ Update draft invoice multiple times
  • ✅ Send draft invoice (transitions to sent)
  • ✅ Verify draft invoice cannot be canceled (only deleted)
  • ✅ Cancel sent invoice (transitions to canceled)
  • ✅ Book payment on sent invoice
  • ✅ Book multiple partial payments
  • ✅ Verify overpayment protection
  • ✅ Check audit log records all status changes
  • ✅ Verify status history shows correct user attribution

Benefits

  1. Reliable Invoice Creation - Draft invoices can be created without complete customer data
  2. Atomic Operations - PostgreSQL functions ensure data consistency
  3. Correct Business Logic - Draft invoices must be deleted, not canceled
  4. Comprehensive Audit Trail - All status changes tracked with user attribution
  5. Type Safety - JSONB fields preserve structure through database operations
  6. Error Prevention - Guards prevent invalid status transitions
  7. Test Coverage - 36 unit tests prevent future regressions

Files Modified

Backend (API)

  • database/functions/document_transactions.sql - Fixed search_path and JSONB handling
  • apps/api/src/services/DocumentPersistenceService.ts - Fixed context binding
  • apps/api/src/subscribers/AuditSubscriber.ts - Fixed metadata validation
  • database/tables/audit.sql - Removed legacy columns

Frontend (Web)

  • apps/web/composables/useInvoiceStatusMachine.ts - Removed CANCEL from DRAFT
  • apps/web/test/unit/useInvoiceStatusMachine.spec.ts - Added 36 comprehensive tests

Technical Details

PostgreSQL SECURITY DEFINER Functions

Functions with SECURITY DEFINER run with the privileges of the function owner, not the caller. This is critical for Row Level Security (RLS) scenarios where the API needs to perform operations that bypass user-level restrictions.

search_path Configuration:

SET search_path = public, pg_temp

This ensures: 1. Tables are resolved in the public schema first 2. Temporary tables (session-specific) are checked second 3. No ambiguity in table resolution 4. Security: Prevents schema injection attacks

JSONB Operators

  • -> operator: Extracts JSONB object/array (preserves type)
  • ->> operator: Extracts as text (converts to string)

Example:

-- Wrong: Returns text, causes type mismatch
customer_address ->> 'street'

-- Correct: Returns JSONB object
customer_address -> 'street'

XState Guards

Guards are pure functions that determine if a transition is allowed:

canCancel: ({ context }) => {
  return !BLOCKED_STATUSES.includes(context.invoice.status);
}

Guards must be: - Pure functions (same input = same output) - Synchronous - Side-effect free - Testable in isolation

Migration Notes

Database Migration Required

Run the following SQL to update existing audit tables:

-- Remove legacy columns (safe operation)
ALTER TABLE public.audit DROP COLUMN IF EXISTS event;
ALTER TABLE public.audit DROP COLUMN IF EXISTS entity_type;
ALTER TABLE public.audit DROP COLUMN IF EXISTS target_id;

Existing Invoices

  • ✅ Existing invoices continue to work without changes
  • ✅ Draft invoices can no longer be canceled (must be deleted)
  • ✅ Sent invoices can still be canceled normally
  • ✅ All existing status transitions remain valid

API Compatibility

  • ✅ No breaking changes to REST API
  • ✅ PostgreSQL functions are backward compatible
  • ✅ Response formats unchanged
  • ✅ Client code requires no updates
  • Issue #221: Remove ambiguous characters from document numbers
  • Issue #223: Add status history table to invoice details
  • Issue #220: Fix missing document_number API error
  • Issue #219: Fix pnpm dependency warnings

References

  • Commit: d1f6b51, 2f58c85, 9480979, 2c56fb7
  • PostgreSQL Search Path: https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH
  • XState Guards: https://xstate.js.org/docs/guides/guards.html
  • JSONB Operators: https://www.postgresql.org/docs/current/functions-json.html