diff --git a/cdk/lib/stripe-webhook-endpoints.ts b/cdk/lib/stripe-webhook-endpoints.ts new file mode 100644 index 0000000000..cb55e05b8e --- /dev/null +++ b/cdk/lib/stripe-webhook-endpoints.ts @@ -0,0 +1,114 @@ +import type { GuStackProps } from "@guardian/cdk/lib/constructs/core"; +import { GuStack } from "@guardian/cdk/lib/constructs/core"; +import { GuLambdaFunction } from "@guardian/cdk/lib/constructs/lambda"; +import type { App } from "aws-cdk-lib"; +import { Duration } from "aws-cdk-lib"; +import * as IAM from 'aws-cdk-lib/aws-iam'; +import * as sqs from 'aws-cdk-lib/aws-sqs'; +import * as ApiGW from 'aws-cdk-lib/aws-apigateway'; + +const appName = "stripe-webhook-endpoints"; + +export class AwsSqsDirectIntegrationStack extends GuStack { + constructor(scope: App, id: string, props: GuStackProps) { + super(scope, id, props); + + const queueName = `stripe-webhook-endpoints-${props.stage}`; + + // role + const integrationRole = new IAM.Role(this, 'integration-role', { + assumedBy: new IAM.ServicePrincipal('apigateway.amazonaws.com'), + }); + + // queue + const queue = new sqs.Queue(this,`${appName}Queue`, { + encryption: sqs.QueueEncryption.KMS_MANAGED, + }); + + // grant sqs:SendMessage* to Api Gateway Role + queue.grantSendMessages(integrationRole); + + // Api Gateway Direct Integration + const sendMessageIntegration = new ApiGW.AwsIntegration({ + service: 'sqs', + path: `${process.env.CDK_DEFAULT_ACCOUNT}/${queue.queueName}`, + integrationHttpMethod: 'POST', + options: { + credentialsRole: integrationRole, + requestParameters: { + 'integration.request.header.Content-Type': `'application/x-www-form-urlencoded'`, + }, + requestTemplates: { + 'application/json': 'Action=SendMessage&MessageBody=$input.body', + }, + integrationResponses: [ + { + statusCode: '200', + }, + { + statusCode: '400', + }, + { + statusCode: '500', + } + ] + }, + }); + + // Rest Api + const api = new ApiGW.RestApi(this, 'api', {}); + + // post method + api.root.addMethod('POST', sendMessageIntegration, { + methodResponses: [ + { + statusCode: '400', + }, + { + statusCode: '200', + }, + { + statusCode: '500', + } + ] + }); + } + + // Create a role + const role = new Role(this, "stripe-webhook-endpoints-sqs-lambda-role", { + roleName: `sqs-lambda-${this.stage}`, + assumedBy: new ServicePrincipal("lambda.amazonaws.com"), + }); + role.addToPolicy( + new PolicyStatement({ + actions: [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + ], + resources: ["*"], + }) +); + role.addToPolicy( + new PolicyStatement({ + actions: ["ssm:GetParameter"], + resources: [ + `arn:aws:ssm:${this.region}:${this.account}:parameter/${appName}/${props.stage}/gcp-wif-credentials-config`, + ], + }) +); + + new GuLambdaFunction(this, `${appName}Lambda`, { + app: appName, + runtime: Runtime.JAVA_11_CORRETTO, + fileName: `${appName}.jar`, + functionName: `${appName}-${props.stage}`, + handler: "com.gu.paymentIntentIssues.Lambda::handler", + events: [eventSource], + timeout: Duration.minutes(2), + role, + }); +} +} + + diff --git a/handlers/stripe-webhook-endpoints/cfn.yaml b/handlers/stripe-webhook-endpoints/cfn.yaml index e57ee88f33..39d2f56757 100644 --- a/handlers/stripe-webhook-endpoints/cfn.yaml +++ b/handlers/stripe-webhook-endpoints/cfn.yaml @@ -22,10 +22,198 @@ Parameters: Type: String Default: membership-dist -Conditions: - IsProd: !Equals [ !Ref Stage, "PROD" ] - Resources: + ApiGateway: #to have API created in AWS Console + Type: AWS::Serverless::Api #Creates a collection of Amazon API Gateway resources and methods that can be invoked through HTTPS endpoints. + Properties: + StageName: !Ref Stage + Description: Gateway for Stripe to make POST requests to + Name: !Sub stripe-webhook-endpoints-${Stage} + Cors: + AllowMethods: "'*'" + AllowHeaders: "'*'" + AllowOrigin: "'*'" + DefinitionBody: + swagger: "2.0" + info: + title: !Ref Stack + description: API Gateway to handle Stripe events + x-amazon-apigateway-request-validators: + body-only: + validateRequestBody: true + validateRequestParameters: false + params-only: + validateRequestBody: false + validateRequestParameters: true + x-amazon-apigateway-request-validator: body-only + securityDefinitions: + authorizer: + type: apiKey + name: Authorization + in: header + x-amazon-apigateway-authtype: oauth2 + x-amazon-apigateway-authorizer: + type: token + authorizerUri: !Join [ "", [ !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/", !GetAtt StripeAuthorizer.Arn, "/invocations" ] ] + authorizerCredentials: !GetAtt ApiGatewayToSQSRole.Arn + identityValidationExpression: "Bearer [A-Za-z0-9_-]+.[A-Za-z0-9_-]+.[A-Za-z0-9_-]+" + authorizerResultTtlInSeconds: 300 + paths: + "/": + post: + summary: receive a new stripe payment failed event + consumes: + - "application/json" + produces: + - "application/json" + responses: + "200": + description: "200 response" + schema: + $ref: "#/definitions/Empty" + security: + - authorizer: [ ] + parameters: + - in: body + name: PaymentFailedEventBody + required: true + schema: + $ref: "#/definitions/PaymentFailedEventBody" + x-amazon-apigateway-request-validator: body-only + x-amazon-apigateway-integration: + credentials: !GetAtt ApiGatewayToSQSRole.Arn + uri: !Sub "arn:aws:apigateway:${AWS::Region}:sqs:path//" + responses: + default: + statusCode: "200" + requestParameters: + integration.request.header.Content-Type: "'application/x-www-form-urlencoded'" + requestTemplates: + application/json: !Sub "Action=SendMessage##\n&QueueUrl=$util.urlEncode('${StripePaymentFailureMessagesQueue}')##\n\ + &MessageBody=$util.urlEncode($input.body)##\n" + passthroughBehavior: "never" + httpMethod: "POST" + type: "aws" + definitions: + Empty: + type: object + title: Empty + PaymentFailedEventBody: + title: PaymentFailedEvent + type: object + properties: + id: + type: string + description: The unique identifier for the payment intent. + object: + type: string + description: The type of object, always 'payment_intent'. + amount: + type: integer + description: The amount of the payment intent in the smallest currency unit. + currency: + type: string + description: The currency of the payment intent. + status: + type: string + description: The status of the payment intent. Should be 'failed'. + failure_message: + type: string + description: A message indicating why the payment intent failed. + created: + type: integer + description: Timestamp indicating when the payment intent was created. + payment_method: + type: string + description: The ID of the payment method used in the payment intent. + payment_method_types: + type: array + items: + type: string + description: The list of payment method types (e.g. card) that this PaymentIntent is allowed to use. + customer: + type: string + description: The ID of the customer associated with the payment intent. + metadata: + type: object + description: Any additional metadata associated with the payment intent. + required: + - id + - object + - amount + - currency + - status + - created + - payment_method + - payment_method_types + - customer + + # authorization lambda + StripeAuthorizer: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub ${App}-authorization-${Stage} + Description: Authorize API Gateway requests + Handler: com.gu.paymentIntentIssues.StripeAuthorizerLambda::handler + Runtime: java11 + MemorySize: 512 + Timeout: 300 + Environment: + Variables: + App: payment-intent-issues + Stack: !Ref Stack + Stage: !Ref Stage + CodeUri: + Bucket: !Ref DeployBucket + Key: !Sub ${Stack}/${Stage}/${App}/${App}.jar + Policies: + - AWSLambdaBasicExecutionRole + - Statement: + Effect: Allow + Action: + - ssm:GetParametersByPath + Resource: + - !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${Stage}/membership/payment-intent-issues + - Statement: + - Effect: Allow + Action: s3:GetObject + Resource: + - arn:aws:s3::*:membership-dist/* + ApiGatewayToSQSRole: #to allow API Gateway to push message to SQS + Type: AWS::IAM::Role + Properties: + Path: / + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - apigateway.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs + RoleName: !Sub "stripe-webhook-endpoints-apigateway-to-sqs" + Policies: + - PolicyName: ApiQueuePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - sqs:ReceiveMessage + - sqs:SendMessage + - sqs:SetQueueAttributes + Resource: !GetAtt StripePaymentFailureMessagesQueue.Arn + - PolicyName: LambdaPolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - lambda:InvokeFunction + Resource: !GetAtt StripeAuthorizer.Arn + PaymentIntentIssuesLambda: Type: AWS::Serverless::Function Properties: @@ -44,24 +232,49 @@ Resources: Bucket: !Ref DeployBucket Key: !Sub ${Stack}/${Stage}/${App}/${App}.jar Policies: - - AWSLambdaBasicExecutionRole - - Statement: - Effect: Allow - Action: - - ssm:GetParametersByPath - Resource: - - !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${Stage}/membership/payment-intent-issues - - Statement: - - Effect: Allow - Action: s3:GetObject - Resource: - - arn:aws:s3::*:membership-dist/* + - AWSLambdaBasicExecutionRole + - Statement: + Effect: Allow + Action: + - ssm:GetParametersByPath + Resource: + - !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${Stage}/membership/payment-intent-issues + - Statement: + - Effect: Allow + Action: s3:GetObject + Resource: + - arn:aws:s3::*:membership-dist/* + - AWSLambdaSQSQueueExecutionRole # This policy is required to allow the lambda to read from the queue + - Statement: + Effect: Allow + Action: + - sqs:ReceiveMessage + - sqs:DeleteMessage + - sqs:GetQueueAttributes + Resource: + - !GetAtt StripePaymentFailureMessagesQueue.Arn Events: - AcquisitionEvent: - Type: Api - Properties: - Path: '/payment-intent-issue' - Method: post + MySQSEvent: + Type: SQS + Properties: + Queue: !GetAtt StripePaymentFailureMessagesQueue.Arn + BatchSize: 10 + Enabled: false + + StripePaymentFailureMessagesQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub stripe-payment-failure-messages-queue-${Stage} + VisibilityTimeout: 1800 + RedrivePolicy: + deadLetterTargetArn: !GetAtt StripePaymentFailureMessagesDLQ.Arn + maxReceiveCount: 10 + + StripePaymentFailureMessagesDLQ: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub stripe-payment-failure-messages-queue-dlq-${Stage} + CustomerUpdatedLambda: Type: AWS::Serverless::Function Properties: diff --git a/handlers/stripe-webhook-endpoints/deploy-DEV-cfn.sh b/handlers/stripe-webhook-endpoints/deploy-DEV-cfn.sh new file mode 100755 index 0000000000..206890413e --- /dev/null +++ b/handlers/stripe-webhook-endpoints/deploy-DEV-cfn.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +# Exit if any of these commands fail, print commands to console +set -ex + +aws cloudformation update-stack \ + --capabilities '["CAPABILITY_AUTO_EXPAND", "CAPABILITY_NAMED_IAM", "CAPABILITY_IAM"]' \ + --stack-name stripe-webhook-endpoints-CODE \ + --template-body file://cfn.yaml \ + --parameters ParameterKey=Stage,ParameterValue=CODE \ + --profile membership \ + --region eu-west-1 + + + + +echo -e "\nStack update has been started, check progress in the AWS console."; +echo -e "https://eu-west-1.console.aws.amazon.com/cloudformation/home"; diff --git a/handlers/stripe-webhook-endpoints/src/api.yaml b/handlers/stripe-webhook-endpoints/src/api.yaml new file mode 100644 index 0000000000..a11bcd6ed3 --- /dev/null +++ b/handlers/stripe-webhook-endpoints/src/api.yaml @@ -0,0 +1,79 @@ +swagger: "2.0" +info: + title: !Ref Stack +x-amazon-apigateway-request-validators: + body-only: + validateRequestBody: true + validateRequestParameters: false + params-only: + validateRequestBody: false + validateRequestParameters: true +x-amazon-apigateway-request-validator: body-only +securityDefinitions: + token-authorizer: + type: apiKey + name: Authorization + in: header + x-amazon-apigateway-authtype: oauth2 + x-amazon-apigateway-authorizer: + type: token + authorizerUri: !Join [ "", [ !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/", !GetAtt Authorizer.Arn, "/invocations" ] ] + authorizerCredentials: !GetAtt ApiGatewayToSQSRole.Arn + identityValidationExpression: "Bearer [A-Za-z0-9_-]+.[A-Za-z0-9_-]+.[A-Za-z0-9_-]+" + authorizerResultTtlInSeconds: 300 +paths: + "/": + post: + consumes: + - "application/json" + produces: + - "application/json" + responses: + "200": + description: "200 response" + schema: + $ref: "#/definitions/Empty" + security: + - token-authorizer: [ ] + parameters: + - in: body + name: MailSendBody + required: true + schema: + $ref: "#/definitions/MailSendBody" + x-amazon-apigateway-request-validator: body-only + x-amazon-apigateway-integration: + credentials: !GetAtt ApiGatewayToSQSRole.Arn + uri: !Sub "arn:aws:apigateway:${AWS::Region}:sqs:path//" + responses: + default: + statusCode: "200" + requestParameters: + integration.request.header.Content-Type: "'application/x-www-form-urlencoded'" + requestTemplates: + application/json: !Sub "Action=SendMessage##\n&QueueUrl=$util.urlEncode('${ApiQueue}')##\n\ + &MessageBody=$util.urlEncode($input.body)##\n" + passthroughBehavior: "never" + httpMethod: "POST" + type: "aws" +definitions: + Empty: + type: object + title: Empty + MailSendBody: + title: MailSendBody + type: object + properties: + template: + type: string + locale: + type: string + userName: + type: string + userEmail: + type: string + required: + - template + - locale + - userName + - userEmail diff --git a/handlers/stripe-webhook-endpoints/src/main/scala/com/gu/paymentIntentIssues/Lambda.scala b/handlers/stripe-webhook-endpoints/src/main/scala/com/gu/paymentIntentIssues/Lambda.scala index bf02cf3fdc..37447edfbb 100644 --- a/handlers/stripe-webhook-endpoints/src/main/scala/com/gu/paymentIntentIssues/Lambda.scala +++ b/handlers/stripe-webhook-endpoints/src/main/scala/com/gu/paymentIntentIssues/Lambda.scala @@ -1,8 +1,7 @@ package com.gu.paymentIntentIssues - -import com.amazonaws.services.lambda.runtime.events.{APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent} +import com.amazonaws.services.lambda.runtime.events.{APIGatewayProxyRequestEvent,SQSBatchResponse, SQSEvent} import com.typesafe.scalalogging.LazyLogging - +import com.fasterxml.jackson.databind.ObjectMapper import scala.jdk.CollectionConverters._ import com.stripe.net.Webhook @@ -22,42 +21,59 @@ import sttp.client3.{HttpURLConnectionBackend, SttpBackend} import sttp.client3._ import sttp.client3.circe._ import com.gu.zuora.AccessToken +import io.circe.ParsingFailure +import io.circe.{Decoder, DecodingFailure} +import io.circe.generic.auto._ +import io.circe.parser.{decode => circeDecode} + object Lambda extends LazyLogging { - def handler(event: APIGatewayProxyRequestEvent): APIGatewayProxyResponseEvent = { + // ObjectMapper to deserialize JSON + private val objectMapper = new ObjectMapper() + def handler(event: SQSEvent): Unit= { + logger.info(s"Input was $event") + val identity = AppIdentity.whoAmI(defaultAppName = "payment-intent-issues") - val program = loadConfig(identity).subflatMap(config => - for { - payload <- getPayload(event, config.endpointSecret) - _ <- processEvent(payload, config) - } yield (), - ) - - val result = program.value.unsafeRunSync() - - val response = new APIGatewayProxyResponseEvent() - result match { - case Left(ConfigLoadingError(message)) => - logger.error(message) - response.setStatusCode(500) - case Left(error @ (InvalidRequestError(_) | InvalidJsonError(_))) => - logger.error(error.message) - response.setStatusCode(400) - case Left(error @ (MissingPaymentNumberError(_) | ZuoraApiError(_))) => - // TODO: alarm - logger.error(error.message) - response.setStatusCode(200) - case Right(_) => - response.setStatusCode(200) + val messages: List[SQSEvent.SQSMessage] = event.getRecords.asScala.toList + + messages.foreach { message => + val sqsMessageBody = message.getBody + val apiGatewayEvent = deserializeAPIGatewayEvent(sqsMessageBody) + + val program = loadConfig(identity).subflatMap(config => + for { + payload <- getPayload(apiGatewayEvent, config.endpointSecret) + _ <- processEvent(payload, config) + } yield (), + ) + + val result = program.value.unsafeRunSync() + +// result match { +// case Right(_) => +// logger.info(s"Message processed successfully") +// val failedMessageIds = messages.map(message => processEvent(, config)).collect { case Left(messageId) => messageId } +// new SQSBatchResponse( +// failedMessageIds.map(messageId => new BatchItemFailure(messageId)).asJava, +// ) +// case Left(error) => +// logger.error(s"Error processing SQS event: $error") +// new SQSBatchResponse( +// event.getRecords.asScala.map(message => new SQSBatchResponse.BatchItemFailure(message.getMessageId)).asJava, +// ) +// } } - response } def loadConfig(identity: AppIdentity): EitherT[IO, Error, Config] = ConfigLoader.loadConfig[IO, Config](identity).leftMap(e => ConfigLoadingError(e.message)) + def deserializeAPIGatewayEvent(json: String): APIGatewayProxyRequestEvent = { + // Deserialize the JSON string into APIGatewayProxyRequestEvent + objectMapper.readValue(json, classOf[APIGatewayProxyRequestEvent]) - def getPayload(event: APIGatewayProxyRequestEvent, endpointSecret: String): Either[Error, String] = + } + def getPayload(event: APIGatewayProxyRequestEvent,endpointSecret: String): Either[Error, String] = for { payload <- Option(event.getBody()).toRight(InvalidRequestError("Missing body")) sigHeader <- event.getHeaders.asScala.get("Stripe-Signature").toRight(InvalidRequestError("Missing sig header")) @@ -88,10 +104,10 @@ object Lambda extends LazyLogging { } def refundZuoraPayment( - paymentNumber: String, - paymentIntentObject: PaymentIntentObject, - config: Config, - ): Either[Error, Unit] = { + paymentNumber: String, + paymentIntentObject: PaymentIntentObject, + config: Config, + ): Either[Error, Unit] = { logger.info(s"Zuora payment number: $paymentNumber") implicit val backend: SttpBackend[Id, Any] = HttpURLConnectionBackend() @@ -112,10 +128,10 @@ object Lambda extends LazyLogging { accessTokenGetResponseV2(oauthConfig, backend).left.map(e => ZuoraApiError(e.reason)) def queryPayments( - paymentNumber: String, - config: ZuoraRestConfig, - backend: SttpBackend[Id, Any], - ): Either[Error, ZuoraPaymentQueryResponse] = + paymentNumber: String, + config: ZuoraRestConfig, + backend: SttpBackend[Id, Any], + ): Either[Error, ZuoraPaymentQueryResponse] = basicRequest .post(uri"${config.baseUrl}/action/query") .header("Authorization", s"Bearer ${config.accessToken}") @@ -128,11 +144,11 @@ object Lambda extends LazyLogging { .body def rejectPayment( - paymentId: String, - paymentIntentObject: PaymentIntentObject, - config: ZuoraRestConfig, - backend: SttpBackend[Id, Any], - ): Either[Error, Unit] = { + paymentId: String, + paymentIntentObject: PaymentIntentObject, + config: ZuoraRestConfig, + backend: SttpBackend[Id, Any], + ): Either[Error, Unit] = { val body = ZuoraRejectPaymentBody.fromStripePaymentIntentObject(paymentIntentObject) basicRequest diff --git a/handlers/stripe-webhook-endpoints/src/main/scala/com/gu/paymentIntentIssues/LocalTest.scala b/handlers/stripe-webhook-endpoints/src/main/scala/com/gu/paymentIntentIssues/LocalTest.scala new file mode 100644 index 0000000000..409bc199a3 --- /dev/null +++ b/handlers/stripe-webhook-endpoints/src/main/scala/com/gu/paymentIntentIssues/LocalTest.scala @@ -0,0 +1,337 @@ +package com.gu.paymentIntentIssues + +import com.amazonaws.services.lambda.runtime.events.SQSEvent +import com.stripe.net.Webhook +import play.api.libs.json._ +import com.fasterxml.jackson.databind.ObjectMapper +import scala.jdk.CollectionConverters._ + +object LocalTest { + + /** NB: It will probably be quite tricky to run this locally going forward. It was useful during development and is + * mainly being kept for reference. + * + * For testing locally against zuora sandbox we require the following environment variables: + * + * - endpointSecret + * - zuoraClientId + * - zuoraSecret + * + * endpointSecret can be found in the stripe webhook dashboard (https://dashboard.stripe.com/test/webhooks). + * zuoraClientId and zuoraSecret can be found in parameter store (e.g + * /CODE/membership/payment-intent-issues/zuoraClientId) + * + * To run every step of the zuora update, we need to generate a new failed payment event from stripe. To do this in + * CODE, we need to: + * + * 1. Disable the webhook in AWS to prevent it from processing the event 2. Visit the CODE LP and make a + * contribution with a test IBAN (https://stripe.com/docs/connect/testing) 3. Visit the stripe webhook dashboard + * to copy the event it generated for the failed payment 4. Paste the event json in the `getJson` function + * (maintaining the custom timestamp) + * + * NB: this does not test the Stripe signature verification logic. This needs to be done in aws with a real Stripe + * event. + */ + def main(args: Array[String]): Unit = { + println(s"Processing test SEPA payment failure event...") + val timestamp = getTimestamp + val json = getJson(timestamp) + val config = getConfig.get + val sqsEvent= getSQSEvent() + val sqsMessages= getSQSMessages(sqsEvent) + + val eventResult = Lambda.deserializeAPIGatewayEvent(sqsEvent) + println(s"Event Result: $eventResult") + + + val result = Lambda.processEvent(json, config) + println(s"Result: $result") + } + + def getConfig = + for { + endpointSecret <- sys.env.get("endpointSecret") + zuoraBaseUrl = "https://rest.apisandbox.zuora.com/v1" + zuoraClientId <- sys.env.get("zuoraClientId") + zuoraSecret <- sys.env.get("zuoraSecret") + } yield Config(endpointSecret, zuoraBaseUrl, zuoraClientId, zuoraSecret) + + def getTimestamp = System.currentTimeMillis() / 1000L + + def getJson(timestamp: Long) = + s""" +{ + "id": "evt_2JPS0AItVxyc3Q6n1kS8z3uw", + "object": "event", + "api_version": "2019-08-14", + "created": ${timestamp}, + "data": { + "object": { + "id": "pi_2JPS0AItVxyc3Q6n1Eai3ExG", + "object": "payment_intent", + "amount": 500, + "amount_capturable": 0, + "amount_received": 0, + "application": "ca_InA7dYYPZTUPuEBolrjvtCzbkivYpfeM", + "application_fee_amount": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "py_2JPS0AItVxyc3Q6n1O3eyhB5", + "object": "charge", + "amount": 500, + "amount_captured": 500, + "amount_refunded": 0, + "application": "ca_InA7dYYPZTUPuEBolrjvtCzbkivYpfeM", + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": null, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "mr.test@guardian.co.uk", + "name": "mr test", + "phone": null + }, + "calculated_statement_descriptor": null, + "captured": true, + "created": 1629205606, + "currency": "eur", + "customer": "cus_K3Z8s1gjto6COh", + "description": null, + "destination": null, + "dispute": null, + "disputed": false, + "failure_code": null, + "failure_message": null, + "fraud_details": { + }, + "invoice": null, + "livemode": false, + "metadata": { + "zpayment_number": "P-00126890" + }, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "not_assessed", + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": false, + "payment_intent": "pi_2JPS0AItVxyc3Q6n1Eai3ExG", + "payment_method": "pm_0JPS09ItVxyc3Q6n4YxpwZAp", + "payment_method_details": { + "sepa_debit": { + "bank_code": "19043", + "branch_code": null, + "country": "AT", + "fingerprint": "vUIITsFzpDpkeigs", + "last4": "3202", + "mandate": "mandate_0JPS09ItVxyc3Q6nbjCRwVCD" + }, + "type": "sepa_debit" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": null, + "refunded": false, + "refunds": { + "object": "list", + "data": [ + ], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/py_2JPS0AItVxyc3Q6n1O3eyhB5/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "failed", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_2JPS0AItVxyc3Q6n1Eai3ExG" + }, + "client_secret": "pi_2JPS0AItVxyc3Q6n1Eai3ExG_secret_APuLtVwJ5tvymrq7jbBQY6VEv", + "confirmation_method": "automatic", + "created": 1629205606, + "currency": "eur", + "customer": "cus_K3Z8s1gjto6COh", + "description": null, + "invoice": null, + "last_payment_error": { + "code": "payment_intent_payment_attempt_failed", + "doc_url": "https://stripe.com/docs/error-codes/payment-intent-payment-attempt-failed", + "message": "The payment failed.", + "payment_method": { + "id": "pm_0JPS09ItVxyc3Q6n4YxpwZAp", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "mr.test@guardian.co.uk", + "name": "mr test", + "phone": null + }, + "created": 1629205605, + "customer": "cus_K3Z8s1gjto6COh", + "livemode": false, + "metadata": { + }, + "sepa_debit": { + "bank_code": "19043", + "branch_code": "", + "country": "AT", + "fingerprint": "vUIITsFzpDpkeigs", + "generated_from": { + "charge": null, + "setup_attempt": null + }, + "last4": "3202" + }, + "type": "sepa_debit" + }, + "type": "invalid_request_error" + }, + "level3": null, + "livemode": false, + "metadata": { + "zpayment_number": "P-00126890" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_options": { + "sepa_debit": { + } + }, + "payment_method_types": [ + "sepa_debit" + ], + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 1, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "payment_intent.payment_failed" +} + """ + + def getHeaders(endpointSecret: String, json: String, timestamp: Long) = { + val signature = Webhook.Util.computeHmacSha256(endpointSecret, json) + Map("Stripe-Signature" -> s"t=$timestamp,v1=$signature").asJava + } + + def getSQSEvent()= s""" + { + "Records": [ + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", + "body": ${json1}), + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1545082649183", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185" + }, + "messageAttributes": {}, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2" + }, + { + "messageId": "2e1424d4-f796-459a-8184-9c92662be6da", + "receiptHandle": "AQEBzWwaftRI0KuVm4tP+/7q1rGgNqicHq...", + "body": "Test message.", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1545082650636", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082650649" + }, + "messageAttributes": {}, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2" + } + ] + } """ + + def getSQSMessages(jsonString: String): Seq[String] = { + // Parse the JSON string + val json = Json.parse(jsonString) + // Extract the "Records" field + val records = (json \ "Records").asOpt[JsArray] + + records match { + case Some(jsArray) => jsArray.value.map { message => + (message \ "body").as[String] + } .toSeq// Extracts the list of messages + case None => Seq.empty[String] // Returns an empty list if "Records" field is not found + } + + } + + val json1 = + """ + { + "resource": "/{proxy+}", + "path": "/hello/world", + "httpMethod": "POST", + "headers": { + "Accept": "*/*", + "Content-Type": "application/json" + }, + "requestContext": { + "identity": { + "apiKey": "test-api-key" + } + }, + "body": "{\"name\":\"John\",\"age\":30}", + "isBase64Encoded": false + } + """ + + +} diff --git a/handlers/stripe-webhook-endpoints/src/main/scala/com/gu/paymentIntentIssues/StripeAuthorizerLambda.scala b/handlers/stripe-webhook-endpoints/src/main/scala/com/gu/paymentIntentIssues/StripeAuthorizerLambda.scala new file mode 100644 index 0000000000..9273755259 --- /dev/null +++ b/handlers/stripe-webhook-endpoints/src/main/scala/com/gu/paymentIntentIssues/StripeAuthorizerLambda.scala @@ -0,0 +1,67 @@ +package com.gu.paymentIntentIssues + +object StripeAuthorizerLambda extends LazyLogging{ + def handler(event: APIGatewayProxyRequestEvent): APIGatewayProxyResponseEvent = { + val identity = AppIdentity.whoAmI(defaultAppName = "payment-intent-issues") + + val program = loadConfig(identity).subflatMap(config => + for { + payload <- getPayload(event, config.endpointSecret) + _ <- processEvent(payload, config) + } yield (), + ) + + val result = program.value.unsafeRunSync() + + val response = new APIGatewayProxyResponseEvent() + result match { + case Left(ConfigLoadingError(message)) => + logger.error(message) + response.setStatusCode(500) + case Left(error @ (InvalidRequestError(_) | InvalidJsonError(_))) => + logger.error(error.message) + response.setStatusCode(400) + case Left(error @ (MissingPaymentNumberError(_) | ZuoraApiError(_))) => + // TODO: alarm + logger.error(error.message) + response.setStatusCode(200) + case Right(_) => + response.setStatusCode(200) + } + response + } + + def loadConfig(identity: AppIdentity): EitherT[IO, Error, Config] = + ConfigLoader.loadConfig[IO, Config](identity).leftMap(e => ConfigLoadingError(e.message)) + + def getPayload(event: APIGatewayProxyRequestEvent, endpointSecret: String): Either[Error, String] = + for { + payload <- Option(event.getBody()).toRight(InvalidRequestError("Missing body")) + sigHeader <- event.getHeaders.asScala.get("Stripe-Signature").toRight(InvalidRequestError("Missing sig header")) + _ <- Try(Webhook.Signature.verifyHeader(payload, sigHeader, endpointSecret, 300)).toEither.left.map(e => + InvalidRequestError(e.getMessage()), + ) + } yield payload + + def processEvent(payload: String, config: Config): Either[Error, Unit] = + for { + intent <- parsePaymentIntent(payload) + _ <- processPaymentIntent(intent, config) + } yield () + + def parsePaymentIntent(payload: String): Either[Error, PaymentIntent] = + for { + event <- PaymentIntentEvent.fromJson(payload) + intent <- PaymentIntent.fromEvent(event) + } yield intent + + def processPaymentIntent(intent: PaymentIntent, config: Config): Either[Error, Unit] = + intent match { + case SepaPaymentIntent(paymentNumber, paymentIntentObject) => + refundZuoraPayment(paymentNumber, paymentIntentObject, config) + case OtherPaymentIntent() => + logger.info(s"Ignoring non-SEPA event") + Right(()) + } + +}