diff --git a/app/go/e2e/E2E_postman_tests.json b/app/go/e2e/E2E_postman_tests.json new file mode 100644 index 0000000..aa7e7ce --- /dev/null +++ b/app/go/e2e/E2E_postman_tests.json @@ -0,0 +1,177 @@ +{ + "info": { + "name": "Fender development Exercise", + "description": "E2E test Postman", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Create subscription", + "request": { + "method": "POST", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base}}/api/v1/webhooks/subscriptions", + "host": [ + "{{base}}" + ], + "path": [ + "api", + "v1", + "webhooks", + "subscriptions" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"eventId\": \"evt_123456789\", \"eventType\": \"subscription.created\", \"timestamp\": \"2024-03-20T10:00:00Z\", \"provider\": \"STRIPE\", \"subscriptionId\": \"sub_456789\", \"paymentId\": \"pm_123456\", \"userId\": \"123\", \"customerId\": \"cus_789012\", \"expiresAt\": \"2024-04-20T10:00:00Z\", \"metadata\": {\"planSku\": \"PREMIUM_MONTHLY\", \"autoRenew\": true, \"paymentMethod\": \"CREDIT_CARD\"}}" + } + } + }, + { + "name": "Get subscription (after create)", + "request": { + "method": "GET", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + } + ], + "url": { + "raw": "{{base}}/api/v1/subscriptions/123", + "host": [ + "{{base}}" + ], + "path": [ + "api", + "v1", + "subscriptions", + "123" + ] + } + } + }, + { + "name": "Renew subscription", + "request": { + "method": "POST", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base}}/api/v1/webhooks/subscriptions", + "host": [ + "{{base}}" + ], + "path": [ + "api", + "v1", + "webhooks", + "subscriptions" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"eventId\": \"evt_123456789\", \"eventType\": \"subscription.renewed\", \"timestamp\": \"2024-04-20T10:00:00Z\", \"provider\": \"STRIPE\", \"subscriptionId\": \"sub_456789\", \"paymentId\": \"pm_122334\", \"userId\": \"123\", \"customerId\": \"cus_789012\", \"expiresAt\": \"2024-05-20T10:00:00Z\", \"metadata\": {\"planSku\": \"PREMIUM_MONTHLY\", \"autoRenew\": true, \"paymentMethod\": \"CREDIT_CARD\"}}" + } + } + }, + { + "name": "Get subscription (after renew)", + "request": { + "method": "GET", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + } + ], + "url": { + "raw": "{{base}}/api/v1/subscriptions/123", + "host": [ + "{{base}}" + ], + "path": [ + "api", + "v1", + "subscriptions", + "123" + ] + } + } + }, + { + "name": "Cancel subscription", + "request": { + "method": "POST", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "{{base}}/api/v1/webhooks/subscriptions", + "host": [ + "{{base}}" + ], + "path": [ + "api", + "v1", + "webhooks", + "subscriptions" + ] + }, + "body": { + "mode": "raw", + "raw": "{\"eventId\": \"evt_456789123\", \"eventType\": \"subscription.cancelled\", \"timestamp\": \"2024-05-20T10:00:00Z\", \"provider\": \"STRIPE\", \"subscriptionId\": \"sub_456789\", \"paymentId\": null, \"userId\": \"123\", \"customerId\": \"cus_789012\", \"expiresAt\": \"2024-05-20T10:00:00Z\", \"cancelledAt\": \"2024-05-20T10:00:00Z\", \"metadata\": {\"planSku\": \"PREMIUM_MONTHLY\", \"autoRenew\": false, \"paymentMethod\": \"CREDIT_CARD\", \"cancelReason\": \"USER_REQUESTED\"}}" + } + } + }, + { + "name": "Get subscription (after cancel)", + "request": { + "method": "GET", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + } + ], + "url": { + "raw": "{{base}}/api/v1/subscriptions/123", + "host": [ + "{{base}}" + ], + "path": [ + "api", + "v1", + "subscriptions", + "123" + ] + } + } + } + ] +} \ No newline at end of file diff --git a/app/go/go.mod b/app/go/go.mod index 79410d3..37fa55a 100755 --- a/app/go/go.mod +++ b/app/go/go.mod @@ -2,4 +2,26 @@ module github.com/fenderdigital/fds-aws-coding-exercise go 1.24.5 -require github.com/aws/aws-lambda-go v1.49.0 +require ( + github.com/aws/aws-lambda-go v1.49.0 + github.com/aws/aws-sdk-go-v2 v1.39.3 + github.com/aws/aws-sdk-go-v2/config v1.31.14 + github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.17 + github.com/aws/aws-sdk-go-v2/service/dynamodb v1.52.1 +) + +require ( + github.com/aws/aws-sdk-go-v2/credentials v1.18.18 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.31.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.8 // indirect + github.com/aws/smithy-go v1.23.1 // indirect +) diff --git a/app/go/go.sum b/app/go/go.sum index a5b506a..9831d5c 100755 --- a/app/go/go.sum +++ b/app/go/go.sum @@ -1,5 +1,39 @@ github.com/aws/aws-lambda-go v1.49.0 h1:z4VhTqkFZPM3xpEtTqWqRqsRH4TZBMJqTkRiBPYLqIQ= github.com/aws/aws-lambda-go v1.49.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= +github.com/aws/aws-sdk-go-v2 v1.39.3 h1:h7xSsanJ4EQJXG5iuW4UqgP7qBopLpj84mpkNx3wPjM= +github.com/aws/aws-sdk-go-v2 v1.39.3/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM= +github.com/aws/aws-sdk-go-v2/config v1.31.14 h1:kj/KpDqvt0UqcEL3WOvCykE9QUpBb6b23hQdnXe+elo= +github.com/aws/aws-sdk-go-v2/config v1.31.14/go.mod h1:X5PaY6QCzViihn/ru7VxnIamcJQrG9NSeTxuSKm2YtU= +github.com/aws/aws-sdk-go-v2/credentials v1.18.18 h1:5AfxTvDN0AJoA7rg/yEc0sHhl6/B9fZ+NtiQuOjWGQM= +github.com/aws/aws-sdk-go-v2/credentials v1.18.18/go.mod h1:m9mE1mJ1s7zI6rrt7V3RQU2SCgUbNaphlfqEksLp+Fs= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.17 h1:IPznA4MUKCdHL28SZeuMhFxBSMTYZjm+lqLdQRKd6gM= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.17/go.mod h1:OFWH4SmyLk8MzTWhk3XaveS3cNyY7SU6UQDPviMIEbM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10 h1:UuGVOX48oP4vgQ36oiKmW9RuSeT8jlgQgBFQD+HUiHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.10/go.mod h1:vM/Ini41PzvudT4YkQyE/+WiQJiQ6jzeDyU8pQKwCac= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10 h1:mj/bdWleWEh81DtpdHKkw41IrS+r3uw1J/VQtbwYYp8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.10/go.mod h1:7+oEMxAZWP8gZCyjcm9VicI0M61Sx4DJtcGfKYv2yKQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10 h1:wh+/mn57yhUrFtLIxyFPh2RgxgQz/u+Yrf7hiHGHqKY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.10/go.mod h1:7zirD+ryp5gitJJ2m1BBux56ai8RIRDykXZrJSp540w= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.52.1 h1:HWdbTAAa51HIg4jXyTtkHRU5ZF0n3+rNChldmveicDw= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.52.1/go.mod h1:GyNGZUbiqJH5lMAVNlYlYXCNoJcCmyPAeLxlDKsmi1g= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.31.2 h1:AVmNRz6Sjfwug8mA314XbCOETbotDO1PtwZGk5bTy3I= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.31.2/go.mod h1:IakOzjzwZN+7RAC1Hja1n0A466zBL9lx/I4KIDvJjUY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.10 h1:T0QsDQNCVealR4CrVt+spgWJgjl8oIDje/5TH8YnCmE= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.10/go.mod h1:SGBJMtnGk4y9Yvrr3iNPos9WUqexJHxq2OI6Z1ch634= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10 h1:DRND0dkCKtJzCj4Xl4OpVbXZgfttY5q712H9Zj7qc/0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.10/go.mod h1:tGGNmJKOTernmR2+VJ0fCzQRurcPZj9ut60Zu5Fi6us= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7 h1:fspVFg6qMx0svs40YgRmE7LZXh9VRZvTT35PfdQR6FM= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.7/go.mod h1:BQTKL3uMECaLaUV3Zc2L4Qybv8C6BIXjuu1dOPyxTQs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2 h1:scVnW+NLXasGOhy7HhkdT9AGb6kjgW7fJ5xYkUaqHs0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.2/go.mod h1:FRNCY3zTEWZXBKm2h5UBUPvCVDOecTad9KhynDyGBc0= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.8 h1:xSL4IV19pKDASL2fjWXRfTGmZddPiPPZNPpbv6uZQZY= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.8/go.mod h1:L1xxV3zAdB+qVrVW/pBIrIAnHFWHo6FBbFe4xOGsG/o= +github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/app/go/src/dtos/dtos.go b/app/go/src/dtos/dtos.go new file mode 100644 index 0000000..6e470fb --- /dev/null +++ b/app/go/src/dtos/dtos.go @@ -0,0 +1,35 @@ +package dtos + +type SubscriptionResponse struct { + UserID string `json:"userId"` + SubscriptionID string `json:"subscriptionId"` + Plan *SubscriptionResponsePlan `json:"plan"` + StartDate string `json:"startDate"` + ExpiresAt string `json:"expiresAt"` + CancelledAt *string `json:"cancelledAt"` + Status SubStatus `json:"status"` + Attributes map[string]any `json:"attributes"` +} + +type SubscriptionResponsePlan struct { + SKU string `json:"sku"` + Name string `json:"name"` + Price float64 `json:"price"` + Currency string `json:"currency"` + BillingCycle string `json:"billingCycle"` + Features []string `json:"features"` +} + +type SubscriptionRequest struct { + EventID string `json:"eventId"` + EventType string `json:"eventType"` + Timestamp string `json:"timestamp"` + Provider string `json:"provider"` + SubscriptionID string `json:"subscriptionId"` + PaymentID *string `json:"paymentId"` + UserID string `json:"userId"` + CustomerID string `json:"customerId"` + ExpiresAt string `json:"expiresAt"` + CanceledAt *string `json:"cancelledAt"` + Metadata map[string]any `json:"metadata"` +} diff --git a/app/go/src/dtos/status.go b/app/go/src/dtos/status.go new file mode 100644 index 0000000..db4554b --- /dev/null +++ b/app/go/src/dtos/status.go @@ -0,0 +1,9 @@ +package dtos + +type SubStatus string + +const ( + SubStatusActive SubStatus = "active" + SubStatusPending SubStatus = "pending" + SubStatusCancelled SubStatus = "cancelled" +) diff --git a/app/go/src/entities/subscription.go b/app/go/src/entities/subscription.go new file mode 100644 index 0000000..bbb4dc5 --- /dev/null +++ b/app/go/src/entities/subscription.go @@ -0,0 +1,27 @@ +package entities + +type SubscriptionItem struct { + PK string `dynamodbav:"pk"` + SK string `dynamodbav:"sk"` + Type string `dynamodbav:"type"` + PlanSKU string `dynamodbav:"planSku"` + StartDate string `dynamodbav:"startDate"` + ExpiresAt string `dynamodbav:"expiresAt"` + CancelledAt *string `dynamodbav:"cancelledAt"` + LastModifiedAt string `dynamodbav:"lastModifiedAt"` + Attributes map[string]any `dynamodbav:"attributes"` +} + +type Plan struct { + PK string `dynamodbav:"pk"` + SK string `dynamodbav:"sk"` + Type string `dynamodbav:"type"` + SKU string `dynamodbav:"-"` + Name string `dynamodbav:"name"` + Price float64 `dynamodbav:"price"` + Currency string `dynamodbav:"currency"` + BillingCycle string `dynamodbav:"billingCycle"` + Features []string `dynamodbav:"features"` + Status string `dynamodbav:"status"` + LastModifiedAt string `dynamodbav:"lastModifiedAt"` +} diff --git a/app/go/src/handlers/subscription.go b/app/go/src/handlers/subscription.go new file mode 100644 index 0000000..957273d --- /dev/null +++ b/app/go/src/handlers/subscription.go @@ -0,0 +1,291 @@ +package handlers + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/fenderdigital/fds-aws-coding-exercise/src/dtos" + "github.com/fenderdigital/fds-aws-coding-exercise/src/entities" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" + ddb "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +var ( + errActivePlan = errors.New("plan is active") + errActiveOrPendingSub = errors.New("user already has an active/pending subscription") + errMissingCanceledAt = errors.New("missing canceledAt time to cancel subscription") +) + +type ApiHandler struct { + tableName string + ddbCli *ddb.Client +} + +func NewApiHandler(tableName string, ddbCli *ddb.Client) *ApiHandler { + return &ApiHandler{tableName: tableName, ddbCli: ddbCli} +} + +func (ah *ApiHandler) GetUserSubs(ctx context.Context, userID string) ([]*dtos.SubscriptionResponse, error) { + subs, err := ah.getUserSubs(ctx, userID) + if err != nil { + return nil, err + } + + subResponses := make([]*dtos.SubscriptionResponse, 0, len(subs)) + for _, sub := range subs { + plan, err := ah.getPlan(ctx, sub.PlanSKU) + if err != nil { + return nil, err + } + + subID := strings.TrimPrefix(sub.SK, "sub:") + + status, err := getStatus(sub.CancelledAt, sub.ExpiresAt) + if err != nil { + return nil, err + } + + resp := dtos.SubscriptionResponse{ + UserID: strings.TrimPrefix(sub.PK, "user:"), + SubscriptionID: subID, + StartDate: sub.StartDate, + ExpiresAt: sub.ExpiresAt, + CancelledAt: sub.CancelledAt, + Status: status, + Attributes: sub.Attributes, + Plan: &dtos.SubscriptionResponsePlan{ + SKU: plan.SKU, + Name: plan.Name, + Price: plan.Price, + Currency: plan.Currency, + BillingCycle: plan.BillingCycle, + Features: plan.Features, + }, + } + + subResponses = append(subResponses, &resp) + } + + return subResponses, nil +} + +func (ah *ApiHandler) CreateUserSub(ctx context.Context, subReq *dtos.SubscriptionRequest) error { + plan, err := ah.getPlan(ctx, asString(subReq.Metadata["planSku"])) + if err != nil { + return err + } + + if strings.ToLower(plan.Status) != "active" { + return errActivePlan + } + + subs, err := ah.getUserSubs(ctx, subReq.UserID) + if err != nil { + return err + } + + hasActiveOrPendingSub, err := hasActiveOrPending(subs) + if err != nil { + return err + } + + if hasActiveOrPendingSub { + return errActiveOrPendingSub + } + + pk, sk := userSubKey(subReq.UserID, subReq.SubscriptionID) + start := subReq.Timestamp + planSKU := asString(subReq.Metadata["planSku"]) + + delete(subReq.Metadata, "planSku") + item := entities.SubscriptionItem{ + PK: pk, + SK: sk, + Type: "sub", + PlanSKU: planSKU, + StartDate: start, + ExpiresAt: subReq.ExpiresAt, + CancelledAt: nil, + LastModifiedAt: time.Now().Format(time.RFC3339), + Attributes: subReq.Metadata, + } + av, err := attributevalue.MarshalMap(item) + if err != nil { + return fmt.Errorf("failed to marshal item: %w", err) + } + + _, err = ah.ddbCli.PutItem(ctx, &ddb.PutItemInput{ + TableName: aws.String(ah.tableName), + Item: av, + ConditionExpression: aws.String("attribute_not_exists(pk) AND attribute_not_exists(sk)"), + }) + if err != nil { + return fmt.Errorf("failed to create subscription: %w", err) + } + + return nil +} + +func (ah *ApiHandler) RenewUserSub(ctx context.Context, subReq *dtos.SubscriptionRequest) error { + pk, sk := userSubKey(subReq.UserID, subReq.SubscriptionID) + _, err := ah.ddbCli.UpdateItem(ctx, &ddb.UpdateItemInput{ + TableName: aws.String(ah.tableName), + Key: map[string]types.AttributeValue{ + "pk": &types.AttributeValueMemberS{Value: pk}, + "sk": &types.AttributeValueMemberS{Value: sk}, + }, + UpdateExpression: aws.String("SET expiresAt = :exp, lastModifiedAt = :now, attributes = :attrs REMOVE canceledAt"), + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":exp": &types.AttributeValueMemberS{Value: subReq.ExpiresAt}, + ":now": &types.AttributeValueMemberS{Value: time.Now().Format(time.RFC3339)}, + ":attrs": parseMetadataAttributes(subReq.Metadata), + }, + ConditionExpression: aws.String("attribute_exists(pk) AND attribute_exists(sk)"), + }) + if err != nil { + return fmt.Errorf("failed to renew subscription: %w", err) + } + + return nil +} + +func (ah *ApiHandler) CancelUserSub(ctx context.Context, subReq *dtos.SubscriptionRequest) error { + if subReq.CanceledAt == nil || *subReq.CanceledAt == "" { + return errMissingCanceledAt + } + pk, sk := userSubKey(subReq.UserID, subReq.SubscriptionID) + _, err := ah.ddbCli.UpdateItem(ctx, &ddb.UpdateItemInput{ + TableName: aws.String(ah.tableName), + Key: map[string]types.AttributeValue{ + "pk": &types.AttributeValueMemberS{Value: pk}, + "sk": &types.AttributeValueMemberS{Value: sk}, + }, + UpdateExpression: aws.String("SET cancelledAt = :canceled, lastModifiedAt = :now, attributes = :attrs"), + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":canceled": &types.AttributeValueMemberS{Value: *subReq.CanceledAt}, + ":now": &types.AttributeValueMemberS{Value: time.Now().Format(time.RFC3339)}, + ":attrs": parseMetadataAttributes(subReq.Metadata), + }, + ConditionExpression: aws.String("attribute_exists(pk) AND attribute_exists(sk)"), + }) + if err != nil { + return fmt.Errorf("failed to cancel subscription: %w", err) + } + + return nil +} + +func (ah *ApiHandler) getUserSubs(ctx context.Context, userID string) ([]*entities.SubscriptionItem, error) { + pk := "user:" + userID + out, err := ah.ddbCli.Query(ctx, &ddb.QueryInput{ + TableName: aws.String(ah.tableName), + KeyConditionExpression: aws.String("#pk = :pk"), + ExpressionAttributeNames: map[string]string{ + "#pk": "pk", + }, + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":pk": &types.AttributeValueMemberS{Value: pk}, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to query user subscription: %w", err) + } + + subs := make([]*entities.SubscriptionItem, 0, len(out.Items)) + for _, it := range out.Items { + var s entities.SubscriptionItem + if err := attributevalue.UnmarshalMap(it, &s); err != nil { + return nil, fmt.Errorf("failed to unmarshal subscription item: %w", err) + } + subs = append(subs, &s) + } + + return subs, nil +} + +func (ah *ApiHandler) getPlan(ctx context.Context, sku string) (*entities.Plan, error) { + pk, sk := planKey(sku) + out, err := ah.ddbCli.GetItem(ctx, &ddb.GetItemInput{ + TableName: aws.String(ah.tableName), + Key: map[string]types.AttributeValue{ + "pk": &types.AttributeValueMemberS{Value: pk}, + "sk": &types.AttributeValueMemberS{Value: sk}, + }, + }) + if err != nil { + return nil, err + } + if out.Item == nil { + return nil, fmt.Errorf("plan %s not found", sku) + } + var p entities.Plan + if err := attributevalue.UnmarshalMap(out.Item, &p); err != nil { + return nil, err + } + + if strings.HasPrefix(p.PK, "plan:") { + p.SKU = strings.TrimPrefix(p.PK, "plan:") + } + return &p, nil +} + +func getStatus(canceledAt *string, expiresAt string) (dtos.SubStatus, error) { + exp, err := time.Parse(time.RFC3339, expiresAt) + if err != nil { + return "", fmt.Errorf("invalid expiresAt parsing: %w", err) + } + if canceledAt == nil || *canceledAt == "" { + return dtos.SubStatusActive, nil + } + + if time.Now().Before(exp) { + return dtos.SubStatusPending, nil + } + + return dtos.SubStatusCancelled, nil +} + +func planKey(sku string) (pk, sk string) { + return "plan:" + sku, "meta" +} + +func hasActiveOrPending(subs []*entities.SubscriptionItem) (bool, error) { + for _, s := range subs { + st, err := getStatus(s.CancelledAt, s.ExpiresAt) + if err != nil { + return false, err + } + if st == dtos.SubStatusActive || st == dtos.SubStatusPending { + return true, nil + } + } + return false, nil +} + +func asString(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + return fmt.Sprintf("%v", v) +} + +func userSubKey(userID, subID string) (pk, sk string) { + return "user:" + userID, "sub:" + subID +} + +func parseMetadataAttributes(m map[string]any) types.AttributeValue { + av, err := attributevalue.Marshal(m) + if err != nil { + return &types.AttributeValueMemberM{Value: map[string]types.AttributeValue{}} + } + return av +} diff --git a/app/go/src/main.go b/app/go/src/main.go index 590b390..abddd8a 100755 --- a/app/go/src/main.go +++ b/app/go/src/main.go @@ -2,22 +2,141 @@ package main import ( "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strings" + "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" + "github.com/aws/aws-sdk-go-v2/config" + ddb "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/fenderdigital/fds-aws-coding-exercise/src/dtos" + "github.com/fenderdigital/fds-aws-coding-exercise/src/handlers" ) -type Response struct { - StatusCode int `json:"statusCode"` - Body string `json:"body"` +type LambdaHandler struct { + apiHandler *handlers.ApiHandler } -func handler(ctx context.Context, event interface{}) (Response, error) { - return Response{ - StatusCode: 200, - Body: "Hello from Go Lambda!", - }, nil +func NewLambdaHandler(ctx context.Context) (*LambdaHandler, error) { + tableName := os.Getenv("DDB_TABLE") + if tableName == "" { + return nil, fmt.Errorf("DDB_TABLE is required") + } + + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("load aws config: %w", err) + } + + return &LambdaHandler{apiHandler: handlers.NewApiHandler(tableName, ddb.NewFromConfig(cfg))}, nil +} + +func (lh *LambdaHandler) handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { + switch { + case req.HTTPMethod == "GET" && strings.HasPrefix(req.Path, "/api/v1/subscriptions/"): + userID := req.PathParameters["userId"] + if userID == "" { + return badRequest("missing userId"), nil + } + return lh.handleGetSubscription(ctx, userID) + case req.HTTPMethod == "POST" && req.Path == "/api/v1/webhooks/subscriptions": + var subEventReq dtos.SubscriptionRequest + if err := json.Unmarshal([]byte(req.Body), &subEventReq); err != nil { + return badRequest(err.Error()), nil + } + switch subEventReq.EventType { + case "subscription.created": + return lh.handleCreateSubscription(ctx, &subEventReq) + case "subscription.renewed": + return lh.handleRenewSubscription(ctx, &subEventReq) + case "subscription.cancelled": + return lh.handleCancelSubscription(ctx, &subEventReq) + default: + return badRequest("unknown event type"), nil + } + } + + return notFound("route not found"), nil +} + +func (lh *LambdaHandler) handleGetSubscription(ctx context.Context, userID string) (events.APIGatewayProxyResponse, error) { + subs, err := lh.apiHandler.GetUserSubs(ctx, userID) + if err != nil { + return serverErr(err), nil + } + + return parseJSON(http.StatusOK, subs) +} + +func (lh *LambdaHandler) handleCreateSubscription(ctx context.Context, req *dtos.SubscriptionRequest) (events.APIGatewayProxyResponse, error) { + err := lh.apiHandler.CreateUserSub(ctx, req) + if err != nil { + return serverErr(err), nil + } + return parseJSON(http.StatusCreated, map[string]string{"status": "created"}) +} + +func (lh *LambdaHandler) handleRenewSubscription(ctx context.Context, req *dtos.SubscriptionRequest) (events.APIGatewayProxyResponse, error) { + err := lh.apiHandler.RenewUserSub(ctx, req) + if err != nil { + return serverErr(err), nil + } + + return parseJSON(http.StatusOK, map[string]string{"status": "renewed"}) +} + +func (lh *LambdaHandler) handleCancelSubscription(ctx context.Context, req *dtos.SubscriptionRequest) (events.APIGatewayProxyResponse, error) { + err := lh.apiHandler.CancelUserSub(ctx, req) + if err != nil { + return serverErr(err), nil + } + return parseJSON(http.StatusOK, map[string]string{"status": "cancelled"}) } func main() { - lambda.Start(handler) + ctx := context.Background() + lHandler, err := NewLambdaHandler(ctx) + if err != nil { + log.Fatal(err) + } + lambda.Start(lHandler.handler) +} + +func parseJSON(code int, v any) (events.APIGatewayProxyResponse, error) { + b, err := json.Marshal(v) + if err != nil { + return events.APIGatewayProxyResponse{}, fmt.Errorf("failed to marshal JSON: %w", err) + } + + return events.APIGatewayProxyResponse{ + StatusCode: code, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: string(b), + }, nil +} + +func badRequest(msg string) events.APIGatewayProxyResponse { + return events.APIGatewayProxyResponse{StatusCode: 400, Body: fmt.Sprintf(`{"error":"%s"}`, msg)} +} + +func serverErr(err error) events.APIGatewayProxyResponse { + // Not configuring log libraries for now + log.Printf("server error: %v", err) + return events.APIGatewayProxyResponse{StatusCode: 500, Body: `{"error":"internal error"}`} +} + +func notFound(msg string) events.APIGatewayProxyResponse { + return events.APIGatewayProxyResponse{StatusCode: 404, Body: fmt.Sprintf(`{"error":"%s"}`, msg)} +} + +func requestErr(err error) events.APIGatewayProxyResponse { + // Maybe not the best error handling for now, update later to use errors.Is/errors.As if possible :) + if strings.Contains(err.Error(), "active/pending") { + return events.APIGatewayProxyResponse{StatusCode: 409, Body: fmt.Sprintf(`{"error":"%s"}`, err.Error())} + } + return events.APIGatewayProxyResponse{StatusCode: 422, Body: fmt.Sprintf(`{"error":"%s"}`, err.Error())} }