feat: map CHECKOUT.PAYMENT-APPROVAL.REVERSED resource by event type - #111
feat: map CHECKOUT.PAYMENT-APPROVAL.REVERSED resource by event type#111Ali Ghanei (Aliaaaam) wants to merge 1 commit into
Conversation
PayPal sends order_id (not id) and often omits resource_type on this event, so Event::assign maps it to PaymentApprovalReversed instead of the generic Resource fallback.
| // PAYMENT-APPROVAL.REVERSED uses order_id (not id) and often omits resource_type. | ||
| $resourceClass = $this->eventType === WebhookEventTypes::CHECKOUT_PAYMENT_APPROVAL_REVERSED | ||
| ? PaymentApprovalReversed::class | ||
| : $this->identifyResourceType($this->resourceVersion, $this->resourceType); |
There was a problem hiding this comment.
Are you sure about this omission? Because it's a required field by PayPal and I can't imagine that being that broken.
There was a problem hiding this comment.
Yes, at least that seems to be the case based on their documentation here:

https://developer.paypal.com/docs/checkout/apm/reference/handle-uncaptured-payments/
There was a problem hiding this comment.
Alright, understood. Since it may not be the only Webhook event with this other structure (or a missing resource_type, then maybe build a similar match structure as the identifyResourceType() to try to match based on event_type it if no resource_type could be found.
There was a problem hiding this comment.
Recommended discrimination hierarchy
1. event_type is the primary key — always. It's the only field PayPal guarantees on every event, and it's the contract you subscribed to. Each event type has exactly one resource body shape, so a mapping event_type → schema is total and unambiguous. The namespace prefix gives you the family:
2. resource_version disambiguates schema generation, not type. A few event types were emitted before v2 APIs existed, so the same event_type can carry a v1 or v2 body (notoriously, CHECKOUT.ORDER.APPROVED exists with resource_version 1.0 and 2.0 depending on integration vintage). So the full lookup key is event_type first, then resource_version to pick the schema revision. Default missing → treat as unversioned/event-specific, not as "1.0".
3. resource_type is a consistency check, never the primary discriminator. It's optional (as you found), it's not versioned, and its vocabulary is coarser than event_type. Use it to validate: if present and it contradicts what event_type predicts, log and reject rather than guess.
4. Structural fallback for the long tail. If the event type is unknown to you, don't deserialize into a typed struct at all — keep the raw array, log event_type + resource_type, and ack. Sniffing fields ("order_id" vs "id"+"status") is fragile; only do it for observability, not dispatch.
Why this ordering, concretely
The (resource_version, resource_type) approach the SDK uses inverts the reliability order: it keys on the two optional fields and ignores the mandatory one. For CHECKOUT.PAYMENT-APPROVAL.REVERSED, both are absent, so the SDK defaults kick in (resourceVersion = '1.0', resourceType = '') and the match at Event.php:216 falls through to the generic Resource struct — which happens to be survivable, but it means the slim body's order_id/purchase_units are only reachable untyped, and any event PayPal ships tomorrow with resource_version: "2.0" but a novel resource_type deserializes to null resource instead of a generic one. An event_type-first map has neither failure mode: unknown event → explicit "unknown" branch; known event → exact schema, with resource_type (when present) as a sanity assertion.
In pseudocode:
schema = KNOWN_EVENTS[event_type] // primary, total for your subscriptions ?? UNKNOWN // explicit long-tail branchif schema is versioned: schema = schema[resource_version ?? latest] // generation pickif resource_type present and resource_type != schema.expected_resource_type: log + reject // consistency check, don't guessdeserialize(resource, schema) // slim/custom bodies get their own DTOThe one structural rule worth internalizing: resource_type names a REST API object; events whose resource isn't a REST object (like the approval-reversal projection) legitimately have nothing to put there. So any dispatch design that assumes its presence is wrong by construction — the field is descriptive metadata, not a discriminator.
There was a problem hiding this comment.
I asked AI how to identify the resource bodies. Maybe we can implement this without breaking changes? 🙈
Summary
PaymentApprovalReversedwebhook resource (order_id+ purchase units).Event::assign()by event type, since PayPal often omitsresource_typeand usesorder_idinstead ofid.Test plan
vendor/bin/phpunit tests/unit/Struct/V1/Webhook/EventTest.php --filter PaymentApprovalReversed