Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ message DispatchPaymentResponse {

// created_at is when this dispatch record was created.
google.protobuf.Timestamp created_at = 6;

// platform_fee_minor_units is the platform fee charged on this payment in the smallest currency unit.
// Zero if no platform fee was applied.
int64 platform_fee_minor_units = 7 [(buf.validate.field).int64.gte = 0];
}

// DispatchRefundRequest submits a financial refund for dispatch via a payment rail.
Expand Down
77 changes: 77 additions & 0 deletions api/proto/meridian/financial_gateway_events/v1/events.proto
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,80 @@ message PaymentFailedEvent {
// failed_at is when the payment failure occurred.
google.protobuf.Timestamp failed_at = 10;
}

// PaymentRefundedEvent is published when a Stripe charge.refunded webhook
// is received and validated. It signals that a payment has been partially or fully refunded.
message PaymentRefundedEvent {
// event_id uniquely identifies this event instance (UUID).
string event_id = 1 [(buf.validate.field).string.uuid = true];

// correlation_id links all events across services for a single user request.
string correlation_id = 2 [(buf.validate.field).string.max_len = 255];

// causation_id identifies the provider event that caused this domain event.
string causation_id = 3 [(buf.validate.field).string.max_len = 255];

// version is the event schema version for forward compatibility.
int32 version = 4 [(buf.validate.field).int32.gte = 1];

// payment_order_id is the Meridian payment order associated with this refund.
string payment_order_id = 5 [(buf.validate.field).string.max_len = 255];

// provider_reference_id is the payment provider's identifier (e.g., Stripe Charge ID).
string provider_reference_id = 6 [(buf.validate.field).string = {
min_len: 1
max_len: 255
}];
Comment thread
bjcoombs marked this conversation as resolved.

// amount_refunded_minor_units is the refunded amount in the smallest currency unit (e.g., cents for USD).
int64 amount_refunded_minor_units = 7 [(buf.validate.field).int64.gte = 0];

// currency is the ISO 4217 currency code (e.g., "USD", "GBP").
string currency = 8 [(buf.validate.field).string.max_len = 3];

// provider_event_id is the payment provider's webhook event ID (e.g., Stripe evt_ ID).
string provider_event_id = 9 [(buf.validate.field).string = {
min_len: 1
max_len: 255
}];

// refunded_at is when the refund was processed by the provider.
google.protobuf.Timestamp refunded_at = 10;
}

// PaymentDisputedEvent is published when a Stripe charge.dispute.created webhook
// is received and validated. It signals that a payment has been disputed by the cardholder.
message PaymentDisputedEvent {
// event_id uniquely identifies this event instance (UUID).
string event_id = 1 [(buf.validate.field).string.uuid = true];

// correlation_id links all events across services for a single user request.
string correlation_id = 2 [(buf.validate.field).string.max_len = 255];

// causation_id identifies the provider event that caused this domain event.
string causation_id = 3 [(buf.validate.field).string.max_len = 255];

// version is the event schema version for forward compatibility.
int32 version = 4 [(buf.validate.field).int32.gte = 1];

// payment_order_id is the Meridian payment order associated with this dispute.
string payment_order_id = 5 [(buf.validate.field).string.max_len = 255];

// provider_reference_id is the payment provider's identifier (e.g., Stripe Charge ID).
string provider_reference_id = 6 [(buf.validate.field).string = {
min_len: 1
max_len: 255
}];

// dispute_reason is the provider's reason for the dispute (e.g., "fraudulent", "product_not_received").
string dispute_reason = 7 [(buf.validate.field).string.max_len = 255];

// provider_event_id is the payment provider's webhook event ID (e.g., Stripe evt_ ID).
string provider_event_id = 8 [(buf.validate.field).string = {
min_len: 1
max_len: 255
}];

// disputed_at is when the dispute was created by the provider.
google.protobuf.Timestamp disputed_at = 9;
}
38 changes: 35 additions & 3 deletions services/financial-gateway/adapters/http/webhook_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,38 @@ func (h *WebhookHandler) mapToDomainEvent(parsed stripeadapter.ParsedWebhookEven
}
return evt, topics.FinancialGatewayPaymentFailedV1, nil

case "REFUNDED":
evt := &financialgatewayeventsv1.PaymentRefundedEvent{
EventId: uuid.New().String(),
Version: 1,
PaymentOrderId: parsed.PaymentOrderID,
ProviderReferenceId: parsed.GatewayReferenceID,
ProviderEventId: parsed.EventID,
CausationId: parsed.EventID,
AmountRefundedMinorUnits: parsed.AmountMinorUnits,
Currency: parsed.Currency,
}
if !parsed.Timestamp.IsZero() {
evt.RefundedAt = timestamppb.New(parsed.Timestamp)
}
return evt, topics.FinancialGatewayPaymentRefundedV1, nil

case "DISPUTED":
evt := &financialgatewayeventsv1.PaymentDisputedEvent{
EventId: uuid.New().String(),
Version: 1,
PaymentOrderId: parsed.PaymentOrderID,
ProviderReferenceId: parsed.GatewayReferenceID,
ProviderEventId: parsed.EventID,
CausationId: parsed.EventID,
DisputeReason: parsed.Message,
}
if !parsed.Timestamp.IsZero() {
evt.DisputedAt = timestamppb.New(parsed.Timestamp)
}
return evt, topics.FinancialGatewayPaymentDisputedV1, nil

default:
// REFUNDED and DISPUTED events are acknowledged but not mapped to
// PaymentCaptured/PaymentFailed events — they have their own topics
// but are out of scope for this task.
h.logger.Debug("stripe event acknowledged without domain event mapping",
"status", parsed.Status,
"event_id", parsed.EventID,
Expand All @@ -304,6 +332,10 @@ func topicToEventType(topic string) string {
return "financial_gateway.payment_captured.v1"
case topics.FinancialGatewayPaymentFailedV1:
return "financial_gateway.payment_failed.v1"
case topics.FinancialGatewayPaymentRefundedV1:
return "financial_gateway.payment_refunded.v1"
case topics.FinancialGatewayPaymentDisputedV1:
return "financial_gateway.payment_disputed.v1"
default:
return topic
}
Expand Down
104 changes: 104 additions & 0 deletions services/financial-gateway/adapters/http/webhook_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,110 @@ func TestWebhookHandler_OutboxPublishFails_Returns500(t *testing.T) {
assert.Equal(t, http.StatusInternalServerError, rr.Code)
}

func TestWebhookHandler_PaymentRefunded_PublishesToOutbox(t *testing.T) {
stub := &stubOutboxPublisher{}
h := setupHandler(t, stub)

payload := buildStripePayload(t, "evt_ref_1", "charge.refunded", map[string]any{
"id": "ch_test_ref_789",
"object": "charge",
"amount_refunded": 3000,
"currency": "usd",
"metadata": map[string]string{},
"payment_intent": map[string]any{
"id": "pi_original_789",
"metadata": map[string]string{"payment_order_id": "po-ref-789"},
},
})
sig := signPayload(t, payload, testWebhookSecret)

req := httptest.NewRequest(http.MethodPost, "/webhooks/stripe/test-tenant", bytes.NewReader(payload))
req.SetPathValue("tenantID", "test-tenant")
req.Header.Set("Stripe-Signature", sig)

rr := httptest.NewRecorder()
h.HandleStripeWebhook(rr, req)

assert.Equal(t, http.StatusOK, rr.Code)

require.Len(t, stub.published, 1)
assert.Equal(t, "financial-gateway.payment-refunded.v1", stub.published[0].topic)
assert.Equal(t, "po-ref-789", stub.published[0].aggID)

evt, ok := stub.published[0].event.(*financialgatewayeventsv1.PaymentRefundedEvent)
require.True(t, ok, "expected *PaymentRefundedEvent, got %T", stub.published[0].event)
assert.Equal(t, "ch_test_ref_789", evt.GetProviderReferenceId())
assert.Equal(t, "po-ref-789", evt.GetPaymentOrderId())
assert.Equal(t, int64(3000), evt.GetAmountRefundedMinorUnits())
assert.Equal(t, "usd", evt.GetCurrency())
assert.Equal(t, "evt_ref_1", evt.GetProviderEventId())
assert.NotEmpty(t, evt.GetEventId())
assert.Equal(t, int32(1), evt.GetVersion())
}

func TestWebhookHandler_PaymentDisputed_PublishesToOutbox(t *testing.T) {
stub := &stubOutboxPublisher{}
h := setupHandler(t, stub)

payload := buildStripePayload(t, "evt_disp_1", "charge.dispute.created", map[string]any{
"id": "dp_test_disp_101",
"object": "dispute",
"reason": "fraudulent",
"status": "needs_response",
"charge": map[string]any{
"id": "ch_disputed_101",
"metadata": map[string]string{"payment_order_id": "po-disp-101"},
},
})
sig := signPayload(t, payload, testWebhookSecret)

req := httptest.NewRequest(http.MethodPost, "/webhooks/stripe/test-tenant", bytes.NewReader(payload))
req.SetPathValue("tenantID", "test-tenant")
req.Header.Set("Stripe-Signature", sig)

rr := httptest.NewRecorder()
h.HandleStripeWebhook(rr, req)

assert.Equal(t, http.StatusOK, rr.Code)

require.Len(t, stub.published, 1)
assert.Equal(t, "financial-gateway.payment-disputed.v1", stub.published[0].topic)
assert.Equal(t, "po-disp-101", stub.published[0].aggID)

evt, ok := stub.published[0].event.(*financialgatewayeventsv1.PaymentDisputedEvent)
require.True(t, ok, "expected *PaymentDisputedEvent, got %T", stub.published[0].event)
assert.Equal(t, "ch_disputed_101", evt.GetProviderReferenceId())
assert.Equal(t, "po-disp-101", evt.GetPaymentOrderId())
assert.Equal(t, "dispute reason: fraudulent", evt.GetDisputeReason())
assert.Equal(t, "evt_disp_1", evt.GetProviderEventId())
assert.NotEmpty(t, evt.GetEventId())
assert.Equal(t, int32(1), evt.GetVersion())
}

func TestWebhookHandler_RefundedWithoutPaymentOrderID_AcknowledgesOnly(t *testing.T) {
stub := &stubOutboxPublisher{}
h := setupHandler(t, stub)

payload := buildStripePayload(t, "evt_ref_noid", "charge.refunded", map[string]any{
"id": "ch_no_po_id",
"object": "charge",
"amount_refunded": 1000,
"currency": "gbp",
"metadata": map[string]string{},
})
sig := signPayload(t, payload, testWebhookSecret)

req := httptest.NewRequest(http.MethodPost, "/webhooks/stripe/test-tenant", bytes.NewReader(payload))
req.SetPathValue("tenantID", "test-tenant")
req.Header.Set("Stripe-Signature", sig)

rr := httptest.NewRecorder()
h.HandleStripeWebhook(rr, req)

assert.Equal(t, http.StatusOK, rr.Code)
assert.Len(t, stub.published, 0, "should not publish event without payment_order_id")
}

func TestWebhookHandler_TenantNotFound_Returns500(t *testing.T) {
stub := &stubOutboxPublisher{}
provider := &testTenantConfigProvider{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import (

// Sentinel errors for the Stripe payment intent adapter.
var (
ErrMissingStripeAccount = errors.New("stripe connected account ID not found in context")
ErrInvalidRequest = errors.New("invalid stripe request")
ErrNilCreator = errors.New("payment intent creator must not be nil")
ErrMissingStripeAccount = errors.New("stripe connected account ID not found in context")
ErrInvalidRequest = errors.New("invalid stripe request")
ErrNilCreator = errors.New("payment intent creator must not be nil")
ErrPaymentIntentNotFound = errors.New("payment intent not found for payment order")
ErrCancelNotConfigured = errors.New("cancel support not configured on adapter")
)

// Prometheus metrics for Stripe gateway operations.
Expand Down Expand Up @@ -59,11 +61,28 @@ type PaymentIntentCreator interface {
Create(ctx context.Context, params *stripego.PaymentIntentCreateParams) (*stripego.PaymentIntent, error)
}

// PaymentIntentCanceller abstracts Stripe PaymentIntent cancellation for testability.
type PaymentIntentCanceller interface {
Cancel(ctx context.Context, id string, params *stripego.PaymentIntentCancelParams) (*stripego.PaymentIntent, error)
}

// PaymentIntentResolver abstracts finding a Stripe PaymentIntent ID by payment order metadata.
type PaymentIntentResolver interface {
// FindByPaymentOrderID returns the Stripe PaymentIntent ID for the given payment_order_id.
FindByPaymentOrderID(ctx context.Context, paymentOrderID string) (string, error)
}

// PaymentIntentAdapterConfig holds configuration for the Stripe payment intent adapter.
type PaymentIntentAdapterConfig struct {
// PlatformFee configures the platform fee calculation.
// If nil or zero, no platform fee is applied.
PlatformFee *PlatformFeeConfig

// Canceller handles Stripe PaymentIntent cancellation. Optional; if nil, CancelPayment returns ErrCancelNotConfigured.
Canceller PaymentIntentCanceller

// Resolver finds Stripe PaymentIntent IDs by metadata. Optional; required for CancelPayment.
Resolver PaymentIntentResolver
}

// stripeAccountKey is the context key for the Stripe Connected Account ID.
Expand Down Expand Up @@ -271,6 +290,80 @@ func (a *PaymentIntentAdapter) handleError(err error, currency string, duration
return DispatchResult{}, fmt.Errorf("stripe error (%s): %w", stripeErr.Type, err)
}

// CancelResult captures the outcome of a Stripe payment cancellation.
type CancelResult struct {
// ProviderReference is the Stripe PaymentIntent ID.
ProviderReference string
// Status is the mapped dispatch status after cancellation.
Status financialgatewayv1.DispatchStatus
}

// CancelPayment finds and cancels the Stripe PaymentIntent associated with the given payment order.
// If the PaymentIntent is already cancelled, it succeeds idempotently.
func (a *PaymentIntentAdapter) CancelPayment(ctx context.Context, paymentOrderID, reason string) (CancelResult, error) {
if a.config.Canceller == nil || a.config.Resolver == nil {
return CancelResult{}, ErrCancelNotConfigured
}

accountID, ok := AccountFromContext(ctx)
if !ok {
return CancelResult{}, ErrMissingStripeAccount
}

piID, err := a.config.Resolver.FindByPaymentOrderID(ctx, paymentOrderID)
if err != nil {
return CancelResult{}, fmt.Errorf("failed to find payment intent for order %s: %w", paymentOrderID, err)
}

params := &stripego.PaymentIntentCancelParams{}
if reason != "" {
params.CancellationReason = stripego.String("requested_by_customer")
}
params.SetStripeAccount(accountID)

a.logger.Debug("cancelling stripe payment intent",
"payment_order_id", paymentOrderID,
"payment_intent_id", piID,
"connected_account", accountID,
)

pi, err := a.config.Canceller.Cancel(ctx, piID, params)
if err != nil {
var stripeErr *stripego.Error
if errors.As(err, &stripeErr) && stripeErr.Code == stripego.ErrorCodePaymentIntentUnexpectedState {
// Stripe returns payment_intent_unexpected_state when the PI
// is in a non-cancellable state. Check if it's already canceled
// (idempotent success) vs a truly non-cancellable state (e.g., succeeded).
if strings.Contains(stripeErr.Msg, "status of canceled") {
a.logger.Info("stripe payment intent already cancelled",
"payment_order_id", paymentOrderID,
"payment_intent_id", piID,
)
return CancelResult{
ProviderReference: piID,
Status: financialgatewayv1.DispatchStatus_DISPATCH_STATUS_FAILED,
}, nil
}
Comment thread
bjcoombs marked this conversation as resolved.
// Non-cancellable state (e.g., succeeded) — return as invalid request
return CancelResult{}, fmt.Errorf("payment intent %s cannot be cancelled: %w", piID, ErrInvalidRequest)
}
return CancelResult{}, fmt.Errorf("stripe cancel failed: %w", err)
}

status := mapPaymentIntentStatus(pi.Status)

a.logger.Info("stripe payment intent cancelled",
"payment_order_id", paymentOrderID,
"payment_intent_id", pi.ID,
"status", string(pi.Status),
)

return CancelResult{
ProviderReference: pi.ID,
Status: status,
}, nil
}

// mapPaymentIntentStatus maps a Stripe PaymentIntent status to a gateway DispatchStatus.
func mapPaymentIntentStatus(status stripego.PaymentIntentStatus) financialgatewayv1.DispatchStatus {
switch status {
Expand Down
Loading
Loading