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 Case | Actor | Trigger |
|---|---|---|---|
| UC-1 | Create Draft Invoice | Admin (Sales/Ops) | Company wants to purchase Placement Credits |
| UC-2a | Generate Invoice PDF | Admin (Sales) | Admin clicks "Generate Invoice File" on a draft invoice |
| UC-2b | Send Invoice to Client | Admin (Sales) | Admin clicks "Send Invoice to Client" |
| UC-2c | Process Mailgun Webhook | System | Mailgun POSTs a delivery event webhook to Jod |
| UC-3 | Edit Draft Invoice | Admin | Admin needs to correct details before issuing |
| UC-4 | Record Bank Transfer | Admin (Ops) | Customer provides proof of bank transfer |
| UC-5 | Verify Payment | Admin (Finance) | Finance confirms money received in the bank account |
| UC-6 | Reject Payment | Admin (Finance) | Proof is invalid or money was not received |
| UC-7 | Create InvoicePosting Guard | System (via UC-5) | Invoice transitions to paid — creates posting record, no entitlements |
| UC-8 | Void Unpaid Invoice | Admin | Invoice was created in error, or customer cancels before paying |
Phase 3 (entitlement engine + Ads enforcement — specified here for handoff):
| # | Use Case | Actor | Trigger |
|---|---|---|---|
| UC-9 | Grant Entitlements on Paid Invoice | System (auto-triggered) | InvoicePosting record exists — extend with LedgerEntry + balance writes |
| UC-10 | Reserve Credits on Campaign Submit | System (via Ads domain) | Ads::Campaign submitted for review (status → pending_review) |
| UC-11 | Daily Consume Credits | System (background job) | Daily job runs for each Ads::CampaignPlacement in active status |
| UC-12 | Release Credits on Cancel / Reject | System (via Ads domain) | Campaign cancelled by employer or rejected by admin |
UC-1: Create a Draft Invoice for a Company's Credit Purchase
| Field | Details |
|---|---|
| Actor | Identities::Admin (sales/ops) |
| Trigger | Company wants to purchase Placement Credits |
| Source Use Case | Ali UC 3.0 — Admin-initiated Purchase Flow (Step 2) |
Preconditions:
Billing::Accountexists for theOrg::Company- An active
Billing::Agreementexists with relevant terms (or terms come from list price) Billing::ProductandBilling::ProductPriceexist for the desired entitlement and marketBilling::LegalEntityexists for the seller jurisdictionBilling::BillToProfileexists for the buyer
Models Touched: Billing::Invoice, Billing::InvoiceLine
System Behaviour:
- Admin selects the billing account, product, and quantity
- System resolves the applicable
Billing::ProductPrice:country_idmatching the company's country (viabilling_legal_entity.country_id)status = :active(and parent productstatus = :active)- Private price (
billing_account_idmatches) overrides standard price (billing_account_id IS NULL)
- System resolves the active
Billing::Agreementto obtain negotiated terms (fee rates, unit prices, discounts) - System generates
Billing::Invoiceindraftstatus:- Snapshots bill-to fields from
Billing::BillToProfile - Sets
currencyfrom the bill-to profile - Associates the seller
Billing::LegalEntity - Admin enters
ref_number(finance-allocated from Xero). System validates global uniqueness
- Snapshots bill-to fields from
- System creates
Billing::InvoiceLinerows:- 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
- Placement Credits: 1 line,
- System computes and stores
subtotal_cents,tax_cents,total_centson the invoice - 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_numberis globally unique across all Jod invoices (GST compliance requirement)ref_numberis 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
draftstatus with computed totals - No payments or postings exist yet
- No entitlements are granted
UC-2a: Generate the Invoice File (PDF)
| Field | Details |
|---|---|
| Actor | Identities::Admin (sales) |
| Trigger | Admin clicks "Generate Invoice File" on a draft invoice |
| Source Use Case | Ali UC 3.0 — Step 2 (invoice review before issuance) |
Preconditions:
- Invoice is in
draftstatus - Invoice has at least one line item
- All required snapshot fields are populated
Models Touched: Billing::Invoice
System Behaviour:
- Background job renders the invoice PDF from the snapshotted invoice fields
- Job stores the S3 key at
file_pathand stampsfile_generated_at statusremains:draft.delivery_statusremains:not_attempted- 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_pathis 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_pathandfile_generated_atare populated- Status unchanged
UC-2b: Send the Invoice to the Client
| Field | Details |
|---|---|
| Actor | Identities::Admin (sales) |
| Trigger | Admin clicks "Send Invoice to Client" |
| Source Use Case | Ali UC 3.0 — Step 2 (issuance step) |
Preconditions:
- Invoice is in
draftstatus file_pathis populated (UC-2a ran)delivery_statusis:not_attempted,:failed, or:bounced(retries allowed)- Bill-to email is set on the snapshotted bill-to fields
Models Touched: Billing::Invoice
System Behaviour:
- Manager calls Mailgun's
messages.sendAPI with the PDF attached and the bill-to email as recipient - Manager appends the synchronous Mailgun response to
email_provider_response - Branch on Mailgun's response — in the same DB transaction:
- HTTP 200/202 (accepted): set
delivery_status: :queued,email_sent_at = now, transitionstatus: :draft → :issued, setissued_at = now - HTTP 4xx/5xx (rejected): set
delivery_status: :failed,email_last_failed_at = now; status stays:draft
- HTTP 200/202 (accepted): set
Business Rules:
- A successful Mailgun send is the moment of issuance — issuance is not a separate click
ref_numberbecomes immutable from this point- Idempotency guard: if
email_sent_atis 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
| Field | Details |
|---|---|
| Actor | System (Mailgun → Jod webhook handler) |
| Trigger | Mailgun POSTs a delivery webhook event |
| Source Use Case | Ali UC 3.0 (delivery tracking) |
Preconditions:
- Webhook signature is valid (HMAC verified)
- Event
email_type = "billing_invoice_issued"andinvoice_uuidresolves to a known invoice
Models Touched: Billing::Invoice
System Behaviour:
- Verify HMAC — reject with 401 if invalid
- Identify invoice by
invoice_uuidfrom webhook custom variables; return 200 if not found - Check for duplicate event by Mailgun event identifier; return 200 if already processed
- Append event to
email_provider_response - Update
delivery_statusper mapping:
| Mailgun event | Effect |
|---|---|
delivered | delivery_status: :delivered |
permanent_fail | delivery_status: :bounced, email_last_failed_at = now |
temporary_fail | No change (Mailgun is retrying) |
| others | Append 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
statusis not changed by webhook events
Postconditions:
email_provider_responsehas one new entrydelivery_statusmay have changed
UC-3: Edit a Draft Invoice Before Issuance
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Admin needs to correct details before issuing |
| Source Use Case | Ali UC 3.0 — Step 2 |
Preconditions:
- Invoice is in
draftstatus
Models Touched: Billing::Invoice, Billing::InvoiceLine
System Behaviour:
- Admin modifies invoice fields (due date, bill-to details) and/or adds, removes, or updates line items
- System recomputes
subtotal_cents,tax_cents,total_cents - 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
| Field | Details |
|---|---|
| Actor | Identities::Admin (ops) |
| Trigger | Customer provides proof of bank transfer |
| Source Use Case | Ali UC 3.0 — Step 3 |
Preconditions:
- Invoice is in
issuedorpartially_paidstatus
Models Touched: Billing::Payment
System Behaviour:
- Admin creates a
Billing::Paymentwith:method: :bank_transferstatus: :submittedamount_centsbank_reference(from bank statement)proof_s3_path(S3 path)
Business Rules:
- Multiple payments per invoice are supported
- A
submittedpayment does not change the invoice status — verification is required first
Postconditions:
- Payment exists in
submittedstatus - Invoice status unchanged
UC-5: Verify a Payment and Settle the Invoice
| Field | Details |
|---|---|
| Actor | Identities::Admin (finance) |
| Trigger | Finance confirms money received in the bank account |
| Source Use Case | Ali UC 3.0 — Step 4 |
Preconditions:
- Payment is in
submittedstatus - Invoice is in
issuedorpartially_paidstatus
Models Touched: Billing::Payment, Billing::Invoice, Billing::InvoicePosting (when invoice → paid)
System Behaviour:
- Finance admin marks the payment as
verified - System records
verified_at,verified_by_admin_id, andreceived_at - System recomputes
verified_total = sum(amount_cents WHERE status = :verified)for this invoice - System updates invoice status:
verified_total == 0→ staysissued0 < verified_total < total_cents→partially_paidverified_total >= total_cents→paid,settled_at = now
- If invoice becomes
paid, the system creates aBilling::InvoicePostingrecord in the same transaction (UC-7):posted_at = nowposted_by_admin_id = verifying adminidempotency_key = "invoice_posting_#{invoice.id}"- No entitlement grants yet — those are Phase 3
Business Rules:
- Only
submittedpayments can be verified - Credits are granted only when the invoice reaches
paid— partial payments do not grant partial credits - Verification records
verified_by_admin_idfor audit - The UNIQUE constraint on
InvoicePosting.billing_invoice_idensures 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::InvoicePostingguard record created (UC-7); entitlement grants deferred to Phase 3 (UC-9)
UC-6: Reject an Invalid Payment
| Field | Details |
|---|---|
| Actor | Identities::Admin (finance) |
| Trigger | Proof is invalid or money was not received |
| Source Use Case | Ali UC 3.0 — Step 4 |
Preconditions:
- Payment is in
submittedstatus
Models Touched: Billing::Payment
System Behaviour:
- Finance admin marks the payment as
rejected
Business Rules:
- Only
submittedpayments 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
| Field | Details |
|---|---|
| Actor | System (called inside UC-5 verify transaction) |
| Trigger | Invoice transitions to paid during payment verification |
| Source Use Case | Ali UC 3.0 — Step 5 (Phase 2 portion only) |
Preconditions:
- Invoice
statusis transitioning topaid(i.e.,verified_total >= total_cents) - No
Billing::InvoicePostingexists for this invoice
Models Touched: Billing::InvoicePosting
System Behaviour:
- Inside the verify transaction, create
Billing::InvoicePosting:posted_at = Time.currentposted_by_admin_id = verifying admin IDidempotency_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_idprevents double-posting under race conditions - No entitlement logic in Phase 2 — the posting record is the guard only
Postconditions:
Billing::InvoicePostingrecord exists withposted_atandposted_by_admin_id- No ledger entries written, no entitlement balances updated (Phase 3)
UC-8: Void an Unpaid Invoice
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Invoice was created in error, or customer cancels before paying |
| Source Use Case | Ali UC 3.0 (invoice void path) |
Preconditions:
- Invoice is in
draftorissuedstatus - No verified payments exist for this invoice
Models Touched: Billing::Invoice
System Behaviour:
- Admin voids the invoice
- System transitions invoice to
voidstatus
Business Rules:
- Cannot void an invoice with verified payments
- Cannot void
paidorcreditedinvoices - A voided invoice is kept for audit (never deleted)
- To correct a voided invoice: create a new one
- The
ref_numberis 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::InvoicePostingrecord 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
| Field | Details |
|---|---|
| Actor | System (extends UC-5 verify transaction) |
| Trigger | Billing::InvoicePosting record created (invoice is paid) |
| Source Use Case | Ali UC 3.0 — Step 5 + Ali UC 4.0 |
Preconditions:
Billing::InvoicePostingrecord exists for the invoice (created in Phase 2 by UC-7)- Invoice
status = :paid
Models Touched: Billing::InvoicePosting, Billing::LedgerEntry, Billing::EntitlementBalance
System Behaviour:
- Lock the invoice row (
SELECT ... FOR UPDATE) - Use the existing
Billing::InvoicePostingrecord created by UC-7 - For each
Billing::InvoiceLine, look atline_typeand the product's entitlement:- Placement
:principal→LedgerEntry(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 + createEntitlementLot - Gig
:platform_fee(Phase 4) → writeplatform_fee_deferred_delta_cents
- Placement
- Update
Billing::EntitlementBalanceprojections:units_available += units_to_grantdeferred_revenue_cents += amount_cents(Placement)
- 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::LedgerEntrygrant entry recorded against the existingInvoicePostingBilling::EntitlementBalanceupdated — company can immediately spend credits
UC-10: Reserve Placement Credits When Campaign Is Submitted
| Field | Details |
|---|---|
| Actor | System (via Ads domain) |
| Trigger | Ads::Campaign submitted for review (status → pending_review) |
| Source Use Case | Ali UC 4.1 |
Preconditions:
Billing::Accountexists for the companyBilling::EntitlementBalanceexists forplacement_creditunits_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:
- Lock
Billing::EntitlementBalancefor(account, placement_credit)FOR UPDATE - Verify
units_available >= credit_cost_snapshot— if not, rollback and return insufficient funds error - Insert
Billing::LedgerEntry:entry_type: :reserveavailable_delta: -credit_cost_snapshotreserved_delta: +credit_cost_snapshotreference_type: "Ads::CampaignPlacement",reference_id: campaign_placement.id
- Create
Billing::EntitlementHold:status: :activeunits_held: credit_cost_snapshotopened_at: nowreference_type: "Ads::CampaignPlacement",reference_id: campaign_placement.id
- Update
Billing::EntitlementBalance:units_available -= credit_cost_snapshotunits_reserved += credit_cost_snapshot
Business Rules:
- One active hold per
Ads::CampaignPlacementreference (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 placementEntitlementHold(status: :active)created per placementEntitlementBalance.units_availabledecrementedEntitlementBalance.units_reservedincremented- Campaign proceeds to
pending_review
UC-11: Daily Consumption of Placement Credits (Campaign Running)
| Field | Details |
|---|---|
| Actor | System (background job) |
| Trigger | Daily job, for each Ads::CampaignPlacement in active status |
| Source Use Case | Ali UC 4.2 |
Preconditions:
Ads::CampaignPlacement.status = :active- Active
Billing::EntitlementHoldexists for this placement withunits_held > 0
Models Touched: Billing::LedgerEntry, Billing::EntitlementHold, Billing::EntitlementBalance
System Behaviour:
- Lock
Billing::EntitlementBalancefor(account, placement_credit)FOR UPDATE - Find active hold for
Ads::CampaignPlacement#id— confirmunits_held > 0 - Compute
units_to_consume = credit_cost_snapshot / duration_days(1 credit per day for the placement's duration) - Compute revenue recognized:
pool_units_before = balance.units_available + balance.units_reservedrecognized_cents = units_to_consume × (balance.deferred_revenue_cents / pool_units_before)(integer math, round half-up)
- Insert
Billing::LedgerEntry:entry_type: :consumereserved_delta: -units_to_consumerecognized_revenue_cents: recognized_centsdeferred_revenue_delta_cents: -recognized_centspool_units_before: pool_units_beforepool_deferred_revenue_before_cents: balance.deferred_revenue_centsreference_type: "Ads::CampaignPlacement",reference_id: campaign_placement.id
- Update
Billing::EntitlementBalance:units_reserved -= units_to_consumedeferred_revenue_cents -= recognized_cents
- Update
Billing::EntitlementHold:units_held -= units_to_consume- If
units_held == 0: mark holdconsumed, setclosed_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 revenueEntitlementBalanceupdated:units_reserveddown,deferred_revenue_centsdownEntitlementHold.units_helddecremented (closed if 0)
UC-12: Release Placement Credits on Campaign Cancel or Rejection
| Field | Details |
|---|---|
| Actor | System (via Ads domain) |
| Trigger | Ads::Campaign cancelled by employer or rejected by admin |
| Source Use Case | Ali UC 4.3 |
Preconditions:
- Active
Billing::EntitlementHoldexists for each affectedAds::CampaignPlacement
Models Touched: Billing::LedgerEntry, Billing::EntitlementHold, Billing::EntitlementBalance
System Behaviour:
For each Ads::CampaignPlacement in the cancelled/rejected campaign:
- Find active hold for
Ads::CampaignPlacement#id— getunits_held - Lock
Billing::EntitlementBalanceFOR UPDATE - Insert
Billing::LedgerEntry:entry_type: :releaseavailable_delta: +units_heldreserved_delta: -units_heldreference_type: "Ads::CampaignPlacement",reference_id: campaign_placement.id
- Update
Billing::EntitlementBalance:units_available += units_heldunits_reserved -= units_held
- Update
Billing::EntitlementHold:status: :releasedclosed_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
releasedorconsumed, the operation is a no-op - For campaigns that were already active (partially consumed), the remaining
units_heldis what gets released, not the originalcredit_cost_snapshot
Postconditions:
LedgerEntry(entry_type: :release)recorded per placementEntitlementBalance.units_availableincremented by remaining held unitsEntitlementBalance.units_reserveddecrementedEntitlementHold.status = :released- Credits are immediately available for the company to use on a new campaign