Class: Invoicing::CreateInvoiceFromDelivery
- Inherits:
-
BaseService
- Object
- BaseService
- Invoicing::CreateInvoiceFromDelivery
- Defined in:
- app/services/invoicing/create_invoice_from_delivery.rb
Overview
Creates an invoice from a shipped delivery.
This service copies all line items, discounts, taxes, and totals from the delivery
to a new invoice. It's designed to be idempotent and race-condition safe.
== Idempotency
If an invoice already exists for the delivery, returns it without creating a duplicate.
Uses advisory locks and database unique constraints for race condition protection.
== Transaction Safety
All operations happen within a single transaction. If any validation fails,
the entire invoice creation is rolled back and an exception propagates up
to abort the delivery's state transition.
== Usage
result = Invoicing::CreateInvoiceFromDelivery.new.process(delivery)
if result.invoice_created?
puts "Created invoice #resultresult.invoiceresult.invoice.reference_number"
else
puts "Invoice already existed: #resultresult.invoiceresult.invoice.reference_number"
end
Defined Under Namespace
Classes: Result
Constant Summary collapse
- PRICE_TOLERANCE =
Tolerance for price differences due to floating point rounding
0.01- DISCOUNTED_TOTAL_TOLERANCE =
Tolerance for discounted total differences (can be larger due to complex discount math)
10.00- TAX_TOLERANCE_PRODUCTION =
Tolerance for tax differences in production (should be very small)
0.02- TAX_TOLERANCE_TEST =
Tolerance for tax differences in test (fixtures may not have accurate tax)
100.0- ADVISORY_LOCK_TIMEOUT =
Timeout for advisory lock (seconds)
30
Instance Method Summary collapse
Instance Method Details
#process(delivery, skip_existing_check: false) ⇒ Object
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
# File 'app/services/invoicing/create_invoice_from_delivery.rb', line 49 def process(delivery, skip_existing_check: false) # Idempotency: if an invoice already exists for this delivery, return it unless skip_existing_check if (existing = Invoice.find_by(delivery_id: delivery.id)) return Result.new(invoice_created: false, invoice: existing) end end # CRITICAL: Use row-level lock (SELECT ... FOR UPDATE) on the delivery FIRST. # This is the primary defense against race conditions where concurrent processes # try to invoice the same delivery simultaneously. # # The row lock ensures: # 1. Only one process can proceed with invoicing for this delivery # 2. Other processes block until the first one commits/rolls back # 3. This is more reliable than advisory locks for transaction-scoped operations # # See: Invoices 266787, 266919 created 2026-02-02/03 due to race conditions delivery.with_lock do # Re-check idempotency after acquiring row lock unless skip_existing_check # Reload to see any changes committed by other transactions delivery.reload # Check for existing invoice by delivery_id (most reliable check) if (existing = Invoice.find_by(delivery_id: delivery.id)) logger.info "Invoice already exists for delivery #{delivery.id} after acquiring lock - skipping" return Result.new(invoice_created: false, invoice: existing) end # If delivery is in 'invoiced' state but no invoice exists, this is a data # integrity issue that must NOT be silently "fixed" by creating a new invoice. # The previous fallthrough caused ghost invoices (valid totals, zero line items) # when a race condition deleted the original invoice mid-transaction. # See: Invoice 268411 / Delivery 774325 incident. if delivery.invoiced? raise "Delivery #{delivery.id} is in 'invoiced' state but no invoice record found. " \ "This requires manual investigation — do not auto-create." end end # TODO(phase2): Remove advisory lock once approve! fix is confirmed stable (~1 week). # Row-level lock (with_lock above) + Sidekiq unique lock is sufficient. lock_key = "create_invoice_from_delivery_#{delivery.id}" Invoice.with_advisory_lock!(lock_key, timeout_seconds: ADVISORY_LOCK_TIMEOUT) do create_invoice_within_lock(delivery, skip_existing_check:) end end end |