Skip to content

feat: map CHECKOUT.PAYMENT-APPROVAL.REVERSED resource by event type - #111

Open
Ali Ghanei (Aliaaaam) wants to merge 1 commit into
trunkfrom
feat/payment-approval-reversed-resource
Open

feat: map CHECKOUT.PAYMENT-APPROVAL.REVERSED resource by event type#111
Ali Ghanei (Aliaaaam) wants to merge 1 commit into
trunkfrom
feat/payment-approval-reversed-resource

Conversation

@Aliaaaam

Copy link
Copy Markdown
Contributor

Summary

  • Add PaymentApprovalReversed webhook resource (order_id + purchase units).
  • Map it in Event::assign() by event type, since PayPal often omits resource_type and uses order_id instead of id.

Test plan

  • vendor/bin/phpunit tests/unit/Struct/V1/Webhook/EventTest.php --filter PaymentApprovalReversed

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.
@Aliaaaam
Ali Ghanei (Aliaaaam) deleted the feat/payment-approval-reversed-resource branch July 21, 2026 11:30
@Aliaaaam Ali Ghanei (Aliaaaam) self-assigned this Jul 21, 2026
@Aliaaaam
Ali Ghanei (Aliaaaam) restored the feat/payment-approval-reversed-resource branch July 21, 2026 12:06
@Aliaaaam
Ali Ghanei (Aliaaaam) requested a review from a team July 21, 2026 12:10
Comment on lines +91 to +94
// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are you sure about this omission? Because it's a required field by PayPal and I can't imagine that being that broken.

@Aliaaaam Ali Ghanei (Aliaaaam) Jul 21, 2026

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.

Yes, at least that seems to be the case based on their documentation here:
Screenshot 2026-07-21 at 21 26 22

https://developer.paypal.com/docs/checkout/apm/reference/handle-uncaptured-payments/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

event_type prefix | Resource body | Typical resource_type -- | -- | -- PAYMENT.CAPTURE.* | Payments v2 Capture | capture PAYMENT.AUTHORIZATION.* | Payments v2 Authorization | authorization PAYMENT.CAPTURE.REFUNDED / refund events | Payments v2 Refund | refund CHECKOUT.ORDER.* | Orders v2 Order | checkout-order CUSTOMER.DISPUTE.* | Dispute | dispute VAULT.PAYMENT-TOKEN.* | Payment Token v3 | payment_token BILLING.SUBSCRIPTION.* | Subscription | subscription MERCHANT.ONBOARDING.* / partner events | Managed accounts / account entities | managed-accounts, account-entities CHECKOUT.PAYMENT-APPROVAL.REVERSED | Event-specific slim body (order_id, trimmed purchase_units, payment_source) | (absent)

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 branch
if schema is versioned:
schema = schema[resource_version ?? latest] // generation pick
if resource_type present and resource_type != schema.expected_resource_type:
log + reject // consistency check, don't guess
deserialize(resource, schema) // slim/custom bodies get their own DTO

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I asked AI how to identify the resource bodies. Maybe we can implement this without breaking changes? 🙈

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