Skip to main content

Phase 2 — Use Cases

This document covers only the use cases required for Phase 2. The original Billing use cases are maintained in use-cases.md.

For gap analysis and use case coverage mapping, see Billing Use Case Gap Analysis.


Use Case Map

Phase 2 (invoice lifecycle — ends at invoice :paid with InvoicePosting guard created):

#Use CaseActorTrigger
UC-1Create Draft InvoiceAdmin (Sales/Ops)Company wants to purchase Placement Credits
UC-2aGenerate Invoice PDFAdmin (Sales)Admin clicks "Generate Invoice File" on a draft invoice
UC-2bSend Invoice to ClientAdmin (Sales)Admin clicks "Send Invoice to Client"
UC-2cProcess Mailgun WebhookSystemMailgun POSTs a delivery event webhook to Jod
UC-3Edit Draft InvoiceAdminAdmin needs to correct details before issuing
UC-4Record Bank TransferAdmin (Ops)Customer provides proof of bank transfer
UC-5Verify PaymentAdmin (Finance)Finance confirms money received in the bank account
UC-6Reject PaymentAdmin (Finance)Proof is invalid or money was not received
UC-7Create InvoicePosting GuardSystem (via UC-5)Invoice transitions to paid — creates posting record, no entitlements
UC-8Void Unpaid InvoiceAdminInvoice was created in error, or customer cancels before paying

Phase 3 (entitlement engine + Ads enforcement — specified here for handoff):

#Use CaseActorTrigger
UC-9Grant Entitlements on Paid InvoiceSystem (auto-triggered)InvoicePosting record exists — extend with LedgerEntry + balance writes
UC-10Reserve Credits on Campaign SubmitSystem (via Ads domain)Ads::Campaign submitted for review (status → pending_review)
UC-11Daily Consume CreditsSystem (background job)Daily job runs for each Ads::CampaignPlacement in active status
UC-12Release Credits on Cancel / RejectSystem (via Ads domain)Campaign cancelled by employer or rejected by admin

UC-1: Create a Draft Invoice for a Company's Credit Purchase

FieldDetails
ActorIdentities::Admin (sales/ops)
TriggerCompany wants to purchase Placement Credits
Source Use CaseAli UC 3.0 — Admin-initiated Purchase Flow (Step 2)

Preconditions:

  • Billing::Account exists for the Org::Company
  • An active Billing::Agreement exists with relevant terms (or terms come from list price)
  • Billing::Product and Billing::ProductPrice exist for the desired entitlement and market
  • Billing::LegalEntity exists for the seller jurisdiction
  • Billing::BillToProfile exists for the buyer

Models Touched: Billing::Invoice, Billing::InvoiceLine

System Behaviour:

  1. Admin selects the billing account, product, and quantity
  2. System resolves the applicable Billing::ProductPrice:
    • country_id matching the company's country (via billing_legal_entity.country_id)
    • status = :active (and parent product status = :active)
    • Private price (billing_account_id matches) overrides standard price (billing_account_id IS NULL)
  3. System resolves the active Billing::Agreement to obtain negotiated terms (fee rates, unit prices, discounts)
  4. System generates Billing::Invoice in draft status:
    • Snapshots bill-to fields from Billing::BillToProfile
    • Sets currency from the bill-to profile
    • Associates the seller Billing::LegalEntity
    • Admin enters ref_number (finance-allocated from Xero). System validates global uniqueness
  5. System creates Billing::InvoiceLine rows:
    • Placement Credits: 1 line, line_type: :principal, full GST
    • Gig Credits (future): 2 lines — :principal (tax-exempt) + :platform_fee (taxed)
    • Every line snapshots: description, quantity, unit_price_cents, amount_cents, tax_cents, units_to_grant, line_type
  6. System computes and stores subtotal_cents, tax_cents, total_cents on the invoice
  7. Admin audit trail recorded (admin_created_by)

Business Rules:

  • Invoice lines snapshot all pricing and tax at creation time — later product/price/agreement changes do not affect existing invoices
  • ref_number is globally unique across all Jod invoices (GST compliance requirement)
  • ref_number is required at create time; format is owned by finance
  • Pricing is never hard-coded — it always comes from Billing::ProductPrice
  • A Placement Credits invoice has exactly 1 line item

Postconditions:

  • Invoice exists in draft status with computed totals
  • No payments or postings exist yet
  • No entitlements are granted

UC-2a: Generate the Invoice File (PDF)

FieldDetails
ActorIdentities::Admin (sales)
TriggerAdmin clicks "Generate Invoice File" on a draft invoice
Source Use CaseAli UC 3.0 — Step 2 (invoice review before issuance)

Preconditions:

  • Invoice is in draft status
  • Invoice has at least one line item
  • All required snapshot fields are populated

Models Touched: Billing::Invoice

System Behaviour:

  1. Background job renders the invoice PDF from the snapshotted invoice fields
  2. Job stores the S3 key at file_path and stamps file_generated_at
  3. status remains :draft. delivery_status remains :not_attempted
  4. Admin can re-generate while invoice is in draft (each generation overwrites the previous file)

Business Rules:

  • Generation is idempotent — if the draft changes after generation, admin must re-generate before sending
  • File is access-controlled — file_path is an S3 key in a private bucket, not a URL. Admins must request a short-lived signed URL (GET .../signed_url) to open the file.

Postconditions:

  • file_path and file_generated_at are populated
  • Status unchanged

UC-2b: Send the Invoice to the Client

FieldDetails
ActorIdentities::Admin (sales)
TriggerAdmin clicks "Send Invoice to Client"
Source Use CaseAli UC 3.0 — Step 2 (issuance step)

Preconditions:

  • Invoice is in draft status
  • file_path is populated (UC-2a ran)
  • delivery_status is :not_attempted, :failed, or :bounced (retries allowed)
  • Bill-to email is set on the snapshotted bill-to fields

Models Touched: Billing::Invoice

System Behaviour:

  1. Manager calls Mailgun's messages.send API with the PDF attached and the bill-to email as recipient
  2. Manager appends the synchronous Mailgun response to email_provider_response
  3. Branch on Mailgun's response — in the same DB transaction:
    • HTTP 200/202 (accepted): set delivery_status: :queued, email_sent_at = now, transition status: :draft → :issued, set issued_at = now
    • HTTP 4xx/5xx (rejected): set delivery_status: :failed, email_last_failed_at = now; status stays :draft

Business Rules:

  • A successful Mailgun send is the moment of issuance — issuance is not a separate click
  • ref_number becomes immutable from this point
  • Idempotency guard: if email_sent_at is already set within the last 5 seconds, treat as no-op (prevents double-send from double-click)

Postconditions:

  • On accepted send: invoice is :issued, delivery_status: :queued
  • On rejected send: invoice stays :draft, delivery_status: :failed

UC-2c: Process a Mailgun Webhook Event

FieldDetails
ActorSystem (Mailgun → Jod webhook handler)
TriggerMailgun POSTs a delivery webhook event
Source Use CaseAli UC 3.0 (delivery tracking)

Preconditions:

  • Webhook signature is valid (HMAC verified)
  • Event email_type = "billing_invoice_issued" and invoice_uuid resolves to a known invoice

Models Touched: Billing::Invoice

System Behaviour:

  1. Verify HMAC — reject with 401 if invalid
  2. Identify invoice by invoice_uuid from webhook custom variables; return 200 if not found
  3. Check for duplicate event by Mailgun event identifier; return 200 if already processed
  4. Append event to email_provider_response
  5. Update delivery_status per mapping:
Mailgun eventEffect
delivereddelivery_status: :delivered
permanent_faildelivery_status: :bounced, email_last_failed_at = now
temporary_failNo change (Mailgun is retrying)
othersAppend to audit log only

Business Rules:

  • Webhook handler is idempotent — duplicate events are ignored after first processing
  • Stale events are appended to the audit log but do not change delivery_status
  • Commercial status is not changed by webhook events

Postconditions:

  • email_provider_response has one new entry
  • delivery_status may have changed

UC-3: Edit a Draft Invoice Before Issuance

FieldDetails
ActorIdentities::Admin
TriggerAdmin needs to correct details before issuing
Source Use CaseAli UC 3.0 — Step 2

Preconditions:

  • Invoice is in draft status

Models Touched: Billing::Invoice, Billing::InvoiceLine

System Behaviour:

  1. Admin modifies invoice fields (due date, bill-to details) and/or adds, removes, or updates line items
  2. System recomputes subtotal_cents, tax_cents, total_cents
  3. Admin audit trail recorded

Business Rules:

  • Editing is only allowed while status is draft
  • All creation-time validations still apply

Postconditions:

  • Invoice reflects updated values with recomputed totals

UC-4: Record a Bank Transfer Payment

FieldDetails
ActorIdentities::Admin (ops)
TriggerCustomer provides proof of bank transfer
Source Use CaseAli UC 3.0 — Step 3

Preconditions:

  • Invoice is in issued or partially_paid status

Models Touched: Billing::Payment

System Behaviour:

  1. Admin creates a Billing::Payment with:
    • method: :bank_transfer
    • status: :submitted
    • amount_cents
    • bank_reference (from bank statement)
    • proof_s3_path (S3 path)

Business Rules:

  • Multiple payments per invoice are supported
  • A submitted payment does not change the invoice status — verification is required first

Postconditions:

  • Payment exists in submitted status
  • Invoice status unchanged

UC-5: Verify a Payment and Settle the Invoice

FieldDetails
ActorIdentities::Admin (finance)
TriggerFinance confirms money received in the bank account
Source Use CaseAli UC 3.0 — Step 4

Preconditions:

  • Payment is in submitted status
  • Invoice is in issued or partially_paid status

Models Touched: Billing::Payment, Billing::Invoice, Billing::InvoicePosting (when invoice → paid)

System Behaviour:

  1. Finance admin marks the payment as verified
  2. System records verified_at, verified_by_admin_id, and received_at
  3. System recomputes verified_total = sum(amount_cents WHERE status = :verified) for this invoice
  4. System updates invoice status:
    • verified_total == 0 → stays issued
    • 0 < verified_total < total_centspartially_paid
    • verified_total >= total_centspaid, settled_at = now
  5. If invoice becomes paid, the system creates a Billing::InvoicePosting record in the same transaction (UC-7):
    • posted_at = now
    • posted_by_admin_id = verifying admin
    • idempotency_key = "invoice_posting_#{invoice.id}"
    • No entitlement grants yet — those are Phase 3

Business Rules:

  • Only submitted payments can be verified
  • Credits are granted only when the invoice reaches paid — partial payments do not grant partial credits
  • Verification records verified_by_admin_id for audit
  • The UNIQUE constraint on InvoicePosting.billing_invoice_id ensures that if two verify requests race for the same invoice, the second transaction rolls back — no duplicate posting, no duplicate entitlements

Postconditions:

  • Payment status = :verified
  • Invoice status updated per verified total
  • If invoice became paid: Billing::InvoicePosting guard record created (UC-7); entitlement grants deferred to Phase 3 (UC-9)

UC-6: Reject an Invalid Payment

FieldDetails
ActorIdentities::Admin (finance)
TriggerProof is invalid or money was not received
Source Use CaseAli UC 3.0 — Step 4

Preconditions:

  • Payment is in submitted status

Models Touched: Billing::Payment

System Behaviour:

  1. Finance admin marks the payment as rejected

Business Rules:

  • Only submitted payments can be rejected
  • Rejecting a payment does not change the invoice status
  • Ops team can record a new payment to try again

Postconditions:

  • Payment status = :rejected
  • Invoice status unchanged

UC-7: Create InvoicePosting Guard When Invoice Becomes Paid

FieldDetails
ActorSystem (called inside UC-5 verify transaction)
TriggerInvoice transitions to paid during payment verification
Source Use CaseAli UC 3.0 — Step 5 (Phase 2 portion only)

Preconditions:

  • Invoice status is transitioning to paid (i.e., verified_total >= total_cents)
  • No Billing::InvoicePosting exists for this invoice

Models Touched: Billing::InvoicePosting

System Behaviour:

  1. Inside the verify transaction, create Billing::InvoicePosting:
    • posted_at = Time.current
    • posted_by_admin_id = verifying admin ID
    • idempotency_key = "invoice_posting_#{invoice.id}"

Business Rules:

  • Posting creation is inside the same DB transaction as payment verify and invoice status update — if posting fails, the entire verify rolls back
  • UNIQUE constraint on billing_invoice_id prevents double-posting under race conditions
  • No entitlement logic in Phase 2 — the posting record is the guard only

Postconditions:

  • Billing::InvoicePosting record exists with posted_at and posted_by_admin_id
  • No ledger entries written, no entitlement balances updated (Phase 3)

UC-8: Void an Unpaid Invoice

FieldDetails
ActorIdentities::Admin
TriggerInvoice was created in error, or customer cancels before paying
Source Use CaseAli UC 3.0 (invoice void path)

Preconditions:

  • Invoice is in draft or issued status
  • No verified payments exist for this invoice

Models Touched: Billing::Invoice

System Behaviour:

  1. Admin voids the invoice
  2. System transitions invoice to void status

Business Rules:

  • Cannot void an invoice with verified payments
  • Cannot void paid or credited invoices
  • A voided invoice is kept for audit (never deleted)
  • To correct a voided invoice: create a new one
  • The ref_number is consumed on void — it cannot be reused

Postconditions:

  • Invoice status = :void
  • No entitlements are affected

Under Consideration — Pending CTO Review: Should voiding require a reason field? Should submitted (unverified) payments be auto-rejected when an invoice is voided?


Phase 3 Use Cases

The following use cases are fully specified here as the handoff contract for Phase 3. They are not implemented in Phase 2.

Phase 2 handoff state: By the time Phase 3 is implemented, every paid invoice will already have a Billing::InvoicePosting record created by UC-7. Phase 3 extends the posting flow to add entitlement grants — it does not re-create posting records. Phase 3 must also handle any existing posting records from Phase 2 that have no entitlements yet (a backfill migration or catch-up mechanism is recommended).


UC-9: Grant Entitlements on a Paid Invoice

FieldDetails
ActorSystem (extends UC-5 verify transaction)
TriggerBilling::InvoicePosting record created (invoice is paid)
Source Use CaseAli UC 3.0 — Step 5 + Ali UC 4.0

Preconditions:

  • Billing::InvoicePosting record exists for the invoice (created in Phase 2 by UC-7)
  • Invoice status = :paid

Models Touched: Billing::InvoicePosting, Billing::LedgerEntry, Billing::EntitlementBalance

System Behaviour:

  1. Lock the invoice row (SELECT ... FOR UPDATE)
  2. Use the existing Billing::InvoicePosting record created by UC-7
  3. For each Billing::InvoiceLine, look at line_type and the product's entitlement:
    • Placement :principalLedgerEntry(entry_type: :grant, available_delta: +units_to_grant, deferred_revenue_delta_cents: +amount_cents, reference_type: "Billing::InvoicePosting", reference_id: posting.id)
    • Gig :principal (Phase 4 — not in Phase 3 scope) → write grant + create EntitlementLot
    • Gig :platform_fee (Phase 4) → write platform_fee_deferred_delta_cents
  4. Update Billing::EntitlementBalance projections:
    • units_available += units_to_grant
    • deferred_revenue_cents += amount_cents (Placement)
  5. All of the above in a single DB transaction (extended from UC-7's verify transaction)

Business Rules:

  • Posting record already exists from UC-7 — Phase 3 does not create it
  • The UNIQUE constraint on InvoicePosting.billing_invoice_id (set in Phase 2) continues to prevent double-granting
  • Each invoice can only be posted once — enforced at DB level
  • Phase 3 deployment must handle existing Phase 2 posting records with no entitlements yet

Postconditions:

  • Billing::LedgerEntry grant entry recorded against the existing InvoicePosting
  • Billing::EntitlementBalance updated — company can immediately spend credits

UC-10: Reserve Placement Credits When Campaign Is Submitted

FieldDetails
ActorSystem (via Ads domain)
TriggerAds::Campaign submitted for review (status → pending_review)
Source Use CaseAli UC 4.1

Preconditions:

  • Billing::Account exists for the company
  • Billing::EntitlementBalance exists for placement_credit
  • units_available >= credit_cost_snapshot (sum across all placements in the campaign)

Models Touched: Billing::LedgerEntry, Billing::EntitlementHold, Billing::EntitlementBalance

System Behaviour:

For each Ads::CampaignPlacement in the submitted campaign:

  1. Lock Billing::EntitlementBalance for (account, placement_credit) FOR UPDATE
  2. Verify units_available >= credit_cost_snapshot — if not, rollback and return insufficient funds error
  3. Insert Billing::LedgerEntry:
    • entry_type: :reserve
    • available_delta: -credit_cost_snapshot
    • reserved_delta: +credit_cost_snapshot
    • reference_type: "Ads::CampaignPlacement", reference_id: campaign_placement.id
  4. Create Billing::EntitlementHold:
    • status: :active
    • units_held: credit_cost_snapshot
    • opened_at: now
    • reference_type: "Ads::CampaignPlacement", reference_id: campaign_placement.id
  5. Update Billing::EntitlementBalance:
    • units_available -= credit_cost_snapshot
    • units_reserved += credit_cost_snapshot

Business Rules:

  • One active hold per Ads::CampaignPlacement reference (enforced by partial unique index on holds)
  • If balance is insufficient, the campaign cannot be submitted — return a clear error to the employer
  • All placements in a campaign are reserved atomically; if any placement fails, the whole reserve fails

Postconditions:

  • LedgerEntry(entry_type: :reserve) recorded per placement
  • EntitlementHold(status: :active) created per placement
  • EntitlementBalance.units_available decremented
  • EntitlementBalance.units_reserved incremented
  • Campaign proceeds to pending_review

UC-11: Daily Consumption of Placement Credits (Campaign Running)

FieldDetails
ActorSystem (background job)
TriggerDaily job, for each Ads::CampaignPlacement in active status
Source Use CaseAli UC 4.2

Preconditions:

  • Ads::CampaignPlacement.status = :active
  • Active Billing::EntitlementHold exists for this placement with units_held > 0

Models Touched: Billing::LedgerEntry, Billing::EntitlementHold, Billing::EntitlementBalance

System Behaviour:

  1. Lock Billing::EntitlementBalance for (account, placement_credit) FOR UPDATE
  2. Find active hold for Ads::CampaignPlacement#id — confirm units_held > 0
  3. Compute units_to_consume = credit_cost_snapshot / duration_days (1 credit per day for the placement's duration)
  4. Compute revenue recognized:
    • pool_units_before = balance.units_available + balance.units_reserved
    • recognized_cents = units_to_consume × (balance.deferred_revenue_cents / pool_units_before) (integer math, round half-up)
  5. Insert Billing::LedgerEntry:
    • entry_type: :consume
    • reserved_delta: -units_to_consume
    • recognized_revenue_cents: recognized_cents
    • deferred_revenue_delta_cents: -recognized_cents
    • pool_units_before: pool_units_before
    • pool_deferred_revenue_before_cents: balance.deferred_revenue_cents
    • reference_type: "Ads::CampaignPlacement", reference_id: campaign_placement.id
  6. Update Billing::EntitlementBalance:
    • units_reserved -= units_to_consume
    • deferred_revenue_cents -= recognized_cents
  7. Update Billing::EntitlementHold:
    • units_held -= units_to_consume
    • If units_held == 0: mark hold consumed, set closed_at = now

Business Rules:

  • Revenue recognition uses the pooled proportional method: each consumed credit releases a proportional share of the deferred revenue pool
  • Integer math with round-half-up prevents float precision errors
  • Recognized revenue amounts are stored on the ledger entry at consumption time — never recomputed later
  • If the placement completes (all days run), the hold is closed

Postconditions:

  • LedgerEntry(entry_type: :consume) recorded with recognized revenue
  • EntitlementBalance updated: units_reserved down, deferred_revenue_cents down
  • EntitlementHold.units_held decremented (closed if 0)

UC-12: Release Placement Credits on Campaign Cancel or Rejection

FieldDetails
ActorSystem (via Ads domain)
TriggerAds::Campaign cancelled by employer or rejected by admin
Source Use CaseAli UC 4.3

Preconditions:

  • Active Billing::EntitlementHold exists for each affected Ads::CampaignPlacement

Models Touched: Billing::LedgerEntry, Billing::EntitlementHold, Billing::EntitlementBalance

System Behaviour:

For each Ads::CampaignPlacement in the cancelled/rejected campaign:

  1. Find active hold for Ads::CampaignPlacement#id — get units_held
  2. Lock Billing::EntitlementBalance FOR UPDATE
  3. Insert Billing::LedgerEntry:
    • entry_type: :release
    • available_delta: +units_held
    • reserved_delta: -units_held
    • reference_type: "Ads::CampaignPlacement", reference_id: campaign_placement.id
  4. Update Billing::EntitlementBalance:
    • units_available += units_held
    • units_reserved -= units_held
  5. Update Billing::EntitlementHold:
    • status: :released
    • closed_at: now

Business Rules:

  • Credits are released in full when a campaign is cancelled or rejected — regardless of how many days already ran (partially consumed placements release only the remaining held units)
  • Release is idempotent: if the hold is already released or consumed, the operation is a no-op
  • For campaigns that were already active (partially consumed), the remaining units_held is what gets released, not the original credit_cost_snapshot

Postconditions:

  • LedgerEntry(entry_type: :release) recorded per placement
  • EntitlementBalance.units_available incremented by remaining held units
  • EntitlementBalance.units_reserved decremented
  • EntitlementHold.status = :released
  • Credits are immediately available for the company to use on a new campaign