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:
- PostgreSQL Function Failures -
create_document_with_items()andupdate_document_with_items()functions failed with "relation 'documents' does not exist" errors due to emptysearch_path - JSONB Type Mismatches - Address fields were treated as text (
->>) instead of JSONB (->) causing type conversion errors - Draft Invoice Validation - Draft invoices failed validation when address fields were NULL, preventing users from creating drafts without complete customer data
- 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)
- Event Handler Context Loss -
DocumentPersistenceServicelostthiscontext when registered as event callback, causing method access failures - Audit Metadata Validation - Audit table inserts failed when metadata field was NULL or undefined
- 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):
- canSend Guard (8 tests)
- Should allow sending draft invoices
- Should NOT allow sending already sent invoices
- Should NOT allow sending paid invoices
- Should NOT allow sending canceled invoices
- Should NOT allow sending invoices without customer address
- Should NOT allow sending invoices without billing address
- Should NOT allow sending invoices without items
-
Should allow sending draft invoices with all required data
-
canCancel Guard (7 tests)
- Should NOT allow canceling draft invoices (NEW - key fix)
- Should allow canceling sent invoices
- Should allow canceling overdue invoices
- Should NOT allow canceling paid invoices
- Should NOT allow canceling overpaid invoices
- Should NOT allow canceling partially paid invoices
-
Should NOT allow canceling already canceled invoices
-
canMarkAsPaid Guard (5 tests)
- Should allow marking sent invoices as paid
- Should allow marking overdue invoices as paid
- Should allow marking partially paid invoices as paid
- Should NOT allow marking draft invoices as paid
-
Should NOT allow marking already paid invoices as paid
-
canBookPayment Guard (6 tests)
- Should allow booking payment for sent invoices
- Should allow booking payment for overdue invoices
- Should allow booking payment for partially paid invoices
- Should NOT allow booking payment for draft invoices
- Should NOT allow booking payment for canceled invoices
-
Should allow booking payment for paid invoices (overpayment)
-
getPaymentStatus Function (10 tests)
- Should return paid when full payment made
- Should return partially_paid when partial payment made
- Should return overpaid when payment exceeds total
- Should return current status when payment is zero
- Should handle multiple partial payments
- Should handle exact payment after partial
- Should handle overpayment after partial
- Should handle edge case: tiny remaining balance
- Should handle negative payment amounts (guard responsibility)
- 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¶
- Reliable Invoice Creation - Draft invoices can be created without complete customer data
- Atomic Operations - PostgreSQL functions ensure data consistency
- Correct Business Logic - Draft invoices must be deleted, not canceled
- Comprehensive Audit Trail - All status changes tracked with user attribution
- Type Safety - JSONB fields preserve structure through database operations
- Error Prevention - Guards prevent invalid status transitions
- Test Coverage - 36 unit tests prevent future regressions
Files Modified¶
Backend (API)¶
database/functions/document_transactions.sql- Fixed search_path and JSONB handlingapps/api/src/services/DocumentPersistenceService.ts- Fixed context bindingapps/api/src/subscribers/AuditSubscriber.ts- Fixed metadata validationdatabase/tables/audit.sql- Removed legacy columns
Frontend (Web)¶
apps/web/composables/useInvoiceStatusMachine.ts- Removed CANCEL from DRAFTapps/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
Related Issues¶
- 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