Refunds are money reversal — not an admin shortcut around Stripe.
DreamBees Art routes every refund through RefundApplicationService. Admin buttons, Concierge tools, and future automations call services.refunds.createRefund() — never RefundService.processRefund() directly.
Policy: commerce-protocol-frozen.md · Stories: flows.md § Refund flow
| You want to… | Jump to |
|---|---|
| Trace admin refund click | Admin-initiated flow |
| Trace Concierge refund | Concierge-initiated flow |
| Understand idempotency | Idempotency |
| Look up API fields | Public API |
| Run seal tests | Verification |
| Shopify concept | DreamBees Art implementation |
|---|---|
| Refund in admin | services.admin.requestRefund → services.refunds.createRefund |
| Partial refund | amount in cents on createRefund |
| Restock on refund | RefundService → inventory.applyInventoryDeltas (when policy requires) |
| Refund idempotency | refund_execution_claims + order processedRefundKeys |
flowchart TD
A[Refund request] --> B{Entry point}
B -->|Admin UI| C[admin.requestRefund]
B -->|Concierge tool| D[refunds.createRefund direct]
C --> E{Elevation + reason?}
E -->|no| F[AdminResult error]
E -->|yes| G[refunds.createRefund]
D --> G
G --> H{Idempotency claim}
H -->|completed| I[Return duplicate ok]
H -->|new| J[RefundService.processRefund]
J --> K[Stripe refund API]
J --> L[Order metadata update]
G --> M[refund_execution_events]
C --> N[operator_action_events]
Operator (elevated) → POST /api/admin/orders/[id]/refund
Input: amount, reason, idempotencyKey
services.admin.requestRefund
✓ actor.elevated
✓ reason non-empty
✓ claim admin operator event
services.refunds.createRefund({ source: 'admin', ... })
✓ claim refund_execution
✓ RefundService → Stripe
✓ recordExecution in event log
operator event marked completed
UI must send a stable idempotency key per refund intent (e.g. client UUID). Retries with the same key must not double-refund.
Chat → [PROCESS_REFUND: "orderId", amount_cents]
✓ customer authenticated
✓ amount ≤ MAX_CONCIERGE_REFUND_CENTS
✓ validateToolCall('processRefund', ...)
services.refunds.createRefund({
source: 'concierge',
actor: { id: 'concierge', email: '...' },
reason: 'Concierge autonomous refund for session …',
idempotencyKey: 'concierge-refund-{session}-{order}-{amount}',
})
Concierge skips admin.requestRefund but still hits the same refund protocol and Stripe path. Event log records source: 'concierge'.
Details: concierge/overview.md
HTTP route / Concierge tool / Admin action
→ services.refunds.createRefund() (RefundApplicationService)
→ RefundResult<T>
→ refundRouteAdapter (admin routes) or inline result handling (Concierge)
→ RefundService.processRefund() (internal only)
→ StripePaymentProcessor.refundPayment()
→ FirestoreRefundEventLog (idempotency + audit)
Interface: RefundApplicationService (src/core/refund/refundApplicationService.ts)
Implementation: RefundFlowService (src/core/refund/RefundFlowService.ts)
Container export: services.refunds
| Field | Required | Notes |
|---|---|---|
orderId |
yes | Target order |
amount |
yes | Positive integer cents |
idempotencyKey |
yes | Dedup across retries |
reason |
yes | Operator or derived Concierge reason |
actor |
yes | { id, email } — admin user or system actor |
source |
optional | 'admin' | 'concierge' | 'system' — stored in event log |
Success data: { orderId, amount, status, stripeRefundId?, idempotencyKey }
Result: RefundResult<T> — failures are typed, not thrown.
Read model: refunded amount, refundable balance, processed keys, Stripe refund entries.
| Store | Collection | Purpose |
|---|---|---|
| Claims | refund_execution_claims |
In-flight / completed dedup |
| Events | refund_execution_events |
Audit record per successful execution |
Duplicate createRefund with the same key returns { ok: true, duplicate: true } without a second Stripe refund.
Order metadata also tracks processedRefundKeys and metadata.stripeRefunds for forensic reads.
AdminFlowService.requestRefund requires:
- Elevated admin actor
- Non-empty
reason idempotencyKey- Operator event recorded on success
Routes:
| Route | Method |
|---|---|
/api/admin/orders/[id]/refund |
POST |
/api/admin/orders/[id] |
PATCH (refund status path) |
Both use services.admin.requestRefund — no refundService import.
| Guard | Behavior |
|---|---|
| Customer auth | Refunds require logged-in customer |
| Amount cap | MAX_CONCIERGE_REFUND_CENTS — escalate above |
| Tool validation | validateToolCall('processRefund', ...) |
| Protocol validation | actor, reason, idempotencyKey enforced in RefundFlowService |
| Module | Role |
|---|---|
RefundService |
Stripe + order metadata updates |
RefundFlowService |
Public orchestration |
createRefundStack() |
Factory |
FirestoreRefundEventLog |
Claims and events |
refundRouteAdapter.ts |
HTTP mapping for admin refund routes |
npm test -- --run src/tests/refund-verification-ladder.test.ts| Invariant | Proof |
|---|---|
| Idempotency key required | Validation test |
| Actor required | Validation test |
| Reason required | Validation test |
| Duplicate key no double refund | processRefund called once |
| Event log records execution | In-memory event log assertion |
| Concierge source tagged | Event source: 'concierge' |
| Admin routes sealed | No refundService in route files |
| Concierge route sealed | Uses refunds.createRefund |
- One public API:
RefundApplicationServiceviaservices.refunds - One result type:
RefundResult<T> - One construction path:
createRefundStack() - No direct
RefundServicefrom routes, tools, or admin handlers - Admin always passes through
requestRefundfor operator authorization
Extend behavior inside RefundFlowService and RefundService. Do not add parallel refund entry points.
src/core/refund/
refundApplicationService.ts
refundResult.ts
RefundFlowService.ts
createRefundStack.ts
refundEventLog.ts
src/core/RefundService.ts
src/infrastructure/refund/FirestoreRefundEventLog.ts
src/infrastructure/server/refundRouteAdapter.ts
src/app/api/admin/orders/[id]/refund/route.ts
src/app/api/concierge/chat/route.ts