Skip to main content

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 CaseAggregateActorIdempotency Key
UC-01CreateListingDraftListingProviderIdempotency-Key header
UC-02UpdateListingDraftListingProviderlisting.version
UC-03SubmitListingListingProviderstate + Idempotency-Key
UC-04ApproveListingListingPlatform adminstate transition uniqueness
UC-05RejectListingListingPlatform admin
UC-06SuspendListingListingPlatform admin
UC-07ReinstateListingListingPlatform admin
UC-08RetireListingListingProvider (or admin)
UC-09PlaceOrderOrderBuyerIdempotency-Key required
UC-10HandlePaymentSucceededSaga, OrderSystem (event)eventId
UC-11HandlePaymentFailedSaga, OrderSystem (event)eventId
UC-12GrantLicenseLicense (new)System (saga step)orderId + lineId
UC-13HandleEnrollmentCreatedSaga, OrderSystem (event)eventId
UC-14FailSagaSaga, OrderSystemsaga state
UC-15RefundOrderOrder, LicenseBuyer or adminIdempotency-Key
UC-16AssignLicenseSeatLicenseOrg adminlicenseId + userId
UC-17ReleaseLicenseSeatLicenseOrg admin
UC-18RevokeLicenseLicenseAdmin
UC-19RedeemCoupon (validate only, pre-order)CouponBuyerread-only
UC-20CreateCouponCouponProvider or admin
UC-21TriggerPayoutsProviderEarningsSchedulerperiod
UC-22AccrueEarnings (on fulfilled)ProviderEarningsSystemorderId
UC-23AccrueEarningsReversal (on refund)ProviderEarningsSystemorderId
UC-24ParticipateGdprErasureAllSystemsubjectId
UC-25SagaTimeoutTickSagaSchedulersagaId + tickAt

2. Detailed Flows

2.1 UC-09 — PlaceOrder (Saga step 1)

Preconditions

  • Buyer authenticated; tid in JWT matches buyerTenantId.
  • All listed items are live public listings.
  • Each line's pricingPlan is active.
  • Total quantity per line ≤ plan's seats (for seat_pack) or ≤ 1 (for one_time).

Steps

  1. Parse request DTO; resolve Listing + PricingPlan for each line.
  2. Apply coupons via PricingCalculator; compute subtotal, discountTotal.
  3. Reserve coupon usage atomically (Coupon.incrementUsage) — if cap exceeded, abort with COUPON_EXHAUSTED.
  4. Construct Order in status created; construct PurchaseSaga in state started.
  5. Persist order + saga + outbox event marketplace.order.placed.v1 in a single TX.
  6. Publish outbox.
  7. Saga transitions started → awaiting_payment and issues a side-effect command: call billing-service POST /api/v1/payments/process with order.id, order.totals, buyer info.
  8. On billing response: store paymentIntentId on order; transition order to pending_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

  1. Load saga by orderId (from event payload).
  2. If saga state ≠ awaiting_payment, log and skip (idempotency / out-of-order).
  3. Transition order to paid, set paidAt = now, refundDeadline = paidAt + refundDays.
  4. Transition saga to licensing.
  5. For each order line, enqueue GrantLicense command (fan-out inside the same TX via outbox commands-to-self).
  6. Persist, write outbox, publish.

2.3 UC-12 — GrantLicense (Saga step 3)

Per-line.

Steps

  1. Construct License with remainingSeats = line.quantity.
  2. Persist license + outbox event marketplace.license.granted.v1.
  3. 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

  1. Load saga by orderId.
  2. Mark the corresponding line as fulfilled (tracked in saga step history, not on Order aggregate).
  3. When all lines reported: transition order paid → fulfilled, saga enrolling → fulfilled, publish marketplace.order.fulfilled.v1.

2.5 UC-11 — HandlePaymentFailed (compensation)

Consumer of billing.payment.failed.v1.

Steps

  1. Load saga by orderId.
  2. Transition order to failed, saga to failed.
  3. Release any coupon usage acquired in UC-09.
  4. Publish marketplace.order.failed.v1 with reason='payment_failed'.

2.6 UC-25 — SagaTimeoutTick (compensation)

Scheduler polls sagas WHERE state='awaiting_payment' AND awaitingPaymentTimeoutAt <= now every 60s.

Steps per saga

  1. Acquire Redis lock saga:{id}.
  2. Re-check state; if still awaiting_payment and timeout past:
    • Transition order → failed, saga → failed, reason payment_timeout.
    • Call billing POST /api/v1/payments/{id}/cancel (best-effort).
    • Release coupon usage.
    • Publish marketplace.order.failed.v1.
  3. Release lock.

2.7 UC-15 — RefundOrder

Preconditions

  • now <= refundDeadline.
  • Actor is buyer OR has marketplace:refund scope.
  • Order status ∈ {paid, fulfilled}.

Steps

  1. Check eligibility via RefundEligibilityPolicy.
  2. For each license of the order:
    • Revoke all seats not in consumed_on_refund state.
    • Transition license active → revoked if no consumed seats, else keep active with reduced seats (partial-refund policy). MVP: full-refund only; partial-refund in S5.
  3. Transition order → refunded.
  4. Publish marketplace.order.refunded.v1 and marketplace.license.revoked.v1 per license.
  5. Call billing POST /api/v1/payments/{id}/refund (billing emits .refunded when Stripe confirms).
  6. Accrue reversal earnings.

2.8 UC-16 — AssignLicenseSeat

Preconditions

  • License state === 'active' and scope === 'org'.
  • remainingSeats > 0.
  • Target user belongs to license.tenantId.
  • Caller has tenant:admin scope.

Steps

  1. Domain method License.allocateSeat(userId) creates SeatAllocation.
  2. 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

  1. For each ProviderEarnings with state='ready' for the closing period:
    • Call billing POST /api/v1/payouts/trigger with provider, amount, currency, bank details.
    • Update earnings → state='paid', store payoutId.
  2. 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.refund command (if payment succeeded but licensing or enrollment failed)

4. Idempotency

ChannelKeyTTL
HTTP mutating requestsIdempotency-Key header + route + body hash24h in Redis
Event consumerseventId in processed_events table30 days
Saga side-effectssaga.currentStep + version — re-entry is safeforever
Coupon redemptionAtomic SQL UPDATE ... WHERE usage_count < usage_capn/a

5. Concurrency Model

  • Optimistic locking on all aggregates via version column.
  • 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

JobSchedulePurpose
saga-timeout-tickevery 60sFire UC-25 for timed-out sagas
license-expiry-sweepevery 5 minTransition active → expired for validUntil <= now
monthly-payouts0 2 1 * *UC-21
earnings-close0 1 1 * *Close previous month's accruing → ready
outbox-relay200ms loopPublish unacked outbox rows to NATS
processed-events-prunedailyDelete processed_events older than 30d

7. Cross-cutting Concerns

  • Every use case opens an OTel span with tenant.id, order.id, saga.id attributes 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:

  1. Anonymize Order.buyerUserIdREDACTED_<hash>; keep order totals for tax/finance retention (7 years).
  2. If the subject is also the seat-allocation target, remove the allocation row; keep license counts intact.
  3. Emit gdpr.subject_request.marketplace.erased.v1 to the GDPR saga coordinator.