@sguild/dispatcher 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/contracts/README.md +30 -0
- package/contracts/coach-availability/README.md +355 -0
- package/contracts/coach-availability/README.v2.md +263 -0
- package/contracts/coach-availability/schema/payloads/coach.assigned-v1.json +91 -0
- package/contracts/coach-availability/validation/delivery-assignment.md +89 -0
- package/contracts/coach-availability/validation/sales-offer-construction.md +76 -0
- package/contracts/coaching-confirmation/README.md +96 -0
- package/contracts/coaching-confirmation/schema/payloads/coaching.lesson.confirmation_decided-v1.json +142 -0
- package/contracts/coaching-confirmation/schema/payloads/lead.coach.confirmation.requested-v1.json +124 -0
- package/contracts/credit-reservation-funding-state/README.md +147 -0
- package/contracts/credit-reservation-lock/README.md +433 -0
- package/contracts/credit-reservation-lock/delivery-state-vocabulary.md +73 -0
- package/contracts/credit-reservation-lock/reservation-create-api.md +191 -0
- package/contracts/credit-reservation-lock/reservation-release-api.md +171 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.locked-v1.json +1 -1
- package/contracts/credit-reservation-lock/schema/payloads/credit.reserved-v1.json +2 -3
- package/contracts/credit-reservation-lock/validation/lesson-lifecycle.md +318 -0
- package/contracts/event-envelope/README.md +205 -0
- package/contracts/event-envelope/schema/envelope-v1.json +2 -2
- package/contracts/event-envelope/validation/event-vocabulary.md +270 -0
- package/contracts/event-types-registry.json +337 -24
- package/contracts/external-actions/README.md +338 -0
- package/contracts/finance-mart/README.md +238 -0
- package/contracts/finance-mart/cac-payback.md +113 -0
- package/contracts/finance-mart/cash-position.md +98 -0
- package/contracts/finance-mart/cohort-summary.md +72 -0
- package/contracts/finance-mart/customer-journey-audit.md +92 -0
- package/contracts/finance-mart/ltv.md +92 -0
- package/contracts/finance-mart/margin.md +87 -0
- package/contracts/finance-mart/pnl.md +83 -0
- package/contracts/finance-mart/reconciliation.md +98 -0
- package/contracts/finance-mart/revenue-recognition-rollup.md +87 -0
- package/contracts/finance-mart/unit-economics.md +94 -0
- package/contracts/growth-warehouse-api/README.md +162 -0
- package/contracts/identity/README.md +184 -0
- package/contracts/identity/person-canonical-fields.md +120 -0
- package/contracts/identity/person-externals.md +267 -0
- package/contracts/identity/person-resolution-semantics.md +144 -0
- package/contracts/identity/person-role-taxonomy.md +120 -0
- package/contracts/identity/schema/payloads/intake.captured-v2.json +60 -0
- package/contracts/identity/schema/payloads/intake.matched-v2.json +123 -0
- package/contracts/identity/schema/payloads/person.updated-v1.json +8 -2
- package/contracts/identity/schema/payloads/role.assigned-v1.json +50 -0
- package/contracts/identity/schema/payloads/role.retired-v1.json +54 -0
- package/contracts/identity/validation/client-table.md +131 -0
- package/contracts/identity/validation/coach-handling.md +100 -0
- package/contracts/identity/validation/person-graph.md +140 -0
- package/contracts/lead-lifecycle/README.md +187 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.handoff.context.recorded-v1.json +108 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.qualified-v1.json +54 -0
- package/contracts/lead-lifecycle/schema/payloads/sales.lead.onboarded-v1.json +120 -0
- package/contracts/lesson-lifecycle/README.md +118 -0
- package/contracts/lesson-lifecycle/schema/payloads/lesson.cancelled-v1.json +30 -0
- package/contracts/lesson-lifecycle/schema/payloads/lesson.delivered-v1.json +29 -0
- package/contracts/lesson-lifecycle/schema/payloads/lesson.rescheduled-v1.json +157 -0
- package/contracts/lesson-lifecycle/schema/payloads/lesson.scheduled-v1.json +107 -0
- package/contracts/lesson-lifecycle/validation/README.md +5 -0
- package/contracts/mart-consumer-api/README.md +108 -0
- package/contracts/order-flow/README.md +106 -0
- package/contracts/order-flow/schema/payloads/order.created-v1.json +58 -0
- package/contracts/order-flow/schema/payloads/order.updated-v1.json +63 -0
- package/contracts/payment-flow/README.md +157 -0
- package/contracts/platform-comms/README.md +84 -0
- package/contracts/platform-comms/schema/payloads/platform.comms.inbound-v1.json +83 -0
- package/contracts/platform-geography-snapshot/README.md +205 -0
- package/contracts/platform-geography-snapshot/schema/payloads/geography.market.archived-v1.json +36 -0
- package/contracts/platform-geography-snapshot/schema/payloads/geography.market.upserted-v1.json +59 -0
- package/contracts/platform-geography-snapshot/schema/payloads/geography.service-area.archived-v1.json +36 -0
- package/contracts/platform-geography-snapshot/schema/payloads/geography.service-area.upserted-v1.json +65 -0
- package/contracts/portfolio-mart/README.md +133 -0
- package/contracts/portfolio-mart/cohort-funnel-panel.md +76 -0
- package/contracts/portfolio-mart/cross-discipline-performance.md +91 -0
- package/contracts/portfolio-mart/cross-market-performance.md +84 -0
- package/contracts/portfolio-mart/health-composites.md +88 -0
- package/contracts/portfolio-mart/org-topology.md +70 -0
- package/contracts/portfolio-mart/portfolio-level-funnel-health.md +92 -0
- package/contracts/portfolio-mart/validation/consumer-isolation.md +33 -0
- package/contracts/portfolio-mart/validation/decoupling-discipline.md +34 -0
- package/contracts/refund-flow/README.md +136 -0
- package/contracts/refund-flow/sales-callable-refund-initiation-api.md +218 -0
- package/contracts/sales-scheduling-surface/README.md +532 -0
- package/contracts/sales-scheduling-surface/schema/payloads/delivery.lesson-hold.cancelled-v1.json +42 -0
- package/contracts/sales-scheduling-surface/schema/payloads/delivery.lesson-hold.created-v1.json +115 -0
- package/contracts/sales-scheduling-surface/validation/composite-hold-create.md +97 -0
- package/contracts/sales-scheduling-surface/validation/lock-state-machine-conformance.md +84 -0
- package/contracts/sales-scheduling-surface/validation/sales-close-orchestration.md +77 -0
- package/contracts/warehouse-silver/README.md +118 -0
- package/contracts/warehouse-silver/coaching-utilization-columns.md +105 -0
- package/dist/events.d.ts +63 -0
- package/dist/events.js +293 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -1
- package/dist/postgres-consumer.js +2 -1
- package/dist/validator.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Portfolio-Level Funnel Health Manifest
|
|
2
|
+
|
|
3
|
+
**Status:** v1.1.0
|
|
4
|
+
**Date:** 2026-05-18
|
|
5
|
+
**Owner:** portfolio
|
|
6
|
+
**Parent contract:** `portfolio-mart` v1.1.0
|
|
7
|
+
**Family key:** `portfolio_level_funnel_health`
|
|
8
|
+
**Related:** `2026-05-18-platform-portfolio-mart-silver-surface-and-firewall-confirmation`
|
|
9
|
+
|
|
10
|
+
## Purpose
|
|
11
|
+
|
|
12
|
+
The portfolio-level funnel health family measures where the cross-domain funnel converts and where it leaks, from acquisition through delivery. It composes stage counts and conversion timing across Growth, Sales, Revenue, Delivery, and Coaching silver faces where the face has a source-backed customer-stage fact.
|
|
13
|
+
|
|
14
|
+
This family is aggregate-only. It serves stage-level rows and does not expose Person-grain funnel paths.
|
|
15
|
+
|
|
16
|
+
## Grain
|
|
17
|
+
|
|
18
|
+
One row per:
|
|
19
|
+
|
|
20
|
+
- `period_grain`
|
|
21
|
+
- `period_start`
|
|
22
|
+
- `period_end`
|
|
23
|
+
- `funnel_stage_key`
|
|
24
|
+
- optional `market_id`
|
|
25
|
+
- optional `organization_id`
|
|
26
|
+
- optional `service_area_id`
|
|
27
|
+
|
|
28
|
+
The default portfolio row has `market_id`, `organization_id`, and `service_area_id` null. Market and Organization slices may be served when every stage in the row has a source-backed key or a spine join path. Service Area is optional passthrough only where the contributing face carries it.
|
|
29
|
+
|
|
30
|
+
The smallest supported period grain is `week`.
|
|
31
|
+
|
|
32
|
+
## Served columns
|
|
33
|
+
|
|
34
|
+
| Column | Type | Required | Definition |
|
|
35
|
+
| --- | --- | --- | --- |
|
|
36
|
+
| `family_key` | string | yes | Constant `portfolio_level_funnel_health`. |
|
|
37
|
+
| `period_grain` | enum | yes | `week`, `month`, or `quarter`. |
|
|
38
|
+
| `period_start` | date | yes | Inclusive UTC date boundary. |
|
|
39
|
+
| `period_end` | date | yes | Exclusive UTC date boundary. |
|
|
40
|
+
| `funnel_stage_key` | string | yes | Stable stage key from the stage map below. |
|
|
41
|
+
| `funnel_stage_order` | integer | yes | Monotonic stage order. |
|
|
42
|
+
| `funnel_stage_label` | string | yes | Consumer-facing stage label. |
|
|
43
|
+
| `prior_funnel_stage_key` | string or null | yes | Prior stage key, null for the first stage. |
|
|
44
|
+
| `market_id` | string or null | no | Market slice from the spine when present. |
|
|
45
|
+
| `market_name` | string or null | no | Market display name from the spine when present. |
|
|
46
|
+
| `organization_id` | string or null | no | Organization slice from the spine when present. |
|
|
47
|
+
| `organization_name` | string or null | no | Organization display name from the spine when present. |
|
|
48
|
+
| `service_area_id` | string or null | no | Source-backed passthrough Service Area slice when present. |
|
|
49
|
+
| `stage_person_count` | integer | yes | Distinct `person_id` count reaching this stage in the period and slice. |
|
|
50
|
+
| `stage_event_count` | integer | yes | Count of source events or records backing this stage in the period and slice. |
|
|
51
|
+
| `conversion_from_prior_stage_rate` | decimal or null | yes | Distinct people reaching this stage divided by distinct people reaching the prior stage in the same period and slice. Null for the first stage or when denominator is zero. |
|
|
52
|
+
| `dropoff_person_count` | integer or null | yes | Prior stage person count minus this stage person count for the same period and slice. Null for the first stage. |
|
|
53
|
+
| `median_seconds_from_prior_stage` | integer or null | yes | Median elapsed seconds from each person's first prior-stage timestamp to first current-stage timestamp. |
|
|
54
|
+
| `p90_seconds_from_prior_stage` | integer or null | yes | 90th percentile elapsed seconds from each person's first prior-stage timestamp to first current-stage timestamp. |
|
|
55
|
+
| `dedup_rule` | string | yes | Constant `distinct_person_id_within_stage_slice`. |
|
|
56
|
+
| `as_of` | timestamp | yes | UTC ISO 8601 materialization timestamp. |
|
|
57
|
+
|
|
58
|
+
## Stage map and silver inputs
|
|
59
|
+
|
|
60
|
+
| Order | Stage key | Label | Source | Required columns |
|
|
61
|
+
| --- | --- | --- | --- | --- |
|
|
62
|
+
| 10 | `intake_captured` | Intake captured | `growth.touchpoint` | `person_id`, `occurred_at`, `intake_stage`, `market_id`, `organization_id` |
|
|
63
|
+
| 20 | `sales_lead_created` | Lead created | `sales.lead` | `person_id`, `created_at`, `lead_stage`, `market_id`, `organization_id` |
|
|
64
|
+
| 30 | `sales_lead_qualified` | Lead qualified | `sales.lead` | `person_id`, `qualified_at`, `lead_stage`, `lead_substage`, `market_id`, `organization_id` |
|
|
65
|
+
| 40 | `first_credit_reserved` | Credit reserved | `revenue.credit_reservation` | `person_id`, `reserved_at`, `reservation_state`, `market_id`, `organization_id` |
|
|
66
|
+
| 50 | `lesson_scheduled` | Lesson scheduled | `delivery.lesson` | `person_id`, `lesson_id`, `scheduled_start_at`, `lesson_state`, `market_id`, `organization_id` |
|
|
67
|
+
| 60 | `coach_booking_confirmed` | Coach booking confirmed | `coaching.lesson_booking` | `learner_person_id`, `booking_id`, `booking_start_at`, `booking_state`, `market_id`, `organization_id` |
|
|
68
|
+
| 70 | `lesson_attended` | Lesson attended | `delivery.attendance` | `person_id`, `lesson_id`, `attended_at`, `attendance_state`, `market_id`, `organization_id` |
|
|
69
|
+
|
|
70
|
+
Each source may additionally provide `service_area_id`; Portfolio will use it only as a source-backed passthrough slice.
|
|
71
|
+
|
|
72
|
+
## Conversion definition
|
|
73
|
+
|
|
74
|
+
For each stage, Portfolio selects the first timestamp at which a `person_id` reaches that stage within the source-backed row set. `stage_person_count` counts distinct people whose first timestamp for the stage falls inside the period and slice. `stage_event_count` counts the source records that reached the stage inside the period and slice.
|
|
75
|
+
|
|
76
|
+
`conversion_from_prior_stage_rate` compares the current stage to the immediate prior stage for the same period and slice. A person is counted as converted when their first current-stage timestamp is at or after their first prior-stage timestamp. Timing columns use only converted people with both timestamps present.
|
|
77
|
+
|
|
78
|
+
This is a period-stage view, not a customer-path export. The served surface does not expose Person-grain journeys.
|
|
79
|
+
|
|
80
|
+
## Composition rules
|
|
81
|
+
|
|
82
|
+
- Every stage person count uses `COUNT(DISTINCT person_id)` at the declared stage row key.
|
|
83
|
+
- Stage ordering is owned by Portfolio in this manifest. Domain-specific source stages are inputs, not served labels.
|
|
84
|
+
- Market and Organization slices are read through the spine. Service Area is optional passthrough when present on the source face.
|
|
85
|
+
- Revenue contributes reservation state only. Financial amounts remain Finance-owned and are not read by this family.
|
|
86
|
+
- If a source field named above is absent from the live silver face, the affected stage and downstream conversion metrics return `null:upstream_unavailable` and the gap is filed back to Platform.
|
|
87
|
+
|
|
88
|
+
## Known coverage gaps for Platform assessment
|
|
89
|
+
|
|
90
|
+
- `sales.lead.qualified_at` is required for the `sales_lead_qualified` timestamp. If Sales silver carries only current `lead_stage`, the stage person count may be available but conversion timing from created to qualified is a gap.
|
|
91
|
+
- `coaching.lesson_booking.learner_person_id` is required for `coach_booking_confirmed` to participate in the customer funnel. If the live coaching face only carries coach `person_id`, this stage should stay `null:upstream_unavailable` until the learner key is source-backed in silver.
|
|
92
|
+
- `service_area_id` is optional in this family because `warehouse-silver` v1.1.0 contracts Org and Market as analytics spine dimensions. Service Area slices should appear only where every contributing source stage carries a source-backed passthrough key.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Validation: Consumer isolation
|
|
2
|
+
|
|
3
|
+
**Contract:** `portfolio-mart` v1.1.0
|
|
4
|
+
**Consumer:** strategy reporting consumers (leadership and operator dashboards)
|
|
5
|
+
**Date:** 2026-05-14
|
|
6
|
+
|
|
7
|
+
## Why this validation exists
|
|
8
|
+
|
|
9
|
+
Consumer isolation is one of Portfolio's three named quality-bar signals (`coordination/domains/portfolio.md`). The portfolio section's firewall bound is contract-shaped rather than warehouse-shaped per ADR-0016: the portfolio consumer tag is scoped to exactly the surface this contract defines. This note documents what "isolated" means for the portfolio section, what a violation looks like, and how it is checked, so that a future surface change can be tested against the isolation guarantee rather than re-deriving it.
|
|
10
|
+
|
|
11
|
+
## What isolation guarantees
|
|
12
|
+
|
|
13
|
+
A strategy reporting consumer holding the portfolio tag reads the portfolio gold section and nothing else. It SHALL NOT reach silver, bronze, another gold section, or Portfolio's derived store. The firewall is the structural enforcement; this note is the consumer-side description of what the firewall is enforcing.
|
|
14
|
+
|
|
15
|
+
The guarantee has two halves:
|
|
16
|
+
|
|
17
|
+
The consumer cannot read outside the portfolio section. This is Platform's firewall mechanism, and the bound it enforces for the portfolio tag is this contract's §4 surface.
|
|
18
|
+
|
|
19
|
+
The consumer does not need to read outside the portfolio section. Every metric a strategy reporting consumer needs is composed by Portfolio and served on the section. If a consumer finds it must reach into a silver face or another gold section to answer a strategy question, that is a gap in this contract's surface, not a reason to widen the consumer's read scope. The gap lands as a minor-version family or column addition per the README §5.
|
|
20
|
+
|
|
21
|
+
## What a violation looks like
|
|
22
|
+
|
|
23
|
+
- A strategy reporting consumer issued a credential or tag that resolves to more than the portfolio section.
|
|
24
|
+
- A consumer joining the portfolio section against a silver face or another gold section client-side, which means the cross-domain composition happened in the consumer rather than in gold. This is the placement-rule violation ADR-0016 exists to prevent, surfacing on the consumer side.
|
|
25
|
+
- A metric served on the portfolio section that was sourced from another gold section rather than composed from silver. This is a producer-side violation; see `decoupling-discipline.md`.
|
|
26
|
+
|
|
27
|
+
## How it is checked
|
|
28
|
+
|
|
29
|
+
v1.1.0 states the guarantee; the live check lands with the firewall mechanism Platform implements (ADR-0016 action item 6). When that mechanism ships, this note gains a concrete check: enumerate the objects the portfolio tag resolves to and assert the set equals the portfolio section. Until then, isolation is reviewed at consumer onboarding: a new strategy reporting consumer is granted the portfolio tag and nothing else, and its queries are reviewed for client-side joins against non-portfolio objects.
|
|
30
|
+
|
|
31
|
+
## Open questions Portfolio reserves the right to raise
|
|
32
|
+
|
|
33
|
+
If a strategy reporting consumer repeatedly needs a fact the portfolio section does not carry, that is a signal the contract surface is too thin, and Portfolio raises it as a minor-version proposal. If an operational domain asks to consume the portfolio section as an input to operational behavior, Portfolio declines in writing and flags the ADR-0015 trigger to revisit: a reporting surface that becomes load-bearing for operations is a domain-kind boundary question.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Validation: Decoupling discipline
|
|
2
|
+
|
|
3
|
+
**Contract:** `portfolio-mart` v1.1.0
|
|
4
|
+
**Producer:** portfolio (the portfolio gold section and the derived portfolio store)
|
|
5
|
+
**Date:** 2026-05-14
|
|
6
|
+
|
|
7
|
+
## Why this validation exists
|
|
8
|
+
|
|
9
|
+
Decoupling discipline is the first of Portfolio's three named quality-bar signals (`coordination/domains/portfolio.md`): cross-domain strategy metrics are computed in the portfolio mart and never pushed back into a per-domain warehouse. This note documents the producer-side guarantee, what a violation looks like, and how it is checked, so that the discipline is testable rather than aspirational.
|
|
10
|
+
|
|
11
|
+
## What the discipline guarantees
|
|
12
|
+
|
|
13
|
+
Every metric served on the portfolio section is composed in gold, over the silver tier and the conformed identity and geography spine. Two things follow.
|
|
14
|
+
|
|
15
|
+
A strategy metric is computed in the portfolio section, not inside any operational domain's warehouse. Portfolio does not ask an operational domain to add a cross-domain column to its own gold section so Portfolio can read it; the cross-domain composition is Portfolio's, and it lives in gold where ADR-0016 places all cross-domain composition.
|
|
16
|
+
|
|
17
|
+
The portfolio section reads silver, never bronze, and never another gold section. Composing over another gold section would couple Portfolio to a domain's analytics scope rather than to the conform-only silver surface, and composing over bronze would couple Portfolio to a source schema it does not own. Both defeat the silver insulation the reporting-domain tier is built on.
|
|
18
|
+
|
|
19
|
+
## What a violation looks like
|
|
20
|
+
|
|
21
|
+
- A per-domain gold section carrying a column that exists only so Portfolio can read it. The strategy metric leaked into an operational warehouse; it belongs in the portfolio section.
|
|
22
|
+
- A portfolio-section metric sourced from another gold section rather than from silver. The composition coupled to a domain's analytics scope.
|
|
23
|
+
- A portfolio-section metric sourced from a bronze table directly. The composition coupled to a source schema Portfolio does not own.
|
|
24
|
+
- A request to an operational domain to compute a cross-domain metric on Portfolio's behalf. The composition was pushed out of gold; Portfolio declines in writing per README §7 and composes it itself.
|
|
25
|
+
|
|
26
|
+
## How it is checked
|
|
27
|
+
|
|
28
|
+
v1.1.0 states the guarantee; per-family validation notes land under this directory as each metric family ships (README §9), each proving its family composes only over silver and the spine. The check for a family is a dependency audit: enumerate the sources its models read and assert every source is a silver face or the spine, with no bronze table and no gold section in the set. The audit mirrors the source-to-silver drift check Platform runs for silver (`warehouse-silver` README §7); Portfolio runs the gold-section equivalent for the portfolio section.
|
|
29
|
+
|
|
30
|
+
Until the per-family notes land, decoupling is reviewed at metric-family design time: a family's proposed model dependency list is reviewed against the silver-and-spine-only rule before the family ships.
|
|
31
|
+
|
|
32
|
+
## Boundary that does not move
|
|
33
|
+
|
|
34
|
+
Portfolio composes cross-domain strategy metrics; it does not write facts. A metric Portfolio would need to write rather than compose (a fact no operational domain owns) is not a decoupling question, it is the ADR-0015 domain-kind boundary, and it reopens that ADR rather than being worked around inside this contract. The decoupling discipline is about where composition happens, not about Portfolio acquiring a write surface.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Refund Flow
|
|
2
|
+
|
|
3
|
+
**Status:** v1.1.0
|
|
4
|
+
**Date:** 2026-05-16
|
|
5
|
+
**Owner:** revenue (refund-items, orders)
|
|
6
|
+
**Consumers:** sales (refund-against-Lead reactivation tracking), delivery (lesson-flow reconciliation when the refund corresponds to a non-lock-released cancellation, which surfaces as a drift case), platform-warehouse (analytics ingestion). Growth and Coaching do not subscribe at v1.
|
|
7
|
+
**Related ADRs:** ADR-0001 (tenant_id), ADR-0002 (canonical entity ID template; `ref_` and `ord_` prefixes), ADR-0003 (Person canonical, Revenue does not own Person), ADR-0005 (event envelope), ADR-0006 (lock state machine; the refund-flow rule below depends on lock-released invariants), ADR-0009 (dispatcher transport, producer transactional guarantee), ADR-0010 (provider externals at Platform; Square is the v1 refund provider)
|
|
8
|
+
**Related contracts:** Event Envelope Contract (`../event-envelope/README.md`), Identity Contract (`../identity/README.md`), Credit Reservation Lock Contract (`../credit-reservation-lock/README.md`), Payment Flow Contract (`../payment-flow/README.md`), Sales Scheduling Surface Contract (`../sales-scheduling-surface/README.md`)
|
|
9
|
+
**Sub-specs (authoritative):** `schema/payloads/refund.initiated-v1.json`, `schema/payloads/refund.completed-v1.json`, `sales-callable-refund-initiation-api.md`
|
|
10
|
+
**Validations:** none yet
|
|
11
|
+
|
|
12
|
+
## 1. Purpose and scope
|
|
13
|
+
|
|
14
|
+
This contract specifies the producer responsibilities and consumer-visible payload shape for Revenue's two refund-flow events: `refund.initiated` and `refund.completed`. Both events are produced by Revenue at writeback transaction commits during the refund lifecycle. Both fire under the producer-transactional-guarantee shape from ADR-0009 (dispatcher.publish inside the same Prisma transaction as the refund record write or the Refund Debit ledger entry post).
|
|
15
|
+
|
|
16
|
+
In scope: the two event names, the payload shapes at v1, the refund-flow rule's interaction with the credit-reservation-lock state machine (a refund corresponding to a confirmed lock requires Delivery to transition the lock to released first or in the same transaction per `domains/revenue.md`), the Sales-callable refund initiation API in `sales-callable-refund-initiation-api.md`, the producer's transactional-guarantee discipline, idempotency expectations, and the versioning policy.
|
|
17
|
+
|
|
18
|
+
Out of scope: the Square adapter's refund-call HTTP wire-format (lives in `revenue/src/lib/providers/square/`); the credit-reservation-lock state machine's `credit.released` and `credit.forfeited` events (covered by `credit-reservation-lock` §9.6 and §9.7); the per-Order revenue-recognition contra entries that fire as a downstream of `refund.completed` (Revenue-internal, see `revenue/src/modules/credit-ledger-entries/service.ts:contraRecognizeCreditLedgerEntry`); the Pay Run cluster's refund-of-coach-payment surface (Q3 work per `2026-05-09-revenue-q2-airtable-sunset-scoping`); the upstream payment-method debit (covered by `payment-flow`; refunds are downstream of payments).
|
|
19
|
+
|
|
20
|
+
## 2. Normative language
|
|
21
|
+
|
|
22
|
+
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted per RFC 2119.
|
|
23
|
+
|
|
24
|
+
## 3. Terminology
|
|
25
|
+
|
|
26
|
+
**Refund.** A Revenue-owned record (`ref_<UUID v7>`) representing a return of funds to a customer for some or all of an Order's paid amount. May or may not correspond to a credit-reservation-lock release; this contract is symmetric across the two cases.
|
|
27
|
+
|
|
28
|
+
**Refund Item.** A line on a Refund tying the refund to a specific Order Item, with a `creditsRevoked` count and an `amountCents` amount. The Refund Debit ledger entry posts against the customer's credit account at the Refund Item grain.
|
|
29
|
+
|
|
30
|
+
**Refund initiation.** The point at which Revenue mints the Refund record at status PENDING and dispatches the Square refund call. The provider call is asynchronous from Revenue's perspective; the Square refund webhook arrives later.
|
|
31
|
+
|
|
32
|
+
**Refund completion.** The point at which the Square webhook arrives confirming the refund settled, Revenue flips the Refund record to COMPLETED, posts the Refund Debit ledger entry per Refund Item, and applies the refund's effects to the parent Order (status flip to PARTIALLY_REFUNDED or REFUNDED, amount-paid update).
|
|
33
|
+
|
|
34
|
+
**Lock-tied refund.** A refund whose underlying Order Item corresponds to a credit reservation that has been locked. Per the refund-flow rule in `domains/revenue.md`, Delivery SHALL transition the lock to `released` (via the credit-reservation-lock state machine) first or in the same transaction; a refund.completed event for a lock-tied Refund Item without a prior `credit.released` event is drift and triggers reconciliation.
|
|
35
|
+
|
|
36
|
+
**Non-lock refund.** A refund whose underlying Order Item does not correspond to a locked reservation (pre-lock cancellation, administrative refund, refund against an unrederemed credit purchase). No `credit.released` is required; the refund is symmetric on the credit-reservation-lock side.
|
|
37
|
+
|
|
38
|
+
## 4. Event types
|
|
39
|
+
|
|
40
|
+
### 4.1 refund.initiated
|
|
41
|
+
|
|
42
|
+
Emitted by Revenue at the writeback transaction commit when the Refund record is minted and the Square refund call has been dispatched. The Refund record is at status PENDING (or equivalent v1 enum value); the actual refund settlement is pending the provider webhook.
|
|
43
|
+
|
|
44
|
+
Payload v1: `refund_id`, `order_id`, `person_id`, `amount_cents`, `currency`, `provider_ref`, `initiated_at`, `cancellation_reason` (optional). See `schema/payloads/refund.initiated-v1.json` for the authoritative shape.
|
|
45
|
+
|
|
46
|
+
The `cancellation_reason` field is optional. When the refund traces back to a credit-reservation-lock cancellation (lock-tied refund), the field carries the `reason_code` from the upstream `credit.released` v2 event (per `credit-reservation-lock` §6.1 and §13.8, values like `customer_requested_in_window`, `weather`, `administrative_void`). When the refund is non-lock (pre-lock cancellation, administrative refund), the field is null. Consumers SHOULD route differently on lock-tied vs non-lock refunds (Sales' Lead reactivation cares about the cause).
|
|
47
|
+
|
|
48
|
+
### 4.2 refund.completed
|
|
49
|
+
|
|
50
|
+
Emitted by Revenue at the writeback transaction commit when the Square webhook confirms the refund has settled. The Refund record is flipped to COMPLETED, the Refund Debit ledger entry per Refund Item is posted, and the parent Order's status is flipped (PAID to PARTIALLY_REFUNDED or PAID to REFUNDED depending on whether the refund covers the full paid amount).
|
|
51
|
+
|
|
52
|
+
Payload v1: `refund_id`, `order_id`, `person_id`, `amount_cents`, `currency`, `provider_ref`, `initiated_at` (the original initiation timestamp for correlation against the matching `refund.initiated` event), `completed_at`, `cancellation_reason` (optional). See `schema/payloads/refund.completed-v1.json` for the authoritative shape.
|
|
53
|
+
|
|
54
|
+
The refund-flow rule from `domains/revenue.md` is enforced upstream of this emit: by the time `refund.completed` fires for a lock-tied Refund Item, the corresponding `credit.released` event has already fired (or fires in the same transaction). Consumers MAY assume the lock-released invariant; the reconciliation surface catches violations.
|
|
55
|
+
|
|
56
|
+
A partial-refund case (the Refund covers some Refund Items but not all of the original Order's items) emits one `refund.completed` for the Refund as a whole; the per-Refund-Item Refund Debit ledger entries are internal to Revenue's ledger and not surfaced as separate events.
|
|
57
|
+
|
|
58
|
+
## 5. Producer responsibilities
|
|
59
|
+
|
|
60
|
+
Revenue SHALL emit `refund.initiated` exactly once per Refund record. Idempotency is enforced at the upstream Refund-creation function: a duplicate Refund creation request against the same `(order_id, refund_intent_key)` is a no-op at the validation layer.
|
|
61
|
+
|
|
62
|
+
Revenue SHALL emit `refund.completed` exactly once per Refund record. Idempotency is enforced at the writeback transaction: the Refund record's status flip from PENDING to COMPLETED is gated on the current status, and a duplicate webhook delivery (Square retries on its own retry policy) is a no-op once the flip has happened.
|
|
63
|
+
|
|
64
|
+
Revenue SHALL emit both events inside the same Prisma transaction as the corresponding writeback per ADR-0009's producer-transactional-guarantee shape. The publish call is the last statement in the transaction by convention, so any earlier rollback prevents the event from firing.
|
|
65
|
+
|
|
66
|
+
Revenue SHALL populate `provider_ref` on every emit. The funding-state external-reference rule in `domains/revenue.md` requires this for any state change carrying a payment-processor reference; refunds qualify. The `provider_ref` is the Square refund id (`sqref_<id>` or equivalent provider identifier) for refunds that reach the provider; for the rare case of a credit-balance-only refund (no provider call), it is the external-actions row id prefixed with `ext_`.
|
|
67
|
+
|
|
68
|
+
Revenue SHALL emit `refund.initiated` BEFORE `refund.completed` for every Refund. Even when the provider's webhook arrives quickly enough that the two writebacks are close in time, the two events represent distinct producer transactions and fire in order.
|
|
69
|
+
|
|
70
|
+
Revenue SHALL ensure the lock-released invariant for lock-tied refunds: if the Refund Item corresponds to a locked reservation, the corresponding `credit.released` event has fired by the time `refund.completed` fires. The transition is enforced upstream at the Refund creation interaction with the credit-reservation-lock state machine; this contract codifies the expectation, not the enforcement mechanism.
|
|
71
|
+
|
|
72
|
+
Revenue MAY emit `refund.completed` without a prior `refund.initiated` event having been observed by consumers in the case of a backfill (a historical Refund record minted before the dispatcher SDK was wired up that completes after the wiring lands). Consumers SHOULD handle this gracefully; the reconciliation surface flags backfill cases for operator review.
|
|
73
|
+
|
|
74
|
+
## 6. Consumer responsibilities
|
|
75
|
+
|
|
76
|
+
Consumers SHALL handle `refund.initiated` and `refund.completed` independently. The two events are correlated by `refund_id` (and confirmable by `initiated_at` matching across both); consumers that want to hold open a refund-in-flight state until completion MUST do so by tracking the `refund_id` from `refund.initiated`.
|
|
77
|
+
|
|
78
|
+
Consumers SHALL handle dispatcher redelivery per the at-most-once invocation guarantee in the event-envelope. Per-(consumer, event_id) dedup tables apply.
|
|
79
|
+
|
|
80
|
+
Consumers SHALL treat unknown values for the optional `cancellation_reason` field as safe-to-ignore per the additive-discipline in §8 (the field's enum mirrors `credit.released` v2's `reason_code` enum and is subject to the same Phase-2 narrowing).
|
|
81
|
+
|
|
82
|
+
Sales SHALL route Lead-reactivation logic on the `cancellation_reason` field when present: customer-initiated refunds (e.g. `customer_requested_in_window`) typically do not warrant Lead reactivation; weather and other Sguild-side cancellations may. The semantics are Sales-internal; consumers MAY route differently per their own domain logic.
|
|
83
|
+
|
|
84
|
+
Delivery SHOULD treat a `refund.completed` for a lock-tied Refund Item as a verification signal that the lock-released invariant held; if Delivery has not seen a corresponding `credit.released` event by the time `refund.completed` arrives, Delivery SHOULD log a drift warning and surface the case to the operator.
|
|
85
|
+
|
|
86
|
+
Platform warehouse SHALL ingest both events for refund-cycle analytics (the refund-cycle completion time metric per `domains/revenue.md`).
|
|
87
|
+
|
|
88
|
+
## 7. Idempotency and retry
|
|
89
|
+
|
|
90
|
+
Per ADR-0009, the dispatcher SDK guarantees at-most-once handler invocation per `(consumer, event_id)` via the per-(consumer, event_id) dedup table. Consumer handlers SHOULD be idempotent on their own work even with this guarantee.
|
|
91
|
+
|
|
92
|
+
Revenue's producer-side idempotency for `refund.initiated` is enforced by the Refund-creation function's deduplication on `(order_id, refund_intent_key)`. Producer-side idempotency for `refund.completed` is enforced by the Refund record's status-machine: a duplicate webhook delivery against an already-COMPLETED Refund is a no-op at the validation layer.
|
|
93
|
+
|
|
94
|
+
Dispatcher delivery retry follows the SDK's default (exponential backoff with jitter, default 3 retries, then dead-letter to the platform-managed DLQ table per ADR-0009 action item 6).
|
|
95
|
+
|
|
96
|
+
## 8. Versioning
|
|
97
|
+
|
|
98
|
+
This contract follows the additive-discipline pattern from `event-envelope` §11. New fields MUST default to null or a backwards-compatible value. New enum values on `cancellation_reason` MUST be appended; consumers MUST treat unknown enum values as safe-to-ignore. Removal, type change, or narrowing of existing enum values requires a major version bump and a producer cutover with a deprecation window.
|
|
99
|
+
|
|
100
|
+
The `cancellation_reason` enum is forward-compatible with `credit.released` v2's `reason_code`: when the upstream contract adds new enum values via Phase-2 narrowing, this contract's `cancellation_reason` enum extends in lockstep without a separate version bump (the schema's enum list is updated as a v1.x patch with the additive discipline preserved).
|
|
101
|
+
|
|
102
|
+
The producer cutover for any minor version follows the event-envelope §11 pattern (publish date plus 14 days by default).
|
|
103
|
+
|
|
104
|
+
## 9. Producer responsibilities trace
|
|
105
|
+
|
|
106
|
+
The producer-side sites where these events fire (per `revenue/docs/dispatcher-emit-wiring.md`):
|
|
107
|
+
|
|
108
|
+
- `refund.initiated` from a future Refund-creation function in the orders or refund-items module (today's source applies refund effects to the Order via `applyRefundToOrder` in `revenue/src/modules/orders/service.ts` after the Refund is already at status COMPLETED; the upstream Refund-creation function that mints the record at PENDING is part of the Postgres migration scope per `2026-05-09-revenue-q2-airtable-sunset-scoping` commitment 0).
|
|
109
|
+
- `refund.completed` from `revenue/src/modules/refund-items/service.ts:createRefundDebit` (the Refund Debit ledger entry post is the writeback the publish binds to). The publish fires once per Refund (not once per Refund Item); when a Refund covers multiple Refund Items, the function loops the Refund Debit posts and emits the single `refund.completed` after the last post commits.
|
|
110
|
+
- `refund.completed` from `revenue/src/modules/orders/service.ts:applyRefundToOrder` is the alternative emit site if the orders-module flow lands the Refund-record-status flip and the Order-status flip in the same transaction. The wiring decision (refund-items vs orders) lands during the Postgres migration; this contract is symmetric to either choice.
|
|
111
|
+
|
|
112
|
+
This trace is the live producer-side reference; it updates as part of the same contract change if a future revision adds an event-firing site outside the named modules.
|
|
113
|
+
|
|
114
|
+
## 10. Action items
|
|
115
|
+
|
|
116
|
+
1. [ ] Revenue: register `refund.initiated` and `refund.completed` in `coordination/contracts/event-types-registry.json` with the consumer mappings named in this contract. Owner: Revenue. Due: at v1.0.0 publish (this filing).
|
|
117
|
+
2. [ ] Revenue: wire the producer-side dispatcher.publish calls at the named sites in §9 inside Prisma transactions per ADR-0009, alongside the per-module Postgres migration in `2026-05-09-revenue-q2-airtable-sunset-scoping` commitment 1. Owner: Revenue. Due: 2026-06-22 per the Q2 directive.
|
|
118
|
+
3. [ ] Revenue: build the upstream Refund-creation function that emits `refund.initiated`. Today's source has `applyRefundToOrder` which applies a refund's effects but assumes the Refund is already at status COMPLETED; the missing primitive is the function that mints the Refund at PENDING and dispatches the Square refund call. Owner: Revenue. Due: alongside the Postgres migration.
|
|
119
|
+
4. [ ] Sales: scaffold a subscriber for `refund.initiated` and `refund.completed` in the lead-pipeline reactivation flow. Owner: Sales. Due: per Sales' own roadmap.
|
|
120
|
+
5. [ ] Delivery: scaffold a subscriber for `refund.completed` for the lock-released invariant verification per §6. Owner: Delivery. Due: per Delivery's own roadmap.
|
|
121
|
+
6. [ ] Platform: ack the registry edits and the contract publication. Owner: Platform. Due: per the response window on `2026-05-02-revenue-emit-wiring-registry-additions`.
|
|
122
|
+
|
|
123
|
+
## 11. Trigger to revisit
|
|
124
|
+
|
|
125
|
+
Any of the following reopens this contract:
|
|
126
|
+
|
|
127
|
+
- A new refund provider lands (alternative to Square). The provider-agnostic shape SHOULD absorb additive without bumping; if the new provider's behavior differs in ways the v1 fields do not capture, the change lands as a v1.x patch.
|
|
128
|
+
- The Pay Run cluster's refund-of-coach-payment surface (Q3 work per the Revenue Q2 sunset scoping memo) introduces a refund flow that does not match the v1 shape (e.g. no Order context, no Person on the consumer side). Revisit whether the Pay Run refund flow lives in this contract or in a sibling.
|
|
129
|
+
- The credit-reservation-lock contract's `credit.released` v2 enum changes in a way that breaks the lockstep additive-discipline on `cancellation_reason`. Phase-2 narrowing should not trigger this; explicit enum value removal would.
|
|
130
|
+
- The funding-sub-state event family decision (collapse vs keep separate per `2026-05-02-revenue-emit-wiring-registry-additions`) introduces an overlap with refund-flow that warrants reorganization (specifically, if `reservation.refunding` and `reservation.refunded` land as Shape A, the per-event-grain semantics overlap; the two contracts coordinate on a shared envelope semantics rather than collapse).
|
|
131
|
+
- The credit-balance-only refund path lands and surfaces fields the v1 payload does not carry (the v1 shape assumes a provider call; credit-balance-only refunds reuse `provider_ref` with the `ext_` prefix per §5 but may need a distinguishing field).
|
|
132
|
+
|
|
133
|
+
## Change log
|
|
134
|
+
|
|
135
|
+
- **v1.1.0** (2026-05-16). Additive minor. Publishes `sales-callable-refund-initiation-api.md`, the Revenue-owned API surface Sales uses to initiate refunds for paid or confirmed cancellation flows. This clears the Revenue precondition for Delivery's `sales-scheduling-surface` v1.1 confirmed-to-released cancellation work. No event payload changes; `refund.initiated` and `refund.completed` remain at payload v1.
|
|
136
|
+
- **v1.0.0** (2026-05-02). Initial release. Two events (`refund.initiated`, `refund.completed`) with v1 payloads. Optional `cancellation_reason` field on both, enum-aligned with `credit.released` v2's `reason_code`. Producer-transactional-guarantee shape from ADR-0009 codified at §5. Refund-flow rule from `domains/revenue.md` codified at §4.2 and §6 with consumer obligations on lock-released invariant verification. Sibling to payment-flow contract published the same day. Registered alongside the `2026-05-02-revenue-emit-wiring-registry-additions` memo's commitment 1.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Refund Flow, Sales-Callable Refund Initiation API
|
|
2
|
+
|
|
3
|
+
**Status:** v1.0.0
|
|
4
|
+
**Date:** 2026-05-16
|
|
5
|
+
**Owner:** Revenue
|
|
6
|
+
**Consumers:** Sales close orchestration; Delivery confirmed-to-released cancellation v1.1 work
|
|
7
|
+
**Parent contract:** Refund Flow v1.1.0
|
|
8
|
+
|
|
9
|
+
## 1. Purpose and scope
|
|
10
|
+
|
|
11
|
+
This sub-spec defines the Sales-callable API for initiating a Revenue refund when a Sales-originated cancellation reaches a confirmed lesson or another paid state where money has moved. It is the Revenue surface that clears the `sales-scheduling-surface` v1.1 precondition for Sales-originated confirmed-to-released cancellations.
|
|
12
|
+
|
|
13
|
+
The endpoint creates a Revenue refund intent, dispatches the payment-provider refund where a provider refund is required, emits `refund.initiated` at writeback commit, and returns the refund state to the caller. It does not replace Delivery's lesson-hold cancellation surface, and it does not let Sales directly emit `credit.released`, `credit.forfeited`, `refund.initiated`, or `refund.completed`.
|
|
14
|
+
|
|
15
|
+
Out of scope: provider-specific wire format, refund settlement webhooks, pay-run refunds, charge creation, and Delivery's own lesson-hold storage transition.
|
|
16
|
+
|
|
17
|
+
## 2. Endpoint
|
|
18
|
+
|
|
19
|
+
`POST /api/v1/refunds/initiate`
|
|
20
|
+
|
|
21
|
+
Headers:
|
|
22
|
+
|
|
23
|
+
- `Authorization`: bearer service token or authenticated cross-domain caller credential accepted by Revenue.
|
|
24
|
+
- `Idempotency-Key`: required, string up to 128 characters. Scoped by `organization_id`.
|
|
25
|
+
|
|
26
|
+
Body:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"organization_id": "org_...",
|
|
31
|
+
"reservation_id": "crr_...",
|
|
32
|
+
"lesson_id": "les_...",
|
|
33
|
+
"order_id": "ord_...",
|
|
34
|
+
"person_id": "per_...",
|
|
35
|
+
"initiator": "admin",
|
|
36
|
+
"reason_code": "customer_requested_in_window",
|
|
37
|
+
"refund_scope": "reservation",
|
|
38
|
+
"amount_cents": null,
|
|
39
|
+
"currency": "USD",
|
|
40
|
+
"reason_notes": "optional free text up to 500 chars"
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Required fields:
|
|
45
|
+
|
|
46
|
+
- `organization_id`
|
|
47
|
+
- `person_id`
|
|
48
|
+
- `initiator`
|
|
49
|
+
- `reason_code`
|
|
50
|
+
- `refund_scope`
|
|
51
|
+
|
|
52
|
+
Required identifiers by `refund_scope`:
|
|
53
|
+
|
|
54
|
+
- `reservation`: `reservation_id` and `lesson_id` are required. `order_id` MAY be supplied as a defensive match check.
|
|
55
|
+
- `order`: `order_id` is required.
|
|
56
|
+
|
|
57
|
+
Optional fields:
|
|
58
|
+
|
|
59
|
+
- `amount_cents`. If omitted or null, Revenue computes the refundable amount from the refund scope and current commercial state. If present, Revenue treats it as a requested cap and validates it against refundable balance.
|
|
60
|
+
- `currency`. Required when `amount_cents` is present. Otherwise optional and derived from the order.
|
|
61
|
+
- `reason_notes`, operator context. Producers SHOULD redact this field from logs.
|
|
62
|
+
|
|
63
|
+
`initiator` values mirror the credit-reservation-lock cancellation policy:
|
|
64
|
+
|
|
65
|
+
- `customer`
|
|
66
|
+
- `admin`
|
|
67
|
+
- `coach`
|
|
68
|
+
- `system_weather`
|
|
69
|
+
- `system_logistics`
|
|
70
|
+
- `system_unpaid`
|
|
71
|
+
- `system_other`
|
|
72
|
+
|
|
73
|
+
`reason_code` values mirror `credit.released` v2:
|
|
74
|
+
|
|
75
|
+
- `site_closure`
|
|
76
|
+
- `coach_unavailable_reschedule_failed`
|
|
77
|
+
- `force_majeure`
|
|
78
|
+
- `weather`
|
|
79
|
+
- `administrative_void`
|
|
80
|
+
- `customer_requested_in_window`
|
|
81
|
+
- `customer_requested_exception`
|
|
82
|
+
- `policy_exception`
|
|
83
|
+
- `bad_debt_writeoff`
|
|
84
|
+
|
|
85
|
+
## 3. Success response
|
|
86
|
+
|
|
87
|
+
HTTP 202 when a provider refund has been initiated and settlement is pending:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"refund_id": "ref_...",
|
|
92
|
+
"organization_id": "org_...",
|
|
93
|
+
"person_id": "per_...",
|
|
94
|
+
"reservation_id": "crr_...",
|
|
95
|
+
"lesson_id": "les_...",
|
|
96
|
+
"order_id": "ord_...",
|
|
97
|
+
"refund_state": "initiated",
|
|
98
|
+
"refund_scope": "reservation",
|
|
99
|
+
"amount_cents": 12000,
|
|
100
|
+
"currency": "USD",
|
|
101
|
+
"provider_ref": "sqref_...",
|
|
102
|
+
"initiator": "admin",
|
|
103
|
+
"reason_code": "customer_requested_in_window",
|
|
104
|
+
"lock_release_state": "release_recorded",
|
|
105
|
+
"as_of": "2026-05-16T06:45:00.000Z"
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
HTTP 200 on idempotent replay:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"refund_id": "ref_...",
|
|
114
|
+
"organization_id": "org_...",
|
|
115
|
+
"person_id": "per_...",
|
|
116
|
+
"reservation_id": "crr_...",
|
|
117
|
+
"lesson_id": "les_...",
|
|
118
|
+
"order_id": "ord_...",
|
|
119
|
+
"refund_state": "initiated",
|
|
120
|
+
"refund_scope": "reservation",
|
|
121
|
+
"amount_cents": 12000,
|
|
122
|
+
"currency": "USD",
|
|
123
|
+
"provider_ref": "sqref_...",
|
|
124
|
+
"initiator": "admin",
|
|
125
|
+
"reason_code": "customer_requested_in_window",
|
|
126
|
+
"lock_release_state": "release_recorded",
|
|
127
|
+
"as_of": "2026-05-16T06:45:00.000Z"
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`refund_state` values:
|
|
132
|
+
|
|
133
|
+
- `initiated`: Revenue minted the Refund and emitted `refund.initiated`; provider settlement is pending.
|
|
134
|
+
- `completed`: Revenue has already completed the refund, usually on idempotent replay after a fast provider callback.
|
|
135
|
+
- `not_provider_backed`: no provider refund was required, but Revenue recorded the refund intent and emitted `refund.initiated` with a non-provider reference.
|
|
136
|
+
|
|
137
|
+
`lock_release_state` values:
|
|
138
|
+
|
|
139
|
+
- `not_lock_tied`: the refund is not tied to a credit reservation.
|
|
140
|
+
- `release_recorded`: the lock-tied reservation release was recorded before or in the same transaction as refund initiation.
|
|
141
|
+
- `release_required`: the request is valid but cannot proceed until Delivery and Revenue compose the release path. This value is returned only with HTTP 409 in v1.0.0; it is listed here so clients can branch uniformly on the field.
|
|
142
|
+
|
|
143
|
+
## 4. Error response
|
|
144
|
+
|
|
145
|
+
Every error response uses this envelope:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"error": {
|
|
150
|
+
"code": "conflict",
|
|
151
|
+
"message": "Human-readable summary.",
|
|
152
|
+
"conflict_reason": "lock_release_required",
|
|
153
|
+
"current_state": {
|
|
154
|
+
"reservation_id": "crr_...",
|
|
155
|
+
"lifecycle_state": "locked",
|
|
156
|
+
"funding_state": "funded",
|
|
157
|
+
"refund_state": null
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
"as_of": "2026-05-16T06:45:00.000Z"
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Status codes:
|
|
165
|
+
|
|
166
|
+
- `400 Bad Request`: malformed JSON, missing required field, invalid identifier prefix, invalid `refund_scope`, invalid `initiator`, invalid `reason_code`.
|
|
167
|
+
- `401 Unauthorized`: missing or invalid caller credential.
|
|
168
|
+
- `403 Forbidden`: caller is authenticated but not allowed to act for the named `organization_id`.
|
|
169
|
+
- `404 Not Found`: referenced `reservation_id`, `lesson_id`, `order_id`, or `person_id` does not resolve through the authoritative surface.
|
|
170
|
+
- `409 Conflict`: live commercial or lock state prevents refund initiation.
|
|
171
|
+
- `422 Unprocessable Entity`: well-formed request fails a refund business rule.
|
|
172
|
+
- `429 Too Many Requests`: per-organization or caller rate limit exceeded.
|
|
173
|
+
- `500` or `503`: Revenue cannot verify or persist the refund initiation. Consumers SHALL treat this branch as unverifiable and SHOULD avoid assuming a refund exists until retry or operator review succeeds.
|
|
174
|
+
|
|
175
|
+
HTTP 409 `conflict_reason` values:
|
|
176
|
+
|
|
177
|
+
- `lock_release_required`: the refund is lock-tied and the corresponding Revenue release has not been recorded or composed into the initiation transaction.
|
|
178
|
+
- `confirmed_state_requires_joint_release`: the lesson is confirmed and the caller must use the joint Delivery and Revenue confirmed-to-released path once `sales-scheduling-surface` v1.1 exists.
|
|
179
|
+
- `refund_already_initiated`: a refund already exists for the same scope under a different idempotency key.
|
|
180
|
+
- `refund_already_completed`: the refund is already completed.
|
|
181
|
+
- `amount_exceeds_refundable_balance`: requested amount is greater than the refundable balance.
|
|
182
|
+
- `order_not_paid`: the order has no refundable payment.
|
|
183
|
+
- `person_mismatch`: identifiers resolve to different Persons.
|
|
184
|
+
- `organization_mismatch`: identifiers resolve across more than one Organization.
|
|
185
|
+
- `lesson_mismatch`: `lesson_id` does not match the reservation.
|
|
186
|
+
- `idempotency_payload_mismatch`: the `Idempotency-Key` was already used with a different payload in the same `organization_id`.
|
|
187
|
+
- `concurrent_state_change`: commercial or lock state changed while Revenue was evaluating the request.
|
|
188
|
+
|
|
189
|
+
Consumers SHALL treat unknown `conflict_reason` values as safe to surface to an operator and unsafe for automated retry branching.
|
|
190
|
+
|
|
191
|
+
## 5. Lock-tied refund invariant
|
|
192
|
+
|
|
193
|
+
For `refund_scope: "reservation"`, Revenue SHALL enforce the lock-released invariant from the parent contract. If the reservation has reached a state where refunding requires release of a confirmed or locked reservation, Revenue SHALL either:
|
|
194
|
+
|
|
195
|
+
- record the Revenue release and refund initiation in the same producer-transactional operation, or
|
|
196
|
+
- return HTTP 409 with `conflict_reason: confirmed_state_requires_joint_release` or `lock_release_required`.
|
|
197
|
+
|
|
198
|
+
Revenue SHALL NOT emit `refund.initiated` for a lock-tied confirmed cancellation while leaving the corresponding release requirement invisible. This is the boundary Delivery's `sales-scheduling-surface` v1.1 work composes against.
|
|
199
|
+
|
|
200
|
+
## 6. Producer responsibilities
|
|
201
|
+
|
|
202
|
+
Revenue SHALL create the Refund record and emit `refund.initiated` in the same transaction per ADR-0009. A failure to publish the event rolls back the refund initiation writeback. A failed provider call does not emit `refund.initiated`; it returns an error or records a provider failure through the provider-action surface.
|
|
203
|
+
|
|
204
|
+
Revenue SHALL preserve writeback separation. Provider refund dispatch, Refund record writeback, lock release writeback, and event publication are distinct facts even when they succeed in the same user-facing operation.
|
|
205
|
+
|
|
206
|
+
Revenue SHALL scope idempotency to `(organization_id, idempotency_key)` and retain keys for at least 24 hours. Replays with the same key and same normalized payload return the original result. Replays with the same key and a different normalized payload return HTTP 409 with `conflict_reason: idempotency_payload_mismatch`.
|
|
207
|
+
|
|
208
|
+
## 7. Consumer responsibilities
|
|
209
|
+
|
|
210
|
+
Sales SHALL use this endpoint, not the reservation-release endpoint, when a confirmed lesson cancellation requires refund initiation. Sales SHALL surface HTTP 409 `confirmed_state_requires_joint_release` as the signal that Delivery's confirmed-to-released v1.1 path is required.
|
|
211
|
+
|
|
212
|
+
Delivery SHALL treat this sub-spec as the Revenue precondition for authoring the `sales-scheduling-surface` v1.1 confirmed-to-released cancellation path. Delivery's surface remains responsible for scheduling-side cancellation state; Revenue's surface remains responsible for refund initiation, commercial writeback, and refund events.
|
|
213
|
+
|
|
214
|
+
Consumers SHALL correlate `refund.initiated` and `refund.completed` by `refund_id`.
|
|
215
|
+
|
|
216
|
+
## 8. Versioning
|
|
217
|
+
|
|
218
|
+
Patch versions may clarify prose and add non-normative examples. Minor versions may add optional request fields, optional response fields, or new `conflict_reason` values. Major versions are required to remove or rename fields, change required fields, change idempotency semantics, change the path, or remove the lock-tied refund invariant.
|