diff --git a/api/proto/meridian/financial_gateway/v1/financial_gateway.proto b/api/proto/meridian/financial_gateway/v1/financial_gateway.proto index 572f55e5d..6e2746229 100644 --- a/api/proto/meridian/financial_gateway/v1/financial_gateway.proto +++ b/api/proto/meridian/financial_gateway/v1/financial_gateway.proto @@ -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. diff --git a/api/proto/meridian/financial_gateway_events/v1/events.proto b/api/proto/meridian/financial_gateway_events/v1/events.proto index 21c429971..2dfe85efb 100644 --- a/api/proto/meridian/financial_gateway_events/v1/events.proto +++ b/api/proto/meridian/financial_gateway_events/v1/events.proto @@ -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 + }]; + + // 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; +} diff --git a/services/financial-gateway/adapters/http/webhook_handler.go b/services/financial-gateway/adapters/http/webhook_handler.go index 2241af7ea..c5a9f8bef 100644 --- a/services/financial-gateway/adapters/http/webhook_handler.go +++ b/services/financial-gateway/adapters/http/webhook_handler.go @@ -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, @@ -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 } diff --git a/services/financial-gateway/adapters/http/webhook_handler_test.go b/services/financial-gateway/adapters/http/webhook_handler_test.go index e8f0ad85f..2e9e8c22f 100644 --- a/services/financial-gateway/adapters/http/webhook_handler_test.go +++ b/services/financial-gateway/adapters/http/webhook_handler_test.go @@ -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{ diff --git a/services/financial-gateway/adapters/stripe/payment_intent_adapter.go b/services/financial-gateway/adapters/stripe/payment_intent_adapter.go index 7649904f5..9a0b1d91a 100644 --- a/services/financial-gateway/adapters/stripe/payment_intent_adapter.go +++ b/services/financial-gateway/adapters/stripe/payment_intent_adapter.go @@ -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. @@ -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. @@ -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 + } + // 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 { diff --git a/services/financial-gateway/adapters/stripe/payment_intent_adapter_test.go b/services/financial-gateway/adapters/stripe/payment_intent_adapter_test.go index f68a0e575..34ff6307c 100644 --- a/services/financial-gateway/adapters/stripe/payment_intent_adapter_test.go +++ b/services/financial-gateway/adapters/stripe/payment_intent_adapter_test.go @@ -359,3 +359,246 @@ func TestNewPaymentIntentAdapter_NilCreator(t *testing.T) { require.Error(t, err) assert.True(t, errors.Is(err, ErrNilCreator)) } + +// --- CancelPayment Tests --- + +type mockPaymentIntentCanceller struct { + cancelFn func(ctx context.Context, id string, params *stripego.PaymentIntentCancelParams) (*stripego.PaymentIntent, error) +} + +func (m *mockPaymentIntentCanceller) Cancel(ctx context.Context, id string, params *stripego.PaymentIntentCancelParams) (*stripego.PaymentIntent, error) { + return m.cancelFn(ctx, id, params) +} + +type mockPaymentIntentResolver struct { + findFn func(ctx context.Context, paymentOrderID string) (string, error) +} + +func (m *mockPaymentIntentResolver) FindByPaymentOrderID(ctx context.Context, paymentOrderID string) (string, error) { + return m.findFn(ctx, paymentOrderID) +} + +func TestPaymentIntentAdapter_CancelPayment_Success(t *testing.T) { + creator := &mockPaymentIntentCreator{ + createFn: func(_ context.Context, _ *stripego.PaymentIntentCreateParams) (*stripego.PaymentIntent, error) { + return nil, nil + }, + } + canceller := &mockPaymentIntentCanceller{ + cancelFn: func(_ context.Context, id string, params *stripego.PaymentIntentCancelParams) (*stripego.PaymentIntent, error) { + assert.Equal(t, "pi_to_cancel", id) + assert.NotNil(t, params.CancellationReason) + assert.Equal(t, "requested_by_customer", *params.CancellationReason) + + require.NotNil(t, params.StripeAccount) + assert.Equal(t, "acct_tenant_a", *params.StripeAccount) + + return &stripego.PaymentIntent{ + ID: "pi_to_cancel", + Status: stripego.PaymentIntentStatusCanceled, + }, nil + }, + } + resolver := &mockPaymentIntentResolver{ + findFn: func(_ context.Context, paymentOrderID string) (string, error) { + assert.Equal(t, "po-cancel-123", paymentOrderID) + return "pi_to_cancel", nil + }, + } + + adapter, err := NewPaymentIntentAdapter(creator, PaymentIntentAdapterConfig{ + Canceller: canceller, + Resolver: resolver, + }, slog.Default()) + require.NoError(t, err) + + ctx := tenantContext("tenant_a") + ctx = WithStripeAccount(ctx, "acct_tenant_a") + result, err := adapter.CancelPayment(ctx, "po-cancel-123", "customer requested") + require.NoError(t, err) + assert.Equal(t, "pi_to_cancel", result.ProviderReference) + assert.Equal(t, financialgatewayv1.DispatchStatus_DISPATCH_STATUS_FAILED, result.Status) +} + +func TestPaymentIntentAdapter_CancelPayment_NotConfigured(t *testing.T) { + creator := &mockPaymentIntentCreator{ + createFn: func(_ context.Context, _ *stripego.PaymentIntentCreateParams) (*stripego.PaymentIntent, error) { + return nil, nil + }, + } + + adapter, err := NewPaymentIntentAdapter(creator, PaymentIntentAdapterConfig{}, slog.Default()) + require.NoError(t, err) + + ctx := tenantContext("tenant_a") + ctx = WithStripeAccount(ctx, "acct_tenant_a") + _, err = adapter.CancelPayment(ctx, "po-123", "reason") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrCancelNotConfigured)) +} + +func TestPaymentIntentAdapter_CancelPayment_MissingStripeAccount(t *testing.T) { + creator := &mockPaymentIntentCreator{ + createFn: func(_ context.Context, _ *stripego.PaymentIntentCreateParams) (*stripego.PaymentIntent, error) { + return nil, nil + }, + } + canceller := &mockPaymentIntentCanceller{ + cancelFn: func(_ context.Context, _ string, _ *stripego.PaymentIntentCancelParams) (*stripego.PaymentIntent, error) { + return nil, nil + }, + } + resolver := &mockPaymentIntentResolver{ + findFn: func(_ context.Context, _ string) (string, error) { + return "pi_x", nil + }, + } + + adapter, err := NewPaymentIntentAdapter(creator, PaymentIntentAdapterConfig{ + Canceller: canceller, + Resolver: resolver, + }, slog.Default()) + require.NoError(t, err) + + ctx := tenantContext("tenant_a") + // No stripe account in context + _, err = adapter.CancelPayment(ctx, "po-123", "reason") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrMissingStripeAccount)) +} + +func TestPaymentIntentAdapter_CancelPayment_ResolverNotFound(t *testing.T) { + creator := &mockPaymentIntentCreator{ + createFn: func(_ context.Context, _ *stripego.PaymentIntentCreateParams) (*stripego.PaymentIntent, error) { + return nil, nil + }, + } + canceller := &mockPaymentIntentCanceller{ + cancelFn: func(_ context.Context, _ string, _ *stripego.PaymentIntentCancelParams) (*stripego.PaymentIntent, error) { + t.Fatal("cancel should not be called when resolver fails") + return nil, nil + }, + } + resolver := &mockPaymentIntentResolver{ + findFn: func(_ context.Context, _ string) (string, error) { + return "", ErrPaymentIntentNotFound + }, + } + + adapter, err := NewPaymentIntentAdapter(creator, PaymentIntentAdapterConfig{ + Canceller: canceller, + Resolver: resolver, + }, slog.Default()) + require.NoError(t, err) + + ctx := tenantContext("tenant_a") + ctx = WithStripeAccount(ctx, "acct_tenant_a") + _, err = adapter.CancelPayment(ctx, "po-not-found", "reason") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrPaymentIntentNotFound)) +} + +func TestPaymentIntentAdapter_CancelPayment_AlreadyCancelled(t *testing.T) { + creator := &mockPaymentIntentCreator{ + createFn: func(_ context.Context, _ *stripego.PaymentIntentCreateParams) (*stripego.PaymentIntent, error) { + return nil, nil + }, + } + canceller := &mockPaymentIntentCanceller{ + cancelFn: func(_ context.Context, _ string, _ *stripego.PaymentIntentCancelParams) (*stripego.PaymentIntent, error) { + return nil, &stripego.Error{ + HTTPStatusCode: 400, + Type: stripego.ErrorTypeInvalidRequest, + Code: stripego.ErrorCodePaymentIntentUnexpectedState, + Msg: "You cannot cancel this PaymentIntent because it has a status of canceled.", + } + }, + } + resolver := &mockPaymentIntentResolver{ + findFn: func(_ context.Context, _ string) (string, error) { + return "pi_already_cancelled", nil + }, + } + + adapter, err := NewPaymentIntentAdapter(creator, PaymentIntentAdapterConfig{ + Canceller: canceller, + Resolver: resolver, + }, slog.Default()) + require.NoError(t, err) + + ctx := tenantContext("tenant_a") + ctx = WithStripeAccount(ctx, "acct_tenant_a") + result, err := adapter.CancelPayment(ctx, "po-already-cancelled", "reason") + require.NoError(t, err, "already-cancelled should succeed idempotently") + assert.Equal(t, "pi_already_cancelled", result.ProviderReference) + assert.Equal(t, financialgatewayv1.DispatchStatus_DISPATCH_STATUS_FAILED, result.Status) +} + +func TestPaymentIntentAdapter_CancelPayment_AlreadySucceeded(t *testing.T) { + creator := &mockPaymentIntentCreator{ + createFn: func(_ context.Context, _ *stripego.PaymentIntentCreateParams) (*stripego.PaymentIntent, error) { + return nil, nil + }, + } + canceller := &mockPaymentIntentCanceller{ + cancelFn: func(_ context.Context, _ string, _ *stripego.PaymentIntentCancelParams) (*stripego.PaymentIntent, error) { + return nil, &stripego.Error{ + HTTPStatusCode: 400, + Type: stripego.ErrorTypeInvalidRequest, + Code: stripego.ErrorCodePaymentIntentUnexpectedState, + Msg: "You cannot cancel this PaymentIntent because it has a status of succeeded.", + } + }, + } + resolver := &mockPaymentIntentResolver{ + findFn: func(_ context.Context, _ string) (string, error) { + return "pi_already_succeeded", nil + }, + } + + adapter, err := NewPaymentIntentAdapter(creator, PaymentIntentAdapterConfig{ + Canceller: canceller, + Resolver: resolver, + }, slog.Default()) + require.NoError(t, err) + + ctx := tenantContext("tenant_a") + ctx = WithStripeAccount(ctx, "acct_tenant_a") + _, err = adapter.CancelPayment(ctx, "po-already-succeeded", "reason") + require.Error(t, err, "already-succeeded should return error, not silent success") + assert.True(t, errors.Is(err, ErrInvalidRequest)) +} + +func TestPaymentIntentAdapter_CancelPayment_EmptyReason(t *testing.T) { + creator := &mockPaymentIntentCreator{ + createFn: func(_ context.Context, _ *stripego.PaymentIntentCreateParams) (*stripego.PaymentIntent, error) { + return nil, nil + }, + } + canceller := &mockPaymentIntentCanceller{ + cancelFn: func(_ context.Context, _ string, params *stripego.PaymentIntentCancelParams) (*stripego.PaymentIntent, error) { + assert.Nil(t, params.CancellationReason, "empty reason should not set CancellationReason") + return &stripego.PaymentIntent{ + ID: "pi_no_reason", + Status: stripego.PaymentIntentStatusCanceled, + }, nil + }, + } + resolver := &mockPaymentIntentResolver{ + findFn: func(_ context.Context, _ string) (string, error) { + return "pi_no_reason", nil + }, + } + + adapter, err := NewPaymentIntentAdapter(creator, PaymentIntentAdapterConfig{ + Canceller: canceller, + Resolver: resolver, + }, slog.Default()) + require.NoError(t, err) + + ctx := tenantContext("tenant_a") + ctx = WithStripeAccount(ctx, "acct_tenant_a") + result, err := adapter.CancelPayment(ctx, "po-no-reason", "") + require.NoError(t, err) + assert.Equal(t, "pi_no_reason", result.ProviderReference) +} diff --git a/services/financial-gateway/client/starlark.go b/services/financial-gateway/client/starlark.go index be8d7d0d7..ba07d77a4 100644 --- a/services/financial-gateway/client/starlark.go +++ b/services/financial-gateway/client/starlark.go @@ -316,7 +316,7 @@ func dispatchPaymentHandler(c *Client) saga.Handler { return map[string]any{ "provider_reference_id": resp.GetProviderReference(), "status": dispatchStatusToString(resp.GetStatus()), - "platform_fee_minor_units": int64(0), + "platform_fee_minor_units": resp.GetPlatformFeeMinorUnits(), }, nil } } diff --git a/services/financial-gateway/e2e/e2e_test.go b/services/financial-gateway/e2e/e2e_test.go index 0fda2d627..7bd2d4f6a 100644 --- a/services/financial-gateway/e2e/e2e_test.go +++ b/services/financial-gateway/e2e/e2e_test.go @@ -451,7 +451,7 @@ func TestDispatchRefund_Unimplemented(t *testing.T) { assert.Equal(t, codes.Unimplemented, st.Code()) } -func TestCancelPayment_Unimplemented(t *testing.T) { +func TestCancelPayment_MissingTenant(t *testing.T) { creator := &stubPaymentIntentCreator{ createFn: func(_ context.Context, _ *stripego.PaymentIntentCreateParams) (*stripego.PaymentIntent, error) { return nil, nil @@ -465,5 +465,5 @@ func TestCancelPayment_Unimplemented(t *testing.T) { st, ok := status.FromError(err) require.True(t, ok) - assert.Equal(t, codes.Unimplemented, st.Code()) + assert.Equal(t, codes.FailedPrecondition, st.Code()) } diff --git a/services/financial-gateway/service/grpc_service.go b/services/financial-gateway/service/grpc_service.go index 6c5069a1c..a44439fb4 100644 --- a/services/financial-gateway/service/grpc_service.go +++ b/services/financial-gateway/service/grpc_service.go @@ -101,12 +101,13 @@ func (s *FinancialGatewayService) dispatchStripePayment( dispatchID := uuid.New().String() return &financialgatewayv1.DispatchPaymentResponse{ - DispatchId: dispatchID, - PaymentOrderId: req.GetPaymentOrderId(), - Rail: financialgatewayv1.PaymentRail_PAYMENT_RAIL_STRIPE, - Status: result.Status, - ProviderReference: result.ProviderReference, - CreatedAt: timestamppb.Now(), + DispatchId: dispatchID, + PaymentOrderId: req.GetPaymentOrderId(), + Rail: financialgatewayv1.PaymentRail_PAYMENT_RAIL_STRIPE, + Status: result.Status, + ProviderReference: result.ProviderReference, + CreatedAt: timestamppb.Now(), + PlatformFeeMinorUnits: result.PlatformFeeAmount, }, nil } @@ -120,12 +121,45 @@ func (s *FinancialGatewayService) DispatchRefund( } // CancelPayment cancels a pending payment dispatch before it is delivered to the payment rail. -// Returns Unimplemented until cancellation support is added. func (s *FinancialGatewayService) CancelPayment( - _ context.Context, - _ *financialgatewayv1.CancelPaymentRequest, + ctx context.Context, + req *financialgatewayv1.CancelPaymentRequest, ) (*financialgatewayv1.CancelPaymentResponse, error) { - return nil, status.Error(codes.Unimplemented, "CancelPayment not yet implemented") + if s.stripeAdapter == nil || s.clientFactory == nil { + return nil, status.Error(codes.Unavailable, "stripe adapter is not configured") + } + + client, err := s.clientFactory.NewClient(ctx) + if err != nil { + s.logger.Error("failed to create stripe client for cancel", + "payment_order_id", req.GetPaymentOrderId(), + "error", err, + ) + return nil, mapClientFactoryError(err) + } + + ctx = stripeadapter.WithStripeAccount(ctx, client.AccountID) + + result, err := s.stripeAdapter.CancelPayment(ctx, req.GetPaymentOrderId(), req.GetReason()) + if err != nil { + s.logger.Error("stripe cancel failed", + "payment_order_id", req.GetPaymentOrderId(), + "error", err, + ) + return nil, mapCancelError(err) + } + + s.logger.Info("payment cancelled", + "payment_order_id", req.GetPaymentOrderId(), + "provider_reference", result.ProviderReference, + "status", result.Status.String(), + ) + + return &financialgatewayv1.CancelPaymentResponse{ + PaymentOrderId: req.GetPaymentOrderId(), + Status: "CANCELLED", + Reason: req.GetReason(), + }, nil } // GetProviderHealth returns the current health status of a payment rail provider. @@ -182,6 +216,26 @@ func mapClientFactoryError(err error) error { } } +// mapCancelError maps cancel-specific adapter errors to appropriate gRPC status codes. +func mapCancelError(err error) error { + switch { + case errors.Is(err, stripeadapter.ErrCancelNotConfigured): + return status.Error(codes.Unimplemented, "cancel support not configured") + case errors.Is(err, stripeadapter.ErrPaymentIntentNotFound): + return status.Error(codes.NotFound, "payment intent not found for payment order") + case errors.Is(err, stripeadapter.ErrMissingStripeAccount): + return status.Error(codes.FailedPrecondition, "stripe connected account not configured") + case errors.Is(err, stripeadapter.ErrInvalidRequest): + return status.Error(codes.FailedPrecondition, "payment cannot be cancelled in current state") + case errors.Is(err, context.Canceled): + return status.Error(codes.Canceled, "request canceled") + case errors.Is(err, context.DeadlineExceeded): + return status.Error(codes.DeadlineExceeded, "request deadline exceeded") + default: + return status.Error(codes.Unavailable, "cancel provider temporarily unavailable") + } +} + // mapStripeError maps adapter errors to appropriate gRPC status codes. func mapStripeError(err error) error { switch { diff --git a/services/financial-gateway/service/grpc_service_test.go b/services/financial-gateway/service/grpc_service_test.go index 3e4f84ecb..433b40479 100644 --- a/services/financial-gateway/service/grpc_service_test.go +++ b/services/financial-gateway/service/grpc_service_test.go @@ -95,6 +95,20 @@ func TestGetProviderHealth_UnsupportedRail(t *testing.T) { assert.Equal(t, codes.Unimplemented, st.Code()) } +func TestCancelPayment_StripeNotConfigured(t *testing.T) { + svc, err := NewFinancialGatewayService(Config{Logger: slog.Default()}) + require.NoError(t, err) + + _, err = svc.CancelPayment(context.Background(), &financialgatewayv1.CancelPaymentRequest{ + PaymentOrderId: "11111111-1111-1111-1111-111111111111", + Reason: "test", + }) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unavailable, st.Code()) +} + func TestMapCircuitBreakerHealth(t *testing.T) { tests := []struct { name string