Draft --> Sent --> Overdue --> Paid
| | ^
| +----> Paid --------+
| |
v v
Void Cancelled
| Status | Description |
|---|---|
| Draft | Created, not yet sent |
| Sent | Delivered to customer |
| Overdue | Past due date, not fully paid |
| Paid | Fully paid (PaidAmount >= TotalAmount) |
| Void | Cancelled before sending or by admin action |
| Cancelled | Cancelled after sending |
- API receives
POST /api/invoices(from RoofingJacket or Web UI) - Validate tenant, customer, line items
- Calculate SubtotalAmount, TaxAmount, TotalAmount
- Persist Invoice + InvoiceLineItems in a transaction
- Write AuditLog entry
- Return invoice with status
Draft
- API receives
POST /api/invoices/{id}/send - Validate invoice exists, status is
Draft - Transition status to
Sent, setSentAt - Publish
InvoiceEmailRequestedtoemail-sendqueue - Write AuditLog entry
- Worker consumes message, sends email (or stubs), writes
CommunicationLog
- API receives
POST /api/paymentswithIdempotency-Keyheader - Check idempotency: look up
IdempotencyKey(TenantId, Operation, KeyValue)- If exists: return stored response (safe retry)
- If new: proceed
- Load invoice, validate it accepts payments (status is Sent or Overdue)
- In a transaction:
- Create
PaymentAttempt(status: Succeeded) - Create
Paymentrecord - Update
Invoice.PaidAmount - If
PaidAmount >= TotalAmount: transition status toPaid, setPaidAt - Write
IdempotencyKeywith response - Write
AuditLogentry
- Create
- Return payment confirmation with CorrelationId
- API publishes
ApplyPaymentCommandtopayment-commandsqueue - Returns
202 Acceptedwith CorrelationId - Worker consumes message:
- Check idempotency
- Create PaymentAttempt
- Apply payment transactionally
- Update invoice status if fully paid
- Write audit/ledger entries
- On transient failure: abandon message (Service Bus retries)
- On non-transient failure: dead-letter immediately with reason
| Scenario | Action |
|---|---|
| Transient failure (gateway timeout, SQL transient) | Abandon message, Service Bus retries |
| Invalid payload | Dead-letter immediately |
| Idempotency key collision with different request | Reject with conflict |
| After MaxDeliveryCount (10) | Service Bus auto-DLQs |
Each tenant has one or more DunningPlans, each with ordered DunningSteps:
DunningPlan: "Standard Collections"
Step 1: Day 0 -> "Friendly Reminder" email
Step 2: Day 3 -> "Payment Overdue" email
Step 3: Day 7 -> "Final Notice" email
Step 4: Day 14 -> "Collections Warning" email
- Scheduler publishes
EvaluateDunningCommandtodunning-evaluatequeue (one per tenant) - Worker consumes:
- Query all overdue invoices for the tenant
- For each invoice with an
InvoiceDunningState:- Check if
NextActionAt <= now - If yes: publish
DunningEmailRequestedtoemail-sendqueue - Advance
CurrentStepNumber, updateNextActionAtandLastActionAt
- Check if
- For overdue invoices without dunning state:
- Create
InvoiceDunningStatelinked to tenant's default DunningPlan - Set
CurrentStepNumber = 1, calculateNextActionAt
- Create
- Worker consumes
DunningEmailRequestedfromemail-sendqueue - Resolve email template from
DunningStep.TemplateKey - Send email (or stub)
- Write
CommunicationLogentry (channel: Email, type: Dunning) - On failure: write CommunicationLog with status Failed, message goes to DLQ after retries
Dunning stops when:
- Invoice is paid (status transitions to
Paid) - Invoice is voided/cancelled
- All dunning steps have been executed
- Tenant deactivates the dunning plan
- API receives
POST /api/webhooks/{provider} - Store raw payload in
WebhookEventrow (status:Received) - Publish
WebhookReceivedtowebhook-ingestqueue (withWebhookEventIdpointer) - Return
200 OKimmediately
- Worker consumes
WebhookReceived - Load
WebhookEventrow from SQL - Update status to
Processing - Normalize event based on provider + event type
- Apply actions idempotently (e.g., mark payment as confirmed)
- Update status to
Processed, setProcessedAt - On failure: update status to
Failed, setErrorMessage
- Support UI triggers replay for a specific
WebhookEventId - Publishes
WebhookReplayRequestedtowebhook-ingestqueue - Worker processes identically to original (idempotent)
All outbound email flows through the email-send queue.
| Message | Source | Purpose |
|---|---|---|
InvoiceEmailRequested |
Invoice send action | Deliver invoice to customer |
DunningEmailRequested |
Dunning evaluation | Send collection reminder |
EmailSendRequested |
Various | Generic email (welcome, etc.) |
- Worker consumes from
email-sendqueue - Resolve recipient, subject, body from message
- Send via email provider (stubbed in V1)
- Write
CommunicationLog:- Status:
SentorFailed - Provider + ProviderMessageId
- ErrorMessage if failed
- Status:
- On failure: abandon for retry. After MaxDeliveryCount: DLQ.
Every flow propagates a CorrelationId:
| Layer | Responsibility |
|---|---|
| API | Read X-Correlation-Id header or generate new |
| Publisher | Set on ServiceBusMessage.CorrelationId + application properties |
| Worker | Start logging scope with tenantId + correlationId from envelope |
| DB writes | Write CorrelationId into domain rows |
This enables end-to-end tracing: API request -> queue message -> worker processing -> database state.