Application Logic
:::info Source
Sourced from services/marketplace-service/APPLICATION_LOGIC.md in the documentation repo.
:::
Companion: DOMAIN_MODEL · EVENT_SCHEMAS · FAILURE_MODES
1. Use Case Catalogue
Use cases are thin orchestration over domain methods. Each use case = single transaction around a single aggregate + outbox write.
| # | Use Case | Aggregate | Actor | Idempotency Key |
|---|---|---|---|---|
| UC-01 | CreateListingDraft | Listing | Provider | Idempotency-Key header |
| UC-02 | UpdateListingDraft | Listing | Provider | listing.version |
| UC-03 | SubmitListing | Listing | Provider | state + Idempotency-Key |
| UC-04 | ApproveListing | Listing | Platform admin | state transition uniqueness |
| UC-05 | RejectListing | Listing | Platform admin | |
| UC-06 | SuspendListing | Listing | Platform admin | |
| UC-07 | ReinstateListing | Listing | Platform admin | |
| UC-08 | RetireListing | Listing | Provider (or admin) | |
| UC-09 | PlaceOrder | Order | Buyer | Idempotency-Key required |
| UC-10 | HandlePaymentSucceeded | Saga, Order | System (event) | eventId |
| UC-11 | HandlePaymentFailed | Saga, Order | System (event) | eventId |
| UC-12 | GrantLicense | License (new) | System (saga step) | orderId + lineId |
| UC-13 | HandleEnrollmentCreated | Saga, Order | System (event) | eventId |
| UC-14 | FailSaga | Saga, Order | System | saga state |
| UC-15 | RefundOrder | Order, License | Buyer or admin | Idempotency-Key |
| UC-16 | AssignLicenseSeat | License | Org admin | licenseId + userId |
| UC-17 | ReleaseLicenseSeat | License | Org admin | |
| UC-18 | RevokeLicense | License | Admin | |
| UC-19 | RedeemCoupon (validate only, pre-order) | Coupon | Buyer | read-only |
| UC-20 | CreateCoupon | Coupon | Provider or admin | |
| UC-21 | TriggerPayouts | ProviderEarnings | Scheduler | period |
| UC-22 | AccrueEarnings (on fulfilled) | ProviderEarnings | System | orderId |
| UC-23 | AccrueEarningsReversal (on refund) | ProviderEarnings | System | orderId |
| UC-24 | ParticipateGdprErasure | All | System | subjectId |
| UC-25 | SagaTimeoutTick | Saga | Scheduler | sagaId + tickAt |
2. Detailed Flows
2.1 UC-09 — PlaceOrder (Saga step 1)
Preconditions
- Buyer authenticated;
tidin JWT matchesbuyerTenantId. - All listed items are
livepublic listings. - Each line's
pricingPlanisactive. - Total quantity per line ≤ plan's
seats(for seat_pack) or ≤ 1 (for one_time).
Steps
- Parse request DTO; resolve
Listing+PricingPlanfor each line. - Apply coupons via
PricingCalculator; computesubtotal,discountTotal. - Reserve coupon usage atomically (
Coupon.incrementUsage) — if cap exceeded, abort withCOUPON_EXHAUSTED. - Construct
Orderin statuscreated; constructPurchaseSagain statestarted. - Persist order + saga + outbox event
marketplace.order.placed.v1in a single TX. - Publish outbox.
- Saga transitions
started → awaiting_paymentand issues a side-effect command: callbilling-service POST /api/v1/payments/processwithorder.id,order.totals, buyer info. - On billing response: store
paymentIntentIdon order; transition order topending_payment. This happens synchronously in the PlaceOrder request for a good UX.
Response — 201 Created, returns { orderId, paymentIntentClientSecret } so the frontend can complete payment.
Failure modes — see FAILURE_MODES §3.1.
2.2 UC-10 — HandlePaymentSucceeded (Saga step 2→3)
Consumer of billing.payment.succeeded.v1.
Idempotent by eventId. Processed-events table prevents double-handling.
Steps
- Load saga by
orderId(from event payload). - If saga state ≠
awaiting_payment, log and skip (idempotency / out-of-order). - Transition order to
paid, setpaidAt = now,refundDeadline = paidAt + refundDays. - Transition saga to
licensing. - For each order line, enqueue
GrantLicensecommand (fan-out inside the same TX via outbox commands-to-self). - Persist, write outbox, publish.
2.3 UC-12 — GrantLicense (Saga step 3)
Per-line.
Steps
- Construct
LicensewithremainingSeats = line.quantity. - Persist license + outbox event
marketplace.license.granted.v1. - Advance saga step record.
Enrollment-service consumes license.granted.v1 and creates enrollments for seat allocations (in org scope, seats may not be auto-assigned; in individual scope, buyer is auto-assigned).
2.4 UC-13 — HandleEnrollmentCreated (Saga step 4→5)
Consumer of enrollment.created.v1.
Steps
- Load saga by
orderId. - Mark the corresponding line as
fulfilled(tracked in saga step history, not on Order aggregate). - When all lines reported: transition order
paid → fulfilled, sagaenrolling → fulfilled, publishmarketplace.order.fulfilled.v1.
2.5 UC-11 — HandlePaymentFailed (compensation)
Consumer of billing.payment.failed.v1.
Steps
- Load saga by
orderId. - Transition order to
failed, saga tofailed. - Release any coupon usage acquired in UC-09.
- Publish
marketplace.order.failed.v1withreason='payment_failed'.
2.6 UC-25 — SagaTimeoutTick (compensation)
Scheduler polls sagas WHERE state='awaiting_payment' AND awaitingPaymentTimeoutAt <= now every 60s.
Steps per saga
- Acquire Redis lock
saga:{id}. - Re-check state; if still
awaiting_paymentand timeout past:- Transition order →
failed, saga →failed, reasonpayment_timeout. - Call billing
POST /api/v1/payments/{id}/cancel(best-effort). - Release coupon usage.
- Publish
marketplace.order.failed.v1.
- Transition order →
- Release lock.
2.7 UC-15 — RefundOrder
Preconditions
now <= refundDeadline.- Actor is buyer OR has
marketplace:refundscope. - Order status ∈ {
paid,fulfilled}.
Steps
- Check eligibility via
RefundEligibilityPolicy. - For each license of the order:
- Revoke all seats not in
consumed_on_refundstate. - Transition license
active → revokedif no consumed seats, else keepactivewith reduced seats (partial-refund policy). MVP: full-refund only; partial-refund in S5.
- Revoke all seats not in
- Transition order →
refunded. - Publish
marketplace.order.refunded.v1andmarketplace.license.revoked.v1per license. - Call billing
POST /api/v1/payments/{id}/refund(billing emits.refundedwhen Stripe confirms). - Accrue reversal earnings.
2.8 UC-16 — AssignLicenseSeat
Preconditions
- License
state === 'active'andscope === 'org'. remainingSeats > 0.- Target user belongs to
license.tenantId. - Caller has
tenant:adminscope.
Steps
- Domain method
License.allocateSeat(userId)createsSeatAllocation. - Persist + outbox
marketplace.license.seat_assigned.v1(enrollment-service listens and creates enrollment).
2.9 UC-21 — TriggerPayouts
Trigger — Cron 0 2 1 * * (monthly), plus admin-triggerable.
Steps
- For each
ProviderEarningswithstate='ready'for the closing period:- Call billing
POST /api/v1/payouts/triggerwith provider, amount, currency, bank details. - Update earnings →
state='paid', storepayoutId.
- Call billing
- Publish
marketplace.payout.initiated.v1(billing handles the actual money movement).
3. Saga Orchestrator — State Machine (authoritative)
┌─────────┐
│ started │
└────┬────┘
│ order.placed published
▼
┌────────────────────┐
│ awaiting_payment │◄── payment intent pending
└────┬──────────────┬┘
│ │
payment.succeeded payment.failed OR timeout(30m)
│ │
▼ ▼
┌──────────┐ ┌────────┐
│licensing │ │ failed │
└────┬─────┘ └────────┘
│
licenses granted
▼
┌──────────┐
│enrolling │
└────┬─────┘
│
enrollments created for all lines
▼
┌──────────┐
│fulfilled │ (terminal success)
└──────────┘
Compensation (failed terminal) may emit:
marketplace.license.revoked.v1(if any license was granted before failure)billing.payment.refundcommand (if payment succeeded but licensing or enrollment failed)
4. Idempotency
| Channel | Key | TTL |
|---|---|---|
| HTTP mutating requests | Idempotency-Key header + route + body hash | 24h in Redis |
| Event consumers | eventId in processed_events table | 30 days |
| Saga side-effects | saga.currentStep + version — re-entry is safe | forever |
| Coupon redemption | Atomic SQL UPDATE ... WHERE usage_count < usage_cap | n/a |
5. Concurrency Model
- Optimistic locking on all aggregates via
versioncolumn. - Pessimistic Redis lock around saga mutation keyed by
saga:{id}with 10s TTL to prevent duplicate step execution when multiple events arrive near-simultaneously. - Serializable TX for coupon redemption + order placement (short transaction).
6. Background Jobs
| Job | Schedule | Purpose |
|---|---|---|
saga-timeout-tick | every 60s | Fire UC-25 for timed-out sagas |
license-expiry-sweep | every 5 min | Transition active → expired for validUntil <= now |
monthly-payouts | 0 2 1 * * | UC-21 |
earnings-close | 0 1 1 * * | Close previous month's accruing → ready |
outbox-relay | 200ms loop | Publish unacked outbox rows to NATS |
processed-events-prune | daily | Delete processed_events older than 30d |
7. Cross-cutting Concerns
- Every use case opens an OTel span with
tenant.id,order.id,saga.idattributes where applicable. - Structured error codes are returned per API; see
API_CONTRACTS.md§8. - Authorization guard runs before use case invocation; use cases trust their inputs are authorized.
- Event publication is never inline with HTTP response — only via outbox.
8. Example: End-to-end Purchase
t=0 POST /orders UC-09
├─ Order.created, saga.started
└─ outbox: order.placed.v1
t=0.5s sync call to billing /payments/process
└─ billing returns paymentIntentClientSecret
t=5s Frontend confirms card with Stripe.js
t=8s Stripe webhook → billing → event billing.payment.succeeded.v1
└─ UC-10 fires; order.paid; saga.licensing; commands: GrantLicense×N
t=8.1s GrantLicense executes per line
└─ outbox: license.granted.v1
└─ saga: enrolling
t=8.3s enrollment-service consumes license.granted
└─ creates enrollment; publishes enrollment.created.v1
t=8.5s UC-13 consumes enrollment.created
└─ all lines satisfied → order.fulfilled; saga.fulfilled
└─ outbox: order.fulfilled.v1
└─ notification-service emails receipt
9. GDPR Erasure Participation (UC-24)
On gdpr.subject_request.received.v1 for subjectUserId:
- Anonymize
Order.buyerUserId→REDACTED_<hash>; keep order totals for tax/finance retention (7 years). - If the subject is also the seat-allocation target, remove the allocation row; keep license counts intact.
- Emit
gdpr.subject_request.marketplace.erased.v1to the GDPR saga coordinator.