Skip to content

feat(promo-codes): domain-authorized promo codes for early registration access#525

Open
caseylocker wants to merge 22 commits intomainfrom
feature/promo-codes-early-registration
Open

feat(promo-codes): domain-authorized promo codes for early registration access#525
caseylocker wants to merge 22 commits intomainfrom
feature/promo-codes-early-registration

Conversation

@caseylocker
Copy link
Copy Markdown
Contributor

@caseylocker caseylocker commented Apr 9, 2026

ref: https://app.clickup.com/t/86b952pgc

Summary

  • Implement domain-based early registration access via two new promo code subtypes: DomainAuthorizedSummitRegistrationDiscountCode (with discount) and DomainAuthorizedSummitRegistrationPromoCode (access-only)
  • Add WithPromoCode audience value on SummitTicketType for promo-code-only ticket types
  • Add auto-discovery endpoint (GET /api/v1/summits/{id}/promo-codes/all/discover) that finds matching codes for the authenticated user's email
  • Add auto_apply support to domain-authorized types and existing email-linked types (Member/Speaker)
  • Add QuantityPerAccount enforcement at both discovery and checkout time
  • Comprehensive unit + integration tests covering domain matching, audience filtering, collision avoidance, serialization, discovery, and checkout enforcement

SDS Implementation (12 tasks)

All 12 tasks implemented. All review follow-ups resolved. Two open deviations remain:

  • D3 (SHOULD-FIX): allowed_email_domains validation needs custom rule (currently sometimes|json)
  • D4 (MUST-FIX): TOCTOU window in QuantityPerAccount checkout enforcement — ApplyPromoCodeTask needs to move after ReserveOrderTask in saga chain

Files changed (35 files, +3127/-53)

  • New models: DomainAuthorizedSummitRegistrationDiscountCode, DomainAuthorizedSummitRegistrationPromoCode
  • New traits: DomainAuthorizedPromoCodeTrait, AutoApplyPromoCodeTrait
  • New interface: IDomainAuthorizedPromoCode
  • New serializers, factory updates, validation rules, repository queries
  • Migration: Version20260401150000
  • Discovery endpoint: route, controller, service
  • Checkout enforcement in ApplyPromoCodeTask
  • Unit tests: DomainAuthorizedPromoCodeTest (30 tests)
  • Integration tests: 8 tests in OAuth2SummitPromoCodesApiTest

Test plan

  • php artisan test --filter=DomainAuthorizedPromoCodeTest — all unit tests pass
  • php artisan test --filter="OAuth2SummitPromoCodesApiTest::testDiscover" — discovery integration tests pass
  • Migration up and down run cleanly
  • Manual API test: create domain-authorized promo code, hit discover endpoint, verify response
  • Verify existing promo code types are unaffected (regression)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Domain-authorized promo codes: email-domain eligibility, per-account limits, and remaining-per-account info.
    • Auto-apply flag added to multiple promo/discount code types.
    • Public promo-code discovery endpoint returning codes applicable to the authenticated user.
    • Support for promo-code-only ticket types and inclusion in ticket selection logic.
  • Tests

    • Extensive unit and API tests covering discovery, domain matching, per-account/global limits, serialization, and validation.

caseylocker and others added 10 commits April 8, 2026 14:44
…registration access

Adds two new promo code subtypes (DomainAuthorizedSummitRegistrationDiscountCode
and DomainAuthorizedSummitRegistrationPromoCode) enabling domain-based early
registration access. Adds WithPromoCode ticket type audience value for
promo-code-only distribution. Includes auto-discovery endpoint, per-account
quantity enforcement at checkout, and auto_apply support for existing
member/speaker promo code types.

SDS: doc/promo-codes-for-early-registration-access.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Task 1: Add ClassName discriminator ENUM widening in migration, add
  data guard before narrowing Audience ENUM in down()
- Task 2: Guard matchesEmailDomain() against emails missing @ to
  prevent false-positive suffix matches
- Task 3: Replace canBeAppliedTo() with direct collection membership
  check in addTicketTypeRule() (Truth #4), override removeTicketTypeRule()
  to prevent parent from re-adding to allowed_ticket_types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Task 5: accepted NITs for constant naming, interface gap, and
pre-existing edge cases.
Task 7: MUST-FIX — canBuyRegistrationTicketByType() missing
WithPromoCode branch blocks checkout for promo-code-only tickets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace 'sometimes|json' with custom AllowedEmailDomainsArray rule for
  allowed_email_domains validation — accepts pre-decoded PHP array and
  validates each entry against @domain.com/.tld/user@email formats
- Remove json_decode() from factory populate for both domain-authorized
  types — value is already a PHP array after request decoding
- Fix expand=allowed_ticket_types silently dropping field on
  DomainAuthorizedSummitRegistrationDiscountCodeSerializer — extend
  re-add guard to check both $relations and $expand
- Rename json_array → json_string_array in both new serializers

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Add WithPromoCode branch to canBuyRegistrationTicketByType() so promo-code-only
ticket types are not rejected at checkout for both invited and non-invited users.
Replace isSoldOut() with canSell() in the strategy's WithPromoCode loop to align
listing visibility with checkout enforcement. Add 5 unit tests for audience-based
filtering scenarios required by Task 7 DoD.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Task 8: wrap INSTANCE OF chain in parentheses to preserve summit
scoping, simplify speaker email matching via getOwnerEmail(), and
exclude cancelled tickets from quantity-per-account count.

Task 9: add remaining_quantity_per_account (null) to all four
member/speaker serializers, re-add allowed_ticket_types to member
and speaker discount code serializers, and declare setter/getter
on IDomainAuthorizedPromoCode interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ity_per_account

Move ApplyPromoCodeTask after ReserveOrderTask in the saga chain so
ticket rows exist when the count query fires. Broaden
getTicketCountByMemberAndPromoCode to include 'Reserved' orders,
ensuring concurrent checkouts correctly see each other's reservations.
Remove the TOCTOU-vulnerable pre-check from PreProcessReservationTask
and relocate it inside ApplyPromoCodeTask's locked transaction, where
it naturally fires once per unique promo code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…sion, canBeAppliedTo, discovery, checkout

- Fix base class: extend Tests\TestCase instead of PHPUnit\Framework\TestCase (boots Laravel facades)
- Add 3 collision avoidance tests for DomainAuthorizedSummitRegistrationDiscountCode
- Add 2 canBeAppliedTo override tests (free ticket guard bypass)
- Add 4 auto_apply tests for existing email-linked types (Member/Speaker promo/discount)
- Fix vacuous testWithPromoCodeAudienceNoPromoCodeNotReturned (now asserts on real data)
- Add 3 serializer tests (auto_apply, remaining_quantity_per_account, email-linked type)
- Rename misleading test to testWithPromoCodeAudienceLivePromoCodeReturned
- Add 5 discovery endpoint integration tests in OAuth2SummitPromoCodesApiTest
- Add 3 checkout enforcement test stubs (2 need order pipeline harness, 1 blocked by D4)
- Mark all 9 review follow-ups complete in SDS doc

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 9, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c8da44a7-1eb6-40b0-9e46-8c8556239286

📥 Commits

Reviewing files that changed from the base of the PR and between d824974 and 93bc180.

📒 Files selected for processing (2)
  • app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php
  • app/Services/Model/Imp/SummitOrderService.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/Services/Model/Imp/SummitOrderService.php

📝 Walkthrough

Walkthrough

Adds domain-authorized promo/discount code types with email-domain validation and per-account limits, adds an auto-apply flag across promo types, exposes a discover endpoint for authenticated members, enforces promo-code-only ticket purchases and per-account limits during reservation, and updates serializers, repository logic, migrations, and tests.

Changes

Cohort / File(s) Summary
Domain-Authorized Models & Traits
app/Models/.../PromoCodes/DomainAuthorizedSummitRegistrationPromoCode.php, app/Models/.../PromoCodes/DomainAuthorizedSummitRegistrationDiscountCode.php, app/Models/.../PromoCodes/DomainAuthorizedPromoCodeTrait.php, app/Models/.../PromoCodes/IDomainAuthorizedPromoCode.php, app/Models/.../PromoCodes/AutoApplyPromoCodeTrait.php
New domain-authorized promo/discount entities, traits for allowed email domains and auto-apply, marker interface, per-account remaining-quantity transient, and overrides for ticket-type/rule logic.
Apply Auto-Apply to Existing Models
app/Models/.../PromoCodes/MemberSummitRegistrationPromoCode.php, app/Models/.../PromoCodes/MemberSummitRegistrationDiscountCode.php, app/Models/.../PromoCodes/SpeakerSummitRegistrationPromoCode.php, app/Models/.../PromoCodes/SpeakerSummitRegistrationDiscountCode.php
Added AutoApplyPromoCodeTrait and auto_apply metadata to member/speaker promo and discount types.
Serializers & Registry
app/ModelSerializers/.../DomainAuthorizedSummitRegistrationPromoCodeSerializer.php, app/ModelSerializers/.../DomainAuthorizedSummitRegistrationDiscountCodeSerializer.php, app/ModelSerializers/.../MemberSummitRegistrationPromoCodeSerializer.php, app/ModelSerializers/.../MemberSummitRegistrationDiscountCodeSerializer.php, app/ModelSerializers/.../SpeakerSummitRegistrationPromoCodeSerializer.php, app/ModelSerializers/.../SpeakerSummitRegistrationDiscountCodeSerializer.php, app/ModelSerializers/SerializerRegistry.php
Added serializers for domain-authorized types, added auto_apply mapping to existing serializers, ensure allowed_ticket_types expansion handling and include remaining_quantity_per_account; registry entries added.
API: Controller, Route, Seeder
app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php, routes/api_v1.php, database/seeders/ApiEndpointsSeeder.php
New OAuth2 discover endpoint GET /api/v1/summits/{id}/promo-codes/all/discover with controller action and seeded API entry.
Service & Repository Discovery
app/Services/Model/ISummitPromoCodeService.php, app/Services/Model/Imp/SummitPromoCodeService.php, app/Models/Foundation/Summit/Repositories/ISummitRegistrationPromoCodeRepository.php, app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php
Added discoverPromoCodes service method; repository additions getDiscoverableByEmailForSummit() and getTicketCountByMemberAndPromoCode() to load and filter discoverable codes by email/domain, liveliness, and per-account usage.
Order Reservation & Saga Tasks
app/Services/Model/Imp/SummitOrderService.php
Reordered saga tasks; PreProcessReservationTask now rejects promo-code-only ticket types when no promo code present; ApplyPromoCodeTask enforces per-account limits for domain-authorized codes; constructors updated and ReserveOrderTask::undo() implemented.
Ticket Types & Strategy
app/Models/Foundation/Summit/Registration/SummitTicketType.php, app/Models/Foundation/Summit/Registration/PromoCodes/Strategies/RegularPromoCodeTicketTypesStrategy.php, app/Models/Foundation/Summit/Summit.php
Added Audience_With_Promo_Code and isPromoCodeOnly(); strategy now includes promo-code-only types when a live promo is present; Summit::canBuyRegistrationTicketByType short-circuits for promo-code audience.
Factory, Constants, Validation Rule
app/Models/Foundation/Summit/Factories/SummitPromoCodeFactory.php, app/Models/Foundation/Summit/Registration/PromoCodes/PromoCodesConstants.php, app/Http/Controllers/.../PromoCodesValidationRulesFactory.php, app/Rules/AllowedEmailDomainsArray.php
Factory builds/populates domain-authorized types and auto_apply; constants updated to include new class names; validation rules accept auto_apply and new AllowedEmailDomainsArray rule added.
Doctrine Mapping & Migration
app/Models/Foundation/Summit/Registration/PromoCodes/SummitRegistrationPromoCode.php, database/migrations/model/Version20260401150000.php
STI discriminator extended for new subtypes; migration adds joined subtype tables, AllowedEmailDomains, QuantityPerAccount, AutoApply columns, WithPromoCode audience value, and updates to existing joined tables.
Tests
tests/Unit/Services/DomainAuthorizedPromoCodeTest.php, tests/Unit/Services/PreProcessReservationTaskTest.php, tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php, tests/oauth2/OAuth2SummitPromoCodesApiTest.php, tests/Unit/Services/SagaCompensationTest.php
Extensive unit and integration tests covering domain matching, per-account limits, discovery filtering, serializer outputs, reservation enforcement, saga compensation, and API behavior.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Authenticated Client
    participant Controller as OAuth2 Controller
    participant Service as PromoCodeService
    participant Repository as PromoCodeRepo
    participant DB as Database

    Client->>Controller: GET /promo-codes/all/discover
    Controller->>Controller: Load Summit & Current User
    Controller->>Service: discoverPromoCodes(summit, member)
    Service->>Repository: getDiscoverableByEmailForSummit(summit, email)
    Repository->>DB: Query promo/discount codes by email/domain/subtype
    DB-->>Repository: Return matching codes
    Repository-->>Service: codes[]
    loop Filter Each Code
        Service->>Service: Check isLive() & hasQuantityAvailable()
        Service->>Repository: getTicketCountByMemberAndPromoCode() (for domain-authorized)
        Repository-->>Service: usedCount
        Service->>Service: Enforce per-account limit & set remaining_quantity_per_account
    end
    Service-->>Controller: Filtered codes[]
    Controller->>Controller: Serialize each code (expand/fields/relations)
    Controller-->>Client: JSON {data: [], total, per_page, current_page, last_page}
Loading
sequenceDiagram
    participant Order as OrderService
    participant PreProcess as PreProcessReservationTask
    participant Reserve as ReserveOrderTask
    participant Apply as ApplyPromoCodeTask
    participant Repo as PromoCodeRepo
    participant Promo as PromoCode Model

    Order->>PreProcess: run() - validate tickets
    PreProcess->>PreProcess: Reject if isPromoCodeOnly() and no promo_code
    PreProcess-->>Order: reservations state
    Order->>Reserve: run() - reserve tickets
    Reserve-->>Order: order & tickets created
    Order->>Apply: run() - apply promo code
    Apply->>Repo: Load promo code
    Repo-->>Apply: PromoCode
    Apply->>Promo: If IDomainAuthorizedPromoCode?
    alt Domain-Authorized
        Apply->>Repo: getTicketCountByMemberAndPromoCode()
        Repo-->>Apply: usedCount
        Apply->>Promo: Check usedCount < getQuantityPerAccount()
        alt Over Limit
            Apply-->>Order: ValidationException
        else Under Limit
            Apply->>Promo: Apply redemption
            Apply-->>Order: Success
        end
    else Regular Code
        Apply->>Promo: Apply redemption
        Apply-->>Order: Success
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • smarcet
  • romanetar
  • martinquiroga-exo

Poem

🐇 I hopped through domains and codes so bright,

I matched emails by day and by night,
auto-apply carrots rolled into place,
discover lit up each eager face,
a rabbit cheers — hop, validate, delight!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 74.19% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly and concisely summarizes the main change: introducing domain-authorized promo codes for early registration access, which is the primary feature implemented across the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/promo-codes-early-registration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@caseylocker caseylocker self-assigned this Apr 9, 2026
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

The GET /api/v1/summits/{id}/promo-codes/all/discover route was added in
Task 12 but never seeded into the api_endpoints table. The OAuth2 bearer
token validator middleware rejects any unregistered route with a 400
"API endpoint does not exits" error, causing 5 discover-endpoint integration
tests in OAuth2SummitPromoCodesApiTest to fail.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

The discover endpoint's seeder entry intentionally omits authz_groups per
SDS Task 9 ("any authenticated user with read scope"). The auth.user
middleware requires at least one matching group, so every request fell
through to a 403. Switch to rate.limit:25,1 to match the adjacent
pre-validate-promo-code route, which has the same "any authenticated user"
profile. OAuth2 bearer auth and scope enforcement are still applied via
the parent 'api' middleware group.

All 5 discover integration tests now pass (verified locally).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

…ted discovery codes

Two review findings on the promo-codes branch.

P1 — `POST /orders` allowed reserving audience=WithPromoCode ticket types
with just a `type_id` and no `promo_code`. `Summit::canBuyRegistrationTicketByType`
unconditionally returns true for that audience, and
`PreProcessReservationTask::run` only validated a promo code when one was
supplied. Add an explicit `isPromoCodeOnly() && empty($promo_code_value)`
guard that throws ValidationException; reuses the existing
`SummitTicketType::isPromoCodeOnly()` helper.

P2 — Promo code discovery endpoint returned globally exhausted finite codes
(`quantity_used >= quantity_available`). The repository filter uses
`isLive()` which is dates-only, and the service layer only enforced the
per-account quota. Add a `hasQuantityAvailable()` short-circuit at the top
of `SummitPromoCodeService::discoverPromoCodes` so discovery matches
`validate()` behavior at checkout.

Regression tests:
- `tests/Unit/Services/PreProcessReservationTaskTest.php` — pure PHPUnit
  unit tests for the WithPromoCode guard (reject + non-overreach).
- `tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php` — pure
  PHPUnit unit tests for the global exhaustion filter (reject, healthy
  passes, mixed batch).
- `tests/oauth2/OAuth2SummitPromoCodesApiTest.php` —
  `testDiscoverExcludesGloballyExhaustedCodes`, sibling to the existing
  per-account exhaustion integration test.

Mutation-verified: temporarily reverted both fixes, confirmed that 3 of 5
new unit tests fail as expected, then restored.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

Follow-up to b87cefd addressing Codex review suggestions #2 and #4.

PreProcessReservationTaskTest: add two mixed-payload tests exercising the
per-ticket guard in heterogeneous reservations (promo-only +
Audience_All), both orderings. The original tests only covered
single-ticket payloads.

- testRejectsMixedPayloadWithPromoCodeOnlyFirst — guard fires on first iter.
- testRejectsMixedPayloadWithPromoCodeOnlySecond — guard fires after prior
  aggregation; proves the exception short-circuits cleanly.

SummitPromoCodeServiceDiscoveryTest: add an infinite-code overreach test
that pins the `quantity_available == 0` semantics — `hasQuantityAvailable()`
short-circuits to true for infinite codes, so the exhaustion guard must
not drop them.

- testDiscoverReturnsInfiniteDomainAuthorizedCode.

Mutation-verified: reverting the production fixes causes the 3 reject
tests to fail while the infinite-code and healthy-code tests still pass,
as expected for overreach guards.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

Serializer unit tests (testSerializerAutoApplyField,
testSerializerRemainingQuantityPerAccount, testSerializerAutoApplyEmailLinkedType)
were failing because bare model instances lacked a Summit association, causing
getSummitId() to call getId() on null. Added buildMockSummitForSerializer()
helper and setSummit() calls in all three tests. Updated D3 deviation status
to RESOLVED — AllowedEmailDomainsArray custom rule was already implemented.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

@caseylocker caseylocker requested review from JpMaxMan and smarcet April 10, 2026 14:49
@caseylocker caseylocker marked this pull request as ready for review April 10, 2026 14:50
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/Services/Model/Imp/SummitOrderService.php (2)

284-296: ⚠️ Potential issue | 🔴 Critical

The saga state is dropped before ApplyPromoCodeTask.

After this reorder, ApplyPromoCodeTask::run() no longer receives promo_codes_usage because ReserveOrderTask::run() replaces the state with ['order' => $order]. That means promo-code redemption/enforcement now runs with missing input, and any failure happens after the order was already persisted while ReserveOrderTask::undo() is still empty.

🛠️ Minimal state fix
-            return ['order' => $order];
+            return array_merge($this->formerState, ['order' => $order]);

Also applies to: 667-674

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Services/Model/Imp/SummitOrderService.php` around lines 284 - 296,
ReserveOrderTask::run() currently replaces the saga state (losing keys like
promo_codes_usage) so ApplyPromoCodeTask::run() no longer receives required
promo data; fix by preserving/merging state instead of overwriting (ensure
ReserveOrderTask::run() returns array_merge($existingState, ['order' => $order])
or similar), or alternatively move the ApplyPromoCodeTask invocation to run
before ReserveOrderTask so promo redemption happens prior to persisting the
order; also apply the same fix at the other occurrence referenced (lines
~667-674) and ensure undo() implementations handle partial persistence.

728-742: ⚠️ Potential issue | 🟠 Major

Guest/public reservations can bypass QuantityPerAccount.

This check is skipped whenever the saga was built with a null $owner, but ReserveOrderTask can later attach the order to an existing member via owner_email. In that flow, the per-account limit is never enforced even though the order now belongs to an account. Resolve the owner from the reserved order (or by owner_email) before counting usage.

Also applies to: 791-809

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Services/Model/Imp/SummitOrderService.php` around lines 728 - 742, The
constructor currently stores a nullable $owner causing per-account checks
(QuantityPerAccount) to be skipped; update the reserve flow so that before
enforcing QuantityPerAccount (in ReserveOrderTask and the methods around the
QuantityPerAccount check referenced at lines ~791-809) you resolve the actual
owner from the reserved order (or by owner_email) instead of relying on the
initial $this->owner being non-null; specifically, locate the QuantityPerAccount
enforcement code and change it to obtain the owner via the reserved Order object
(or lookup by owner_email) and then perform the per-account usage count against
that resolved Member before allowing the reservation to proceed.
🧹 Nitpick comments (10)
app/Models/Foundation/Summit/Summit.php (1)

5555-5568: Keep promo-code visibility separate from purchase authorization.

Returning true here means models\summit\Summit::canBuyRegistrationTicketByType() no longer answers “can this email buy this ticket type?” for models\summit\SummitTicketType::Audience_With_Promo_Code; it only says the ticket is not invitation-gated. That makes the helper easy to reuse incorrectly later and accidentally bypass the promo-code requirement. Consider threading promo-code context into this check, or splitting visibility from authorization into separate helpers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Models/Foundation/Summit/Summit.php` around lines 5555 - 5568, The
current Summit::canBuyRegistrationTicketByType() unconditionally returns true
for SummitTicketType::Audience_With_Promo_Code which conflates visibility with
purchase authorization; instead remove the unconditional return and either (a)
add a promo-context parameter (e.g. $promoCodeProvided or $promoContext) to
canBuyRegistrationTicketByType and check promo validity before granting
purchase, or (b) split responsibilities by creating a separate visibility helper
(e.g. isTicketTypeVisibleToEmail or canViewTicketType) that returns true for
Audience_With_Promo_Code while leaving canBuyRegistrationTicketByType to enforce
promo-code checks; update callers to pass the promo context or use the new
visibility helper as appropriate.
app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php (1)

1585-1602: OpenAPI annotation missing fields and relations query parameters.

The implementation at lines 1617-1622 reads fields and relations from the request, but the OpenAPI annotation only documents the expand parameter. Consider adding the missing parameters for complete API documentation.

         parameters: [
             new OA\Parameter(name: "id", in: "path", required: true, schema: new OA\Schema(type: "integer")),
             new OA\Parameter(name: "expand", in: "query", required: false, schema: new OA\Schema(type: "string")),
+            new OA\Parameter(name: "fields", in: "query", required: false, schema: new OA\Schema(type: "string")),
+            new OA\Parameter(name: "relations", in: "query", required: false, schema: new OA\Schema(type: "string")),
         ],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php`
around lines 1585 - 1602, The OpenAPI annotation for the controller method (the
OA\Get block above discoverPromoCodesBySummit in
OAuth2SummitPromoCodesApiController) is missing the query parameters used in the
implementation; add two OA\Parameter entries for "fields" and "relations" (both
in="query", required=false, schema type="string") with brief descriptions (e.g.,
"Comma-separated list of fields to include" and "Comma-separated list of
relations to expand") so the documented parameters match the code that reads
fields and relations from the request.
app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php (1)

668-721: Discovery filtering logic is correct but has minor duplication.

The method correctly:

  • Normalizes email to lowercase
  • Uses INSTANCE OF to fetch only discoverable code types
  • Applies type-specific matching (domain vs. owner email)
  • Checks isLive() for all codes

The member and speaker code blocks (lines 702-716) share identical logic. Consider extracting to reduce duplication:

♻️ Optional refactor to reduce duplication
-            // Email-linked types: match by associated member/speaker email
-            if ($code instanceof MemberSummitRegistrationPromoCode || $code instanceof MemberSummitRegistrationDiscountCode) {
-                $ownerEmail = $code->getOwnerEmail();
-                if (!empty($ownerEmail) && strtolower($ownerEmail) === $email && $code->isLive()) {
-                    $results[] = $code;
-                }
-                continue;
-            }
-
-            if ($code instanceof SpeakerSummitRegistrationPromoCode || $code instanceof SpeakerSummitRegistrationDiscountCode) {
-                $ownerEmail = $code->getOwnerEmail();
-                if (!empty($ownerEmail) && strtolower($ownerEmail) === $email && $code->isLive()) {
-                    $results[] = $code;
-                }
-                continue;
-            }
+            // Email-linked types: match by associated member/speaker email
+            if (method_exists($code, 'getOwnerEmail')) {
+                $ownerEmail = $code->getOwnerEmail();
+                if (!empty($ownerEmail) && strtolower($ownerEmail) === $email && $code->isLive()) {
+                    $results[] = $code;
+                }
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php`
around lines 668 - 721, The member and speaker owner-email checks inside
getDiscoverableByEmailForSummit are duplicated; extract the shared logic into a
small private helper (e.g., private function isOwnerEmailMatch($code, string
$email): bool) that reads $code->getOwnerEmail(), lowercases and compares to
$email and checks $code->isLive(), then replace the two separate blocks (the
checks against MemberSummitRegistrationPromoCode,
MemberSummitRegistrationDiscountCode and against
SpeakerSummitRegistrationPromoCode, SpeakerSummitRegistrationDiscountCode) with
either a single instanceof-any conditional that calls the helper or two concise
calls to the helper, leaving domain-authorized logic unchanged.
app/Rules/AllowedEmailDomainsArray.php (1)

70-73: trans() used with literal string instead of translation key.

The trans() function expects a translation key, not a literal message. As written, it will return the string as-is. For proper i18n support:

-        return trans('The :attribute must be an array of valid email domain patterns (`@domain.com`, .tld, or user@example.com).');
+        return 'The :attribute must be an array of valid email domain patterns (`@domain.com`, .tld, or user@example.com).';

Or define a proper translation key if i18n is required.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Rules/AllowedEmailDomainsArray.php` around lines 70 - 73, The message()
method in AllowedEmailDomainsArray currently calls trans() with a literal
string; replace it to use a translation key (e.g. use
__('validation.allowed_email_domains_array') or
trans('validation.allowed_email_domains_array')) in the message() method and add
the corresponding key/value in your language files
(resources/lang/{locale}/validation.php) so i18n works correctly; reference:
AllowedEmailDomainsArray::message().
app/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCodeSerializer.php (1)

21-48: Consider adding expand_mappings for allowed_ticket_types.

The promo code model includes an allowed_ticket_types relationship (inherited from SummitRegistrationPromoCode), and the parallel DomainAuthorizedSummitRegistrationDiscountCodeSerializer includes expand_mappings for this field. For API consistency, add the same mapping to the promo code serializer:

+    protected static $expand_mappings = [
+        'allowed_ticket_types' => [
+            'type' => \Libs\ModelSerializers\Many2OneExpandSerializer::class,
+            'getter' => 'getAllowedTicketTypes',
+        ],
+    ];
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCodeSerializer.php`
around lines 21 - 48, The serializer
DomainAuthorizedSummitRegistrationPromoCodeSerializer is missing an
expand_mappings entry for the inherited allowed_ticket_types relation; add a
protected static $expand_mappings property (matching the pattern used in
DomainAuthorizedSummitRegistrationDiscountCodeSerializer) that maps
'allowed_ticket_types' to the appropriate expansion key/serializer (e.g., the
allowed_ticket_types expansion that returns the ticket types collection using
the same serializer used elsewhere for ticket types), so API consumers can
expand allowed_ticket_types when calling the serialize method.
app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php (1)

122-134: Add PHPDoc documentation for $company parameter — it's part of the interface contract across all promo code implementations.

The $company parameter is unused in this method but is retained for consistency with the base class signature SummitRegistrationPromoCode::checkSubject(), which is polymorphically implemented across all promo code trait variants (Member, Speaker, Speakers, Sponsor, DomainAuthorized). Add a brief PHPDoc note explaining its retention for interface compatibility:

/**
 * `@param` string $email
 * `@param` null|string $company Retained for interface compatibility; not used in domain-authorized validation
 * `@return` bool
 * `@throws` ValidationException
 */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php`
around lines 122 - 134, Add a PHPDoc block to the
DomainAuthorizedPromoCodeTrait::checkSubject method documenting the unused
$company parameter for interface compatibility; update the docblock to include
`@param` string $email, `@param` null|string $company Retained for interface
compatibility; not used in domain-authorized validation, `@return` bool and
`@throws` ValidationException so the method signature matches the
SummitRegistrationPromoCode::checkSubject contract and clarifies why $company is
intentionally unused.
app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCode.php (1)

51-58: This override doesn't implement the documented audience bypass.

Right now it only forwards to parent::addAllowedTicketType(), so the “regardless of audience value” behavior in the comment is not actually expressed here. Either remove the override/comment or put the intended bypass logic in this method.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCode.php`
around lines 51 - 58, The override addAllowedTicketType in
DomainAuthorizedSummitRegistrationPromoCode currently just calls
parent::addAllowedTicketType and does not implement the claimed "regardless of
audience" behavior; either remove this override and its comment, or implement
the bypass by directly adding the SummitTicketType to this promo's allowed
ticket collection without performing the parent's audience check (e.g., perform
the collection add/update inside addAllowedTicketType on
DomainAuthorizedSummitRegistrationPromoCode rather than calling
parent::addAllowedTicketType). Ensure you update or remove the doc comment to
match the actual behavior.
tests/oauth2/OAuth2SummitPromoCodesApiTest.php (1)

1085-1116: These skipped checkout tests won't catch regressions.

Right now the new quantity-per-account checkout path has no protection from this file because all three enforcement tests are permanently skipped. If the full OAuth/saga harness is too heavy, a narrower test around ApplyPromoCodeTask::run() would still cover the new guard.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/oauth2/OAuth2SummitPromoCodesApiTest.php` around lines 1085 - 1116, The
three checkout tests in OAuth2SummitPromoCodesApiTest.php are skipped and leave
the new quantity-per-account enforcement untested; add focused unit tests that
call ApplyPromoCodeTask::run() directly (instead of the full saga) to cover
over-limit, under-limit, and basic concurrency behavior: create tests that
construct an ApplyPromoCodeTask with mocked dependencies (e.g., promo
repository, order repository/count query, and any lock/persistence layer), stub
the member's existing redemptions count to simulate over-limit and under-limit
scenarios and assert the task throws the expected rejection or completes
successfully, and for concurrency simulate the reservation/count behavior via
mocks to assert the pessimistic-lock path; place these tests in
OAuth2SummitPromoCodesApiTest.php replacing the skipped methods
(testCheckoutRejectsOverLimitQuantityPerAccount,
testCheckoutSucceedsUnderLimitQuantityPerAccount,
testCheckoutConcurrentEnforcement) so ApplyPromoCodeTask::run() is directly
exercised.
tests/Unit/Services/DomainAuthorizedPromoCodeTest.php (1)

291-305: Vacuously true assertion reduces test value.

Line 304 asserts that id 99 is not in the result, but no ticket type with id 99 was created in this test. The assertion will always pass regardless of the actual filtering behavior.

To strengthen this test, either:

  1. Remove the misleading assertion about id 99 (the test still proves the strategy returns results via the id 30 check), or
  2. Add a WithPromoCode ticket type to the promo code mock and verify the strategy correctly excludes it when no promo code is provided.
♻️ Suggested fix — remove misleading assertion
         $ids = array_map(fn($tt) => $tt->getId(), $result);
         // Audience_All type IS returned (non-vacuous: proves the strategy produces results)
         $this->assertContains(30, $ids, 'Audience_All ticket type should be returned without a promo code');
-        // WithPromoCode type (id 99) is NOT returned — it only lives in promo_code->getAllowedTicketTypes()
-        $this->assertNotContains(99, $ids, 'WithPromoCode ticket types should not be returned without a promo code');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/Unit/Services/DomainAuthorizedPromoCodeTest.php` around lines 291 -
305, The assertion is vacuous because no ticket type with id 99 exists in this
test; update the testWithPromoCodeAudienceNoPromoCodeNotReturned to actually
create a "WithPromoCode" ticket type (e.g., via buildMockTicketType(99,
SummitTicketType::Audience_Promo) or similar) and attach it only to a mocked
promo code's getAllowedTicketTypes() so that
RegularPromoCodeTicketTypesStrategy($summit, $member, null)->getTicketTypes()
has both id 30 and id 99 available in the source data and you can assert that 30
is returned while 99 is not; alternatively, if you prefer the simpler change,
remove the misleading assertNotContains(99, $ids) line entirely to avoid the
vacuous check.
app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCode.php (1)

53-60: Remove this no-op override or clarify intent with accurate documentation.

The parent SummitRegistrationPromoCode::addAllowedTicketType() contains no audience-based restrictions—it simply adds the ticket type to the collection. This override merely delegates to the parent without any custom logic, making it redundant. The docblock's claim that it "allows any ticket type regardless of audience value" is misleading, as the parent already permits any ticket type. Either remove the override or, if it serves a semantic purpose in the inheritance hierarchy, update the docblock to accurately reflect that.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCode.php`
around lines 53 - 60, The addAllowedTicketType method in
DomainAuthorizedSummitRegistrationDiscountCode is a redundant no-op that only
calls parent::addAllowedTicketType (the parent SummitRegistrationPromoCode
already permits any ticket type); either remove this override entirely to avoid
dead code, or if you want an explicit semantic override, keep the method but
replace the misleading docblock with a concise note like "Intentional
passthrough to parent to signal inherited behavior" and ensure the method body
remains a direct parent call (referencing
DomainAuthorizedSummitRegistrationDiscountCode::addAllowedTicketType and
SummitRegistrationPromoCode::addAllowedTicketType).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Models/Foundation/Summit/Factories/SummitPromoCodeFactory.php`:
- Around line 295-296: Normalize the allowed_email_domains input to an array
before calling the typed setter setAllowedEmailDomains: if
$data['allowed_email_domains'] is a string, split on commas (or another
delimiter used by CSV), trim each entry and remove empty values so you always
pass an array to SummitPromoCodeFactory::setAllowedEmailDomains; apply the same
normalization pattern for the other CSV-driven fields that expect arrays (the
other typed setters in this factory, e.g., setAllowedTicketTypes /
setTicketTypesRules or any similar setter calls around the nearby block) so
string CSV values don't throw and get silently skipped.
- Around line 201-202: Replace uses of boolval(...) for the auto_apply field
with filter_var($data['auto_apply'], FILTER_VALIDATE_BOOLEAN,
FILTER_NULL_ON_FAILURE) so string values like "false" are parsed correctly;
specifically update every branch that calls
$promo_code->setAutoApply(boolval($data['auto_apply'])) (all occurrences
handling auto_apply in SummitPromoCodeFactory.php) to first compute the boolean
via filter_var and then pass that result to $promo_code->setAutoApply, applying
the same change to each auto_apply branch mentioned (the multiple setAutoApply
call sites).

In
`@app/ModelSerializers/Summit/Registration/PromoCodes/MemberSummitRegistrationDiscountCodeSerializer.php`:
- Line 98: Replace the hardcoded null assignment to
$values['remaining_quantity_per_account'] with the actual value from the
domain-authorized discount model (e.g. call the model's getter such as
getRemainingQuantityPerAccount() on the serialized object or
$domainAuthorizedDiscount/$this->object) and only fall back to null if that
getter returns an unset/empty value; update
MemberSummitRegistrationDiscountCodeSerializer to read and serialize the real
remaining-per-account value instead of forcing null.

In `@app/Rules/AllowedEmailDomainsArray.php`:
- Around line 46-58: The .tld branch in AllowedEmailDomainsArray (the elseif
that checks str_starts_with($element, '.')) uses a regex that only allows a
single label after the leading dot; update that regex to accept multi-level
suffixes by matching a leading dot followed by one label and then zero or more
additional ".label" segments (i.e., allow repeated dot-separated labels) so
patterns like ".co.uk" or ".ac.jp" are accepted; replace the existing /^\.\w+$/
pattern in that branch accordingly.

In `@database/migrations/model/Version20260401150000.php`:
- Around line 99-117: In the down() migration, before reverting the ClassName
ENUM on SummitRegistrationPromoCode, remove or update any rows that reference
the domain-authorized discriminators so the ALTER TABLE won't fail;
specifically, after dropping the joined tables
DomainAuthorizedSummitRegistrationPromoCode and
DomainAuthorizedSummitRegistrationDiscountCode, delete (or UPDATE ClassName to a
valid value) any SummitRegistrationPromoCode rows whose ClassName equals the
domain-authorized values (e.g., the discriminator values introduced for
domain-authorized promo/discount codes) and only then run the ALTER TABLE ...
MODIFY ClassName ENUM(...) DEFAULT 'SummitRegistrationPromoCode' to narrow the
enum safely.

In `@routes/api_v1.php`:
- Around line 1953-1956: The discover route is currently rate-limited but
unauthenticated; update the route definition for the 'all/discover' endpoint to
include the authentication middleware so only authenticated users can call
OAuth2SummitPromoCodesApiController@discover. Modify the middleware array on the
Route::get for 'discover' to add 'auth.user' alongside 'rate.limit:25,1' (e.g.,
['middleware' => ['auth.user', 'rate.limit:25,1']]) so the route enforces
authentication at the routing layer before controller execution.

---

Outside diff comments:
In `@app/Services/Model/Imp/SummitOrderService.php`:
- Around line 284-296: ReserveOrderTask::run() currently replaces the saga state
(losing keys like promo_codes_usage) so ApplyPromoCodeTask::run() no longer
receives required promo data; fix by preserving/merging state instead of
overwriting (ensure ReserveOrderTask::run() returns array_merge($existingState,
['order' => $order]) or similar), or alternatively move the ApplyPromoCodeTask
invocation to run before ReserveOrderTask so promo redemption happens prior to
persisting the order; also apply the same fix at the other occurrence referenced
(lines ~667-674) and ensure undo() implementations handle partial persistence.
- Around line 728-742: The constructor currently stores a nullable $owner
causing per-account checks (QuantityPerAccount) to be skipped; update the
reserve flow so that before enforcing QuantityPerAccount (in ReserveOrderTask
and the methods around the QuantityPerAccount check referenced at lines
~791-809) you resolve the actual owner from the reserved order (or by
owner_email) instead of relying on the initial $this->owner being non-null;
specifically, locate the QuantityPerAccount enforcement code and change it to
obtain the owner via the reserved Order object (or lookup by owner_email) and
then perform the per-account usage count against that resolved Member before
allowing the reservation to proceed.

---

Nitpick comments:
In
`@app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php`:
- Around line 1585-1602: The OpenAPI annotation for the controller method (the
OA\Get block above discoverPromoCodesBySummit in
OAuth2SummitPromoCodesApiController) is missing the query parameters used in the
implementation; add two OA\Parameter entries for "fields" and "relations" (both
in="query", required=false, schema type="string") with brief descriptions (e.g.,
"Comma-separated list of fields to include" and "Comma-separated list of
relations to expand") so the documented parameters match the code that reads
fields and relations from the request.

In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php`:
- Around line 122-134: Add a PHPDoc block to the
DomainAuthorizedPromoCodeTrait::checkSubject method documenting the unused
$company parameter for interface compatibility; update the docblock to include
`@param` string $email, `@param` null|string $company Retained for interface
compatibility; not used in domain-authorized validation, `@return` bool and
`@throws` ValidationException so the method signature matches the
SummitRegistrationPromoCode::checkSubject contract and clarifies why $company is
intentionally unused.

In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCode.php`:
- Around line 53-60: The addAllowedTicketType method in
DomainAuthorizedSummitRegistrationDiscountCode is a redundant no-op that only
calls parent::addAllowedTicketType (the parent SummitRegistrationPromoCode
already permits any ticket type); either remove this override entirely to avoid
dead code, or if you want an explicit semantic override, keep the method but
replace the misleading docblock with a concise note like "Intentional
passthrough to parent to signal inherited behavior" and ensure the method body
remains a direct parent call (referencing
DomainAuthorizedSummitRegistrationDiscountCode::addAllowedTicketType and
SummitRegistrationPromoCode::addAllowedTicketType).

In
`@app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCode.php`:
- Around line 51-58: The override addAllowedTicketType in
DomainAuthorizedSummitRegistrationPromoCode currently just calls
parent::addAllowedTicketType and does not implement the claimed "regardless of
audience" behavior; either remove this override and its comment, or implement
the bypass by directly adding the SummitTicketType to this promo's allowed
ticket collection without performing the parent's audience check (e.g., perform
the collection add/update inside addAllowedTicketType on
DomainAuthorizedSummitRegistrationPromoCode rather than calling
parent::addAllowedTicketType). Ensure you update or remove the doc comment to
match the actual behavior.

In `@app/Models/Foundation/Summit/Summit.php`:
- Around line 5555-5568: The current Summit::canBuyRegistrationTicketByType()
unconditionally returns true for SummitTicketType::Audience_With_Promo_Code
which conflates visibility with purchase authorization; instead remove the
unconditional return and either (a) add a promo-context parameter (e.g.
$promoCodeProvided or $promoContext) to canBuyRegistrationTicketByType and check
promo validity before granting purchase, or (b) split responsibilities by
creating a separate visibility helper (e.g. isTicketTypeVisibleToEmail or
canViewTicketType) that returns true for Audience_With_Promo_Code while leaving
canBuyRegistrationTicketByType to enforce promo-code checks; update callers to
pass the promo context or use the new visibility helper as appropriate.

In
`@app/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCodeSerializer.php`:
- Around line 21-48: The serializer
DomainAuthorizedSummitRegistrationPromoCodeSerializer is missing an
expand_mappings entry for the inherited allowed_ticket_types relation; add a
protected static $expand_mappings property (matching the pattern used in
DomainAuthorizedSummitRegistrationDiscountCodeSerializer) that maps
'allowed_ticket_types' to the appropriate expansion key/serializer (e.g., the
allowed_ticket_types expansion that returns the ticket types collection using
the same serializer used elsewhere for ticket types), so API consumers can
expand allowed_ticket_types when calling the serialize method.

In `@app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php`:
- Around line 668-721: The member and speaker owner-email checks inside
getDiscoverableByEmailForSummit are duplicated; extract the shared logic into a
small private helper (e.g., private function isOwnerEmailMatch($code, string
$email): bool) that reads $code->getOwnerEmail(), lowercases and compares to
$email and checks $code->isLive(), then replace the two separate blocks (the
checks against MemberSummitRegistrationPromoCode,
MemberSummitRegistrationDiscountCode and against
SpeakerSummitRegistrationPromoCode, SpeakerSummitRegistrationDiscountCode) with
either a single instanceof-any conditional that calls the helper or two concise
calls to the helper, leaving domain-authorized logic unchanged.

In `@app/Rules/AllowedEmailDomainsArray.php`:
- Around line 70-73: The message() method in AllowedEmailDomainsArray currently
calls trans() with a literal string; replace it to use a translation key (e.g.
use __('validation.allowed_email_domains_array') or
trans('validation.allowed_email_domains_array')) in the message() method and add
the corresponding key/value in your language files
(resources/lang/{locale}/validation.php) so i18n works correctly; reference:
AllowedEmailDomainsArray::message().

In `@tests/oauth2/OAuth2SummitPromoCodesApiTest.php`:
- Around line 1085-1116: The three checkout tests in
OAuth2SummitPromoCodesApiTest.php are skipped and leave the new
quantity-per-account enforcement untested; add focused unit tests that call
ApplyPromoCodeTask::run() directly (instead of the full saga) to cover
over-limit, under-limit, and basic concurrency behavior: create tests that
construct an ApplyPromoCodeTask with mocked dependencies (e.g., promo
repository, order repository/count query, and any lock/persistence layer), stub
the member's existing redemptions count to simulate over-limit and under-limit
scenarios and assert the task throws the expected rejection or completes
successfully, and for concurrency simulate the reservation/count behavior via
mocks to assert the pessimistic-lock path; place these tests in
OAuth2SummitPromoCodesApiTest.php replacing the skipped methods
(testCheckoutRejectsOverLimitQuantityPerAccount,
testCheckoutSucceedsUnderLimitQuantityPerAccount,
testCheckoutConcurrentEnforcement) so ApplyPromoCodeTask::run() is directly
exercised.

In `@tests/Unit/Services/DomainAuthorizedPromoCodeTest.php`:
- Around line 291-305: The assertion is vacuous because no ticket type with id
99 exists in this test; update the
testWithPromoCodeAudienceNoPromoCodeNotReturned to actually create a
"WithPromoCode" ticket type (e.g., via buildMockTicketType(99,
SummitTicketType::Audience_Promo) or similar) and attach it only to a mocked
promo code's getAllowedTicketTypes() so that
RegularPromoCodeTicketTypesStrategy($summit, $member, null)->getTicketTypes()
has both id 30 and id 99 available in the source data and you can assert that 30
is returned while 99 is not; alternatively, if you prefer the simpler change,
remove the misleading assertNotContains(99, $ids) line entirely to avoid the
vacuous check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e384ac84-d886-48f3-8deb-ae6c4bca21ae

📥 Commits

Reviewing files that changed from the base of the PR and between 906a7a5 and 19e5f53.

📒 Files selected for processing (38)
  • app/Http/Controllers/Apis/Protected/Summit/Factories/Registration/PromoCodesValidationRulesFactory.php
  • app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php
  • app/ModelSerializers/SerializerRegistry.php
  • app/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCodeSerializer.php
  • app/ModelSerializers/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCodeSerializer.php
  • app/ModelSerializers/Summit/Registration/PromoCodes/MemberSummitRegistrationDiscountCodeSerializer.php
  • app/ModelSerializers/Summit/Registration/PromoCodes/MemberSummitRegistrationPromoCodeSerializer.php
  • app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCodeSerializer.php
  • app/ModelSerializers/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCodeSerializer.php
  • app/Models/Foundation/Summit/Factories/SummitPromoCodeFactory.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/AutoApplyPromoCodeTrait.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedPromoCodeTrait.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationDiscountCode.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/DomainAuthorizedSummitRegistrationPromoCode.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/IDomainAuthorizedPromoCode.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/MemberSummitRegistrationDiscountCode.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/MemberSummitRegistrationPromoCode.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/PromoCodesConstants.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/SpeakerSummitRegistrationDiscountCode.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/SpeakerSummitRegistrationPromoCode.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/Strategies/RegularPromoCodeTicketTypesStrategy.php
  • app/Models/Foundation/Summit/Registration/PromoCodes/SummitRegistrationPromoCode.php
  • app/Models/Foundation/Summit/Registration/SummitTicketType.php
  • app/Models/Foundation/Summit/Repositories/ISummitRegistrationPromoCodeRepository.php
  • app/Models/Foundation/Summit/Summit.php
  • app/Repositories/Summit/DoctrineSummitRegistrationPromoCodeRepository.php
  • app/Rules/AllowedEmailDomainsArray.php
  • app/Services/Model/ISummitPromoCodeService.php
  • app/Services/Model/Imp/SummitOrderService.php
  • app/Services/Model/Imp/SummitPromoCodeService.php
  • database/migrations/model/Version20260401150000.php
  • database/seeders/ApiEndpointsSeeder.php
  • doc/promo-codes-for-early-registration-access.md
  • routes/api_v1.php
  • tests/Unit/Services/DomainAuthorizedPromoCodeTest.php
  • tests/Unit/Services/PreProcessReservationTaskTest.php
  • tests/Unit/Services/SummitPromoCodeServiceDiscoveryTest.php
  • tests/oauth2/OAuth2SummitPromoCodesApiTest.php

… migration rollback

CodeRabbit flagged 6 issues on PR #525. After independent validation (Codex),
2 were confirmed as real bugs, 2 were false positives, and 2 were
informational/misframed.

Fixed (validated as real):
- **CSV import TypeError:** `allowed_email_domains` was not exploded from its
  pipe-delimited CSV string before reaching `setAllowedEmailDomains(array)`,
  causing a TypeError on domain-authorized code import. Added the same
  `explode('|', ...)` normalization used by all other CSV list fields in both
  the add and update import paths.
- **Migration down() failure:** Dropping the joined domain-authorized tables
  did not remove orphaned base-table rows, so narrowing the ClassName ENUM
  would fail if any domain-authorized promo codes existed. Added a DELETE
  statement before the ALTER TABLE.

Dismissed (validated as false positives):
- `remaining_quantity_per_account = null` in MemberDiscountCode serializer is
  correct — Member types do not have per-account quantity.
- Discover route already has OAuth2 auth via the `api` middleware group and
  an explicit controller-level null-member guard. Adding `auth.user` would
  break it (requires authz_groups, intentionally removed in 138c1f8).

Deferred:
- `boolval("false")` pattern is pre-existing across the factory (not
  introduced by this PR); warrants a separate cleanup.
- Multi-level TLD validation regex (`.co.uk`) is an enhancement, not a bug
  in the current domain-matching logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
app/Services/Model/Imp/SummitPromoCodeService.php (1)

1031-1054: Batch per-code usage lookups for discovery.

getDiscoverableByEmailForSummit() loads the candidate codes, and then each domain-authorized result triggers another getTicketCountByMemberAndPromoCode() call. On a summit with many matching codes, this becomes a 1+N query pattern on the registration hot path. Consider a repository method that returns usage counts keyed by promo-code ID for the member.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Services/Model/Imp/SummitPromoCodeService.php` around lines 1031 - 1054,
The loop in SummitPromoCodeService uses getDiscoverableByEmailForSummit() then
calls getTicketCountByMemberAndPromoCode() per code, causing 1+N queries; add a
repository method such as getTicketCountsByMemberAndPromoCodes($member, array
$promoCodeIds) that returns a map promoCodeId=>usedCount, call it once after
retrieving $codes, then in the foreach over $codes (same loop referencing
IDomainAuthorizedPromoCode, getQuantityPerAccount,
setRemainingQuantityPerAccount) read the used count from that map to decide
exhaustion and set remaining per-account quantity instead of making per-code
repository calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/Services/Model/Imp/SummitPromoCodeService.php`:
- Around line 645-647: The CSV import currently uses isset() then explode('|',
$row['allowed_email_domains']) which yields [''] for blank cells and leaves
whitespace—update both occurrences handling allowed_email_domains in
SummitPromoCodeService.php (the two blocks around the shown diff and the block
at the other path mentioned) to: after exploding by '|' trim each element and
remove any empty strings (e.g., array_map('trim', ...) followed by
array_filter(...) or equivalent) so the resulting $row['allowed_email_domains']
is an array of non-empty, trimmed domains or omitted if none remain.

In `@database/migrations/model/Version20260401150000.php`:
- Around line 103-107: Replace the hard DELETE that removes base-table rows from
SummitRegistrationPromoCode with a safe UPDATE that remaps the discriminator to
the base ClassName so rows (and their IDs/foreign links) are preserved;
specifically modify the SQL in Version20260401150000.php that targets ClassName
IN
('DomainAuthorizedSummitRegistrationDiscountCode','DomainAuthorizedSummitRegistrationPromoCode')
to run an UPDATE setting ClassName = 'SummitRegistrationPromoCode' (or the
appropriate surviving base type) instead of DELETE, ensuring child table cascade
behavior and external FK integrity remain intact.

---

Nitpick comments:
In `@app/Services/Model/Imp/SummitPromoCodeService.php`:
- Around line 1031-1054: The loop in SummitPromoCodeService uses
getDiscoverableByEmailForSummit() then calls
getTicketCountByMemberAndPromoCode() per code, causing 1+N queries; add a
repository method such as getTicketCountsByMemberAndPromoCodes($member, array
$promoCodeIds) that returns a map promoCodeId=>usedCount, call it once after
retrieving $codes, then in the foreach over $codes (same loop referencing
IDomainAuthorizedPromoCode, getQuantityPerAccount,
setRemainingQuantityPerAccount) read the used count from that map to decide
exhaustion and set remaining per-account quantity instead of making per-code
repository calls.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 629c3bf1-4fa4-415f-bf98-f0555ef818a6

📥 Commits

Reviewing files that changed from the base of the PR and between 19e5f53 and ae261a7.

📒 Files selected for processing (2)
  • app/Services/Model/Imp/SummitPromoCodeService.php
  • database/migrations/model/Version20260401150000.php

CSV import — blank allowed_email_domains cells produced [''] after
explode, which passed the empty() check on the array but caused
matchesEmailDomain() to reject every email (empty pattern is skipped,
no match found, returns false). Now trims whitespace, filters empty
strings, and unsets the key if no valid domains remain.

Migration down() — replaced DELETE with UPDATE to remap domain-authorized
rows to base types (discount→SummitRegistrationDiscountCode,
promo→SummitRegistrationPromoCode). DELETE would silently cascade through
SummitAttendeeTicket.PromoCodeID (ON DELETE CASCADE), destroying ticket
history. UPDATE preserves FK references while safely narrowing the ENUM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

…n rollback

D10: blank CSV cell for allowed_email_domains produced [''] which
silently bricked promo codes by rejecting all emails.

D11: migration down() DELETE cascaded through SummitAttendeeTicket FK,
destroying ticket history. Replaced with UPDATE to base types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/Services/Model/Imp/SummitPromoCodeService.php (1)

1037-1056: Push discovery availability checks down into the repository.

discoverPromoCodes() now compensates for getDiscoverableByEmailForSummit() by post-filtering exhausted codes and then issuing one ticket-count lookup per returned domain-authorized code. Folding those predicates into the repository would keep discovery to one query path and avoid O(N) follow-up calls as matching code volume grows.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/Services/Model/Imp/SummitPromoCodeService.php` around lines 1037 - 1056,
discoverPromoCodes() is doing post-filtering for global exhaustion and
per-account remaining quantity by iterating results from
getDiscoverableByEmailForSummit() and calling
getTicketCountByMemberAndPromoCode() for each IDomainAuthorizedPromoCode; move
those predicates into the repository to avoid O(N) follow-ups. Update
getDiscoverableByEmailForSummit() to (a) exclude globally exhausted codes (apply
hasQuantityAvailable semantics in the query), and (b) for domain-authorized
promo codes compute remaining per-account quantity in the same query (via a
join/subquery that counts tickets per member) and return that value (or include
it in the returned entity/DTO) so discoverPromoCodes() no longer needs to call
getTicketCountByMemberAndPromoCode() or call setRemainingQuantityPerAccount()
itself; keep discoverPromoCodes() logic but remove the post-filtering loop that
checks hasQuantityAvailable() and quantityPerAccount usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@database/migrations/model/Version20260401150000.php`:
- Around line 94-97: The rollback widens restricted inventory and codes by
setting SummitTicketType.Audience = 'All' and altering enums for promo/discount
code tables; instead, modify the down() migration to first detect any rows that
would lose restrictions (e.g., SummitTicketType rows where
Audience='WithPromoCode' and promo/discount code rows with domain or invitation
constraints), and if any exist, abort the down() with an explicit
exception/error indicating manual cleanup required, or alternatively move those
rows to a designated inactive table/flag for manual migration; update the down()
implementation that touches SummitTicketType and the promo/discount code enum
revert logic to perform this pre-check and fail-fast or relocate restricted rows
rather than broadening access.

---

Nitpick comments:
In `@app/Services/Model/Imp/SummitPromoCodeService.php`:
- Around line 1037-1056: discoverPromoCodes() is doing post-filtering for global
exhaustion and per-account remaining quantity by iterating results from
getDiscoverableByEmailForSummit() and calling
getTicketCountByMemberAndPromoCode() for each IDomainAuthorizedPromoCode; move
those predicates into the repository to avoid O(N) follow-ups. Update
getDiscoverableByEmailForSummit() to (a) exclude globally exhausted codes (apply
hasQuantityAvailable semantics in the query), and (b) for domain-authorized
promo codes compute remaining per-account quantity in the same query (via a
join/subquery that counts tickets per member) and return that value (or include
it in the returned entity/DTO) so discoverPromoCodes() no longer needs to call
getTicketCountByMemberAndPromoCode() or call setRemainingQuantityPerAccount()
itself; keep discoverPromoCodes() logic but remove the post-filtering loop that
checks hasQuantityAvailable() and quantityPerAccount usage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3c17ca05-176f-485e-9fee-749764e0c825

📥 Commits

Reviewing files that changed from the base of the PR and between ae261a7 and c3f8df7.

📒 Files selected for processing (2)
  • app/Services/Model/Imp/SummitPromoCodeService.php
  • database/migrations/model/Version20260401150000.php

@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.


$codes = $this->promo_code_service->discoverPromoCodes($summit, $current_member);

$expand = Request::input('expand', '');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker this not follow api patterns please check other controllers to see how we are doing it

SerializerUtils::getExpand(),
SerializerUtils::getFields(),
SerializerUtils::getRelations()

return Saga::start()
->addTask(new PreOrderValidationTask($summit, $payload, $this->ticket_type_repository, $this->tx_service))
->addTask(new PreProcessReservationTask($summit, $payload))
->addTask(new PreProcessReservationTask($summit, $payload, $owner, $this->promo_code_repository))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker this need to be reviewed
what is the rationale of this change ?
seems to me a breaking change without a clear regression test
Before this PR — saga order was safe

PreOrderValidationTask                                                                                                                                                                            
  → PreProcessReservationTask (validates ticket types, aggregates promo code usage)                                                                                                            
        → ReserveTicketsTask (decrements ticket type inventory with locks)                                                                                                                  
             → ApplyPromoCodeTask (validates checkSubject, canBeAppliedTo, adds usage)                                                                                                             
                 → ReserveOrderTask (creates Order + Tickets in DB, applies discount via applyTo)  

If ApplyPromoCodeTask threw (e.g. checkSubject rejects the user's email domain), the saga compensation ran:

  1. ApplyPromoCodeTask::undo() ( nothing to undo ,no usage was added)
  2. ReserveTicketsTask::undo() (restores ticket type quantities)
  3. PreProcessReservationTask::undo() (no-op)
  4. PreOrderValidationTask::undo() (no-op)

No order existed yet. ReserveOrderTask never ran, so its empty undo() was irrelevant. Clean failure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@smarcet you're right that the rationale wasn't clear and there was no regression test — apologies. Pushed c4bcdef which addresses all three of your comments.

Saga reorder rationale (deviation D4 in the PR description): the pre-existing order had a TOCTOU gap on QuantityPerAccount. DoctrineSummitRegistrationPromoCodeRepository::getTicketCountByMemberAndPromoCode filters on o.Status IN ('Reserved', 'Paid', 'Confirmed'), so running ApplyPromoCodeTask before ReserveOrderTask meant the count query couldn't see the current checkout's in-flight rows. Two concurrent checkouts could each pass the per-account check and then both add $qty tickets. Running ApplyPromoCodeTask after the Reserved order is persisted — and inside the existing promo-code usage lock — makes the count atomic with respect to concurrent orders.

The reorder exposed two real bugs that I also fixed in this commit:

  1. ReserveOrderTask::undo() was a TODO stub, so any downstream failure (invalid code / domain reject / canBeAppliedTo / QuantityPerAccount) was leaving orphaned Order+Ticket rows. Now implemented: detaches tickets from attendees, Summit::removeOrder, then ISummitOrderRepository::delete (Doctrine cascade: ['remove'] + orphanRemoval: true on SummitOrder::$tickets drops the ticket rows).
  2. Event::dispatch(new CreatedSummitRegistrationOrder(...)) was firing inside ReserveOrderTask::run(), so listeners would observe orders that later got rolled back. Moved to SummitOrderService::reserve() after the full saga succeeds. No listener exists today, but the contract is now correct.

Regression tests in tests/Unit/Services/SagaCompensationTest.php: Saga::abort() invokes undo() in reverse order, and ReserveOrderTask::undo() removes the order and detaches tickets.

Other two comments addressed in the same commit:

  • discover() now uses SerializerUtils::getExpand/getFields/getRelations.
  • Added database/migrations/config/Version20260412000000.php following the PR feat: add new endpoint to retrieve member by external id #526 template. FYI it uses the APIEndpointsMigrationHelper trait from main — will rebase onto main before your re-review so CI picks it up.

One unrelated bug I noticed while fixing the seeder: the get-sponsorship entry had IGroup::Sponsors in its scopes array instead of authz_groups. Fixed in the same commit — happy to split into a separate PR if you'd prefer.

Copy link
Copy Markdown
Collaborator

@smarcet smarcet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseylocker please review

SummitScopes::ReadAllSummitData
]
],
[
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

…r serializer, endpoint migration

- Implement ReserveOrderTask::undo() so ApplyPromoCodeTask failures (invalid
  code / canBeAppliedTo / domain reject / QuantityPerAccount) no longer leave
  orphaned Order+Ticket rows. Relies on SummitOrder::\$tickets cascade=remove +
  orphanRemoval=true to drop ticket rows.
- Defer CreatedSummitRegistrationOrder event dispatch from ReserveOrderTask::run
  to SummitOrderService::reserve, so listeners only observe fully-validated
  reservations.
- Use SerializerUtils::getExpand/getFields/getRelations in discover() to match
  the rest of the controller's API pattern.
- Seed discover-promo-codes endpoint via config migration
  Version20260412000000.php so deployed environments get the endpoint row
  without re-running the seeder.
- Fix ApiEndpointsSeeder: IGroup::Sponsors on get-sponsorship was in the scopes
  array; moved to authz_groups.
- Add tests/Unit/Services/SagaCompensationTest covering undo() no-op when no
  order persisted, undo() removes order+detaches tickets, and Saga::abort
  invokes undo in reverse order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

caseylocker and others added 2 commits April 12, 2026 21:42
When SagaCompensationTest runs after tests that bound the real Log facade,
Facade::$resolvedInstance still caches the full LogManager. Clear it in
setUp so the minimal container bound afterwards is honored.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php`:
- Line 1591: The OpenAPI security annotation for the discover endpoint is
missing the ReadAllSummitData scope; update the security array used in the
discover endpoint's annotation (the line with security:
[['summit_promo_codes_oauth2' => [SummitScopes::ReadSummitData]]]) to include
both SummitScopes::ReadSummitData and SummitScopes::ReadAllSummitData so the
documented scopes match the endpoint's actual authorization configuration for
the discover handler.

In `@app/Services/Model/Imp/SummitOrderService.php`:
- Around line 666-667: The current return of a fresh array with only 'order'
drops previously accumulated saga state (e.g., promo_codes_usage) and breaks
subsequent steps like ApplyPromoCodeTask; update the handler that sets
$this->formerState['order'] to preserve and return the full saga state by
merging or returning $this->formerState (i.e., ensure the method returns the
previously accumulated $this->formerState with the updated 'order' key instead
of a new ['order' => $order] array) so ApplyPromoCodeTask can access
promo_codes_usage and other prior state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 60e94142-914f-48aa-9cce-0a468efca022

📥 Commits

Reviewing files that changed from the base of the PR and between c3f8df7 and d824974.

📒 Files selected for processing (7)
  • app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitPromoCodesApiController.php
  • app/Services/Model/Imp/SummitOrderService.php
  • database/migrations/config/Version20260412000000.php
  • database/seeders/ApiEndpointsSeeder.php
  • doc/promo-codes-for-early-registration-access.md
  • routes/api_v1.php
  • tests/Unit/Services/SagaCompensationTest.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • routes/api_v1.php

- Critical: ReserveOrderTask::run now returns the accumulated formerState
  instead of a fresh ['order' => ...] array. After the reorder, ApplyPromoCodeTask
  runs downstream and reads promo_codes_usage / reservations / ticket_types_ids
  that earlier tasks populated; dropping state broke promo redemption and
  per-account enforcement for every promo-code checkout.
- Minor: discover() OpenAPI security annotation now declares both
  ReadSummitData and ReadAllSummitData to match the seeded scopes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/summit-api/openapi/pr-525/

This page is automatically updated on each push to this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants