Class: Invoicing::CreateInvoiceFromDelivery

Inherits:
BaseService
  • Object
show all
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