From 56659e46395892bcc056d4b498feeb23f5c81333 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 Aug 2025 20:17:44 +0000 Subject: [PATCH 1/4] Initial plan From ba1794fe816093a67740aff9f542b9684d9e1f35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 Aug 2025 20:22:12 +0000 Subject: [PATCH 2/4] Initial analysis of Stripe integration requirements Co-authored-by: Trubador <9870550+Trubador@users.noreply.github.com> --- Code/AppBlueprint/Directory.Build.props | 2 +- global.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Code/AppBlueprint/Directory.Build.props b/Code/AppBlueprint/Directory.Build.props index 8c2b4b2..dcf2d92 100644 --- a/Code/AppBlueprint/Directory.Build.props +++ b/Code/AppBlueprint/Directory.Build.props @@ -2,7 +2,7 @@ - net9.0 + net8.0 enable enable diff --git a/global.json b/global.json index 2c21d6b..177ad0d 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.300" + "version": "8.0.119" }, "msbuild-sdks": { "Aspire.AppHost.Sdk": "9.2.1" From 22a4570a5248c3e71621b2a9aaa7590c4d48d2f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 Aug 2025 20:27:07 +0000 Subject: [PATCH 3/4] Implement complete Stripe integration with services, controllers, and tests Co-authored-by: Trubador <9870550+Trubador@users.noreply.github.com> --- .../ApiControllers/PaymentControllerTests.cs | 196 +++++++++ .../StripeSubscriptionServiceTests.cs | 129 ++++++ Code/AppBlueprint/Directory.Build.props | 2 +- .../Payment/Requests/CreateCustomerRequest.cs | 9 + .../Payment/Requests/SubscriptionRequests.cs | 12 + .../Payment/Responses/PaymentResponses.cs | 31 ++ .../Extensions/PaymentServiceExtensions.cs | 22 + .../Services/StripeSubscriptionService.cs | 140 +++++-- .../Controllers/Baseline/PaymentController.cs | 381 ++++++++++++++---- .../ApplicationBuilderExtensions.cs | 4 + global.json | 2 +- 11 files changed, 811 insertions(+), 117 deletions(-) create mode 100644 Code/AppBlueprint/AppBlueprint.Tests/ApiControllers/PaymentControllerTests.cs create mode 100644 Code/AppBlueprint/AppBlueprint.Tests/Infrastructure/StripeSubscriptionServiceTests.cs create mode 100644 Code/AppBlueprint/Shared-Modules/AppBlueprint.Contracts/Baseline/Payment/Requests/CreateCustomerRequest.cs create mode 100644 Code/AppBlueprint/Shared-Modules/AppBlueprint.Contracts/Baseline/Payment/Requests/SubscriptionRequests.cs create mode 100644 Code/AppBlueprint/Shared-Modules/AppBlueprint.Contracts/Baseline/Payment/Responses/PaymentResponses.cs create mode 100644 Code/AppBlueprint/Shared-Modules/AppBlueprint.Infrastructure/Extensions/PaymentServiceExtensions.cs diff --git a/Code/AppBlueprint/AppBlueprint.Tests/ApiControllers/PaymentControllerTests.cs b/Code/AppBlueprint/AppBlueprint.Tests/ApiControllers/PaymentControllerTests.cs new file mode 100644 index 0000000..bc4f742 --- /dev/null +++ b/Code/AppBlueprint/AppBlueprint.Tests/ApiControllers/PaymentControllerTests.cs @@ -0,0 +1,196 @@ +using AppBlueprint.Presentation.ApiModule.Controllers.Baseline; +using AppBlueprint.Infrastructure.Services; +using AppBlueprint.Contracts.Baseline.Payment.Requests; +using AppBlueprint.Contracts.Baseline.Payment.Responses; +using AppBlueprint.Infrastructure.DatabaseContexts.Baseline.Entities.Customer; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Moq; +using TUnit; +using Stripe; + +namespace AppBlueprint.Tests.ApiControllers; + +public class PaymentControllerTests +{ + private readonly Mock _stripeServiceMock; + private readonly Mock> _loggerMock; + private readonly PaymentController _controller; + + public PaymentControllerTests() + { + _stripeServiceMock = new Mock(); + _loggerMock = new Mock>(); + _controller = new PaymentController(_stripeServiceMock.Object, _loggerMock.Object); + } + + [Test] + public async Task CreateCustomer_ShouldReturnCreated_WhenValidRequest() + { + // Arrange + var request = new CreateCustomerRequest + { + Email = "test@example.com", + PaymentMethodId = "pm_123456" + }; + + var customerEntity = new CustomerEntity + { + Id = "cus_123456", + Email = "test@example.com", + Name = "Test Customer", + PhoneNumber = "", + CreatedAt = DateTime.UtcNow + }; + + _stripeServiceMock.Setup(s => s.CreateCustomerAsync(request.Email, request.PaymentMethodId)) + .ReturnsAsync(customerEntity); + + // Act + var result = await _controller.CreateCustomer(request, CancellationToken.None); + + // Assert + result.Should().BeOfType(); + var createdResult = result as CreatedAtActionResult; + createdResult!.Value.Should().BeOfType(); + + var response = createdResult.Value as CustomerResponse; + response!.Id.Should().Be(customerEntity.Id); + response.Email.Should().Be(customerEntity.Email); + } + + [Test] + public async Task CreateCustomer_ShouldReturnBadRequest_WhenServiceReturnsNull() + { + // Arrange + var request = new CreateCustomerRequest + { + Email = "test@example.com", + PaymentMethodId = "pm_123456" + }; + + _stripeServiceMock.Setup(s => s.CreateCustomerAsync(request.Email, request.PaymentMethodId)) + .ReturnsAsync((CustomerEntity?)null); + + // Act + var result = await _controller.CreateCustomer(request, CancellationToken.None); + + // Assert + result.Should().BeOfType(); + var badRequestResult = result as BadRequestObjectResult; + badRequestResult!.Value.Should().BeOfType(); + } + + [Test] + public async Task CreateCustomer_ShouldReturnBadRequest_WhenArgumentException() + { + // Arrange + var request = new CreateCustomerRequest + { + Email = "", + PaymentMethodId = "pm_123456" + }; + + _stripeServiceMock.Setup(s => s.CreateCustomerAsync(request.Email, request.PaymentMethodId)) + .ThrowsAsync(new ArgumentException("Email cannot be null or empty")); + + // Act + var result = await _controller.CreateCustomer(request, CancellationToken.None); + + // Assert + result.Should().BeOfType(); + var badRequestResult = result as BadRequestObjectResult; + badRequestResult!.Value.Should().BeOfType(); + } + + [Test] + public async Task CreateSubscription_ShouldReturnCreated_WhenValidRequest() + { + // Arrange + var request = new CreateSubscriptionRequest + { + CustomerId = "cus_123456", + PriceId = "price_123456" + }; + + var stripeSubscription = CreateMockSubscription("sub_123456", "cus_123456", "price_123456"); + + _stripeServiceMock.Setup(s => s.CreateSubscriptionAsync(request.CustomerId, request.PriceId)) + .ReturnsAsync(stripeSubscription); + + // Act + var result = await _controller.CreateSubscription(request, CancellationToken.None); + + // Assert + result.Should().BeOfType(); + var createdResult = result as CreatedAtActionResult; + createdResult!.Value.Should().BeOfType(); + + var response = createdResult.Value as SubscriptionResponse; + response!.Id.Should().Be(stripeSubscription.Id); + response.CustomerId.Should().Be(stripeSubscription.CustomerId); + } + + [Test] + public async Task CancelSubscription_ShouldReturnOk_WhenValidRequest() + { + // Arrange + var request = new CancelSubscriptionRequest + { + SubscriptionId = "sub_123456" + }; + + var stripeSubscription = CreateMockSubscription("sub_123456", "cus_123456", "price_123456"); + stripeSubscription.Status = "canceled"; + stripeSubscription.CanceledAt = DateTime.UtcNow; + + _stripeServiceMock.Setup(s => s.CancelSubscriptionAsync(request.SubscriptionId)) + .ReturnsAsync(stripeSubscription); + + // Act + var result = await _controller.CancelSubscription(request, CancellationToken.None); + + // Assert + result.Should().BeOfType(); + var okResult = result as OkObjectResult; + okResult!.Value.Should().BeOfType(); + + var response = okResult.Value as SubscriptionResponse; + response!.Id.Should().Be(stripeSubscription.Id); + response.Status.Should().Be("canceled"); + } + + private static Subscription CreateMockSubscription(string id, string customerId, string priceId) + { + var subscription = new Subscription + { + Id = id, + CustomerId = customerId, + Status = "active", + Created = DateTime.UtcNow, + CurrentPeriodStart = DateTime.UtcNow, + CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) + }; + + // Mock subscription items + var price = new Price + { + Id = priceId, + UnitAmount = 1999, + Currency = "usd" + }; + + var subscriptionItem = new SubscriptionItem + { + Price = price + }; + + subscription.Items = new StripeList + { + Data = new List { subscriptionItem } + }; + + return subscription; + } +} \ No newline at end of file diff --git a/Code/AppBlueprint/AppBlueprint.Tests/Infrastructure/StripeSubscriptionServiceTests.cs b/Code/AppBlueprint/AppBlueprint.Tests/Infrastructure/StripeSubscriptionServiceTests.cs new file mode 100644 index 0000000..b683e16 --- /dev/null +++ b/Code/AppBlueprint/AppBlueprint.Tests/Infrastructure/StripeSubscriptionServiceTests.cs @@ -0,0 +1,129 @@ +using AppBlueprint.Infrastructure.Services; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Moq; +using TUnit; +using Stripe; + +namespace AppBlueprint.Tests.Infrastructure; + +public class StripeSubscriptionServiceTests +{ + private readonly Mock _configurationMock; + private readonly StripeSubscriptionService _stripeSubscriptionService; + private const string TestApiKey = "sk_test_123456789"; + + public StripeSubscriptionServiceTests() + { + _configurationMock = new Mock(); + _configurationMock.Setup(c => c.GetConnectionString("StripeApiKey")) + .Returns(TestApiKey); + + _stripeSubscriptionService = new StripeSubscriptionService(_configurationMock.Object); + } + + [Test] + public void Constructor_ShouldThrowException_WhenApiKeyIsNull() + { + // Arrange + var configMock = new Mock(); + configMock.Setup(c => c.GetConnectionString("StripeApiKey")) + .Returns((string?)null); + + // Act & Assert + var act = () => new StripeSubscriptionService(configMock.Object); + act.Should().Throw() + .WithMessage("StripeApiKey connection string is not configured."); + } + + [Test] + public async Task CreateCustomerAsync_ShouldThrowArgumentException_WhenEmailIsNullOrEmpty() + { + // Act & Assert + var act = async () => await _stripeSubscriptionService.CreateCustomerAsync("", "pm_123"); + await act.Should().ThrowAsync() + .WithMessage("Email cannot be null or empty*"); + + var act2 = async () => await _stripeSubscriptionService.CreateCustomerAsync(null!, "pm_123"); + await act2.Should().ThrowAsync() + .WithMessage("Email cannot be null or empty*"); + } + + [Test] + public async Task CreateCustomerAsync_ShouldThrowArgumentException_WhenPaymentMethodIdIsNullOrEmpty() + { + // Act & Assert + var act = async () => await _stripeSubscriptionService.CreateCustomerAsync("test@test.com", ""); + await act.Should().ThrowAsync() + .WithMessage("Payment method ID cannot be null or empty*"); + + var act2 = async () => await _stripeSubscriptionService.CreateCustomerAsync("test@test.com", null!); + await act2.Should().ThrowAsync() + .WithMessage("Payment method ID cannot be null or empty*"); + } + + [Test] + public async Task CreateSubscriptionAsync_ShouldThrowArgumentException_WhenCustomerIdIsNullOrEmpty() + { + // Act & Assert + var act = async () => await _stripeSubscriptionService.CreateSubscriptionAsync("", "price_123"); + await act.Should().ThrowAsync() + .WithMessage("Customer ID cannot be null or empty*"); + + var act2 = async () => await _stripeSubscriptionService.CreateSubscriptionAsync(null!, "price_123"); + await act2.Should().ThrowAsync() + .WithMessage("Customer ID cannot be null or empty*"); + } + + [Test] + public async Task CreateSubscriptionAsync_ShouldThrowArgumentException_WhenPriceIdIsNullOrEmpty() + { + // Act & Assert + var act = async () => await _stripeSubscriptionService.CreateSubscriptionAsync("cus_123", ""); + await act.Should().ThrowAsync() + .WithMessage("Price ID cannot be null or empty*"); + + var act2 = async () => await _stripeSubscriptionService.CreateSubscriptionAsync("cus_123", null!); + await act2.Should().ThrowAsync() + .WithMessage("Price ID cannot be null or empty*"); + } + + [Test] + public async Task GetSubscriptionAsync_ShouldThrowArgumentException_WhenSubscriptionIdIsNullOrEmpty() + { + // Act & Assert + var act = async () => await _stripeSubscriptionService.GetSubscriptionAsync(""); + await act.Should().ThrowAsync() + .WithMessage("Subscription ID cannot be null or empty*"); + + var act2 = async () => await _stripeSubscriptionService.GetSubscriptionAsync(null!); + await act2.Should().ThrowAsync() + .WithMessage("Subscription ID cannot be null or empty*"); + } + + [Test] + public async Task CancelSubscriptionAsync_ShouldThrowArgumentException_WhenSubscriptionIdIsNullOrEmpty() + { + // Act & Assert + var act = async () => await _stripeSubscriptionService.CancelSubscriptionAsync(""); + await act.Should().ThrowAsync() + .WithMessage("Subscription ID cannot be null or empty*"); + + var act2 = async () => await _stripeSubscriptionService.CancelSubscriptionAsync(null!); + await act2.Should().ThrowAsync() + .WithMessage("Subscription ID cannot be null or empty*"); + } + + [Test] + public async Task GetCustomerSubscriptionsAsync_ShouldThrowArgumentException_WhenCustomerIdIsNullOrEmpty() + { + // Act & Assert + var act = async () => await _stripeSubscriptionService.GetCustomerSubscriptionsAsync(""); + await act.Should().ThrowAsync() + .WithMessage("Customer ID cannot be null or empty*"); + + var act2 = async () => await _stripeSubscriptionService.GetCustomerSubscriptionsAsync(null!); + await act2.Should().ThrowAsync() + .WithMessage("Customer ID cannot be null or empty*"); + } +} \ No newline at end of file diff --git a/Code/AppBlueprint/Directory.Build.props b/Code/AppBlueprint/Directory.Build.props index dcf2d92..8c2b4b2 100644 --- a/Code/AppBlueprint/Directory.Build.props +++ b/Code/AppBlueprint/Directory.Build.props @@ -2,7 +2,7 @@ - net8.0 + net9.0 enable enable diff --git a/Code/AppBlueprint/Shared-Modules/AppBlueprint.Contracts/Baseline/Payment/Requests/CreateCustomerRequest.cs b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Contracts/Baseline/Payment/Requests/CreateCustomerRequest.cs new file mode 100644 index 0000000..5de8c4a --- /dev/null +++ b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Contracts/Baseline/Payment/Requests/CreateCustomerRequest.cs @@ -0,0 +1,9 @@ +namespace AppBlueprint.Contracts.Baseline.Payment.Requests; + +public class CreateCustomerRequest +{ + public required string Email { get; set; } + public required string PaymentMethodId { get; set; } + public string? Name { get; set; } + public string? Phone { get; set; } +} \ No newline at end of file diff --git a/Code/AppBlueprint/Shared-Modules/AppBlueprint.Contracts/Baseline/Payment/Requests/SubscriptionRequests.cs b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Contracts/Baseline/Payment/Requests/SubscriptionRequests.cs new file mode 100644 index 0000000..530df82 --- /dev/null +++ b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Contracts/Baseline/Payment/Requests/SubscriptionRequests.cs @@ -0,0 +1,12 @@ +namespace AppBlueprint.Contracts.Baseline.Payment.Requests; + +public class CreateSubscriptionRequest +{ + public required string CustomerId { get; set; } + public required string PriceId { get; set; } +} + +public class CancelSubscriptionRequest +{ + public required string SubscriptionId { get; set; } +} \ No newline at end of file diff --git a/Code/AppBlueprint/Shared-Modules/AppBlueprint.Contracts/Baseline/Payment/Responses/PaymentResponses.cs b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Contracts/Baseline/Payment/Responses/PaymentResponses.cs new file mode 100644 index 0000000..edb1f30 --- /dev/null +++ b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Contracts/Baseline/Payment/Responses/PaymentResponses.cs @@ -0,0 +1,31 @@ +namespace AppBlueprint.Contracts.Baseline.Payment.Responses; + +public class CustomerResponse +{ + public required string Id { get; set; } + public required string Email { get; set; } + public string? Name { get; set; } + public string? Phone { get; set; } + public DateTime CreatedAt { get; set; } +} + +public class SubscriptionResponse +{ + public required string Id { get; set; } + public required string CustomerId { get; set; } + public required string Status { get; set; } + public required string PriceId { get; set; } + public long Amount { get; set; } + public required string Currency { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? CanceledAt { get; set; } + public DateTime? CurrentPeriodStart { get; set; } + public DateTime? CurrentPeriodEnd { get; set; } +} + +public class SubscriptionListResponse +{ + public required List Subscriptions { get; set; } + public bool HasMore { get; set; } + public int TotalCount { get; set; } +} \ No newline at end of file diff --git a/Code/AppBlueprint/Shared-Modules/AppBlueprint.Infrastructure/Extensions/PaymentServiceExtensions.cs b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Infrastructure/Extensions/PaymentServiceExtensions.cs new file mode 100644 index 0000000..10a758a --- /dev/null +++ b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Infrastructure/Extensions/PaymentServiceExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; +using AppBlueprint.Infrastructure.Services; + +namespace AppBlueprint.Infrastructure.Extensions; + +/// +/// Extension methods for registering payment services in dependency injection container. +/// +public static class PaymentServiceExtensions +{ + /// + /// Registers payment-related services including Stripe integration. + /// + /// The service collection to add services to. + /// The service collection for method chaining. + public static IServiceCollection AddPaymentServices(this IServiceCollection services) + { + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/Code/AppBlueprint/Shared-Modules/AppBlueprint.Infrastructure/Services/StripeSubscriptionService.cs b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Infrastructure/Services/StripeSubscriptionService.cs index 465226e..bca4377 100644 --- a/Code/AppBlueprint/Shared-Modules/AppBlueprint.Infrastructure/Services/StripeSubscriptionService.cs +++ b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Infrastructure/Services/StripeSubscriptionService.cs @@ -16,41 +16,127 @@ public StripeSubscriptionService(IConfiguration configuration) StripeConfiguration.ApiKey = _stripeApiKey; } - public CustomerEntity? CreateCustomer(string email, string paymentMethodId) + public async Task CreateCustomerAsync(string email, string paymentMethodId) { - var customerOptions = new CustomerCreateOptions + if (string.IsNullOrEmpty(email)) + throw new ArgumentException("Email cannot be null or empty", nameof(email)); + + if (string.IsNullOrEmpty(paymentMethodId)) + throw new ArgumentException("Payment method ID cannot be null or empty", nameof(paymentMethodId)); + + try { - Email = email, - PaymentMethod = paymentMethodId, - InvoiceSettings = new CustomerInvoiceSettingsOptions + var customerOptions = new CustomerCreateOptions { - DefaultPaymentMethod = paymentMethodId - } - }; - var customerService = new CustomerService(); - - // TODO: Implement actual customer creation logic - // For now, returning null is acceptable since return type is nullable - return null; - //return customerService.Create(customerOptions); + Email = email, + PaymentMethod = paymentMethodId, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = paymentMethodId + } + }; + var customerService = new CustomerService(); + var stripeCustomer = await customerService.CreateAsync(customerOptions); + + // Map Stripe customer to our entity + return new CustomerEntity + { + Id = stripeCustomer.Id, + Name = stripeCustomer.Name ?? email, + Email = stripeCustomer.Email ?? email, + PhoneNumber = stripeCustomer.Phone ?? string.Empty, + CreatedAt = DateTime.UtcNow + }; + } + catch (StripeException ex) + { + // Log the error but don't expose Stripe details + throw new InvalidOperationException($"Failed to create customer: {ex.Message}", ex); + } } - public Subscription CreateSubscription(string customerId, string priceId) + public async Task CreateSubscriptionAsync(string customerId, string priceId) { - // Create a subscription - var subscriptionOptions = new SubscriptionCreateOptions + if (string.IsNullOrEmpty(customerId)) + throw new ArgumentException("Customer ID cannot be null or empty", nameof(customerId)); + + if (string.IsNullOrEmpty(priceId)) + throw new ArgumentException("Price ID cannot be null or empty", nameof(priceId)); + + try { - Customer = customerId, - Items = new List + // Create a subscription + var subscriptionOptions = new SubscriptionCreateOptions { - new() + Customer = customerId, + Items = new List { - Price = priceId // Price ID from your Stripe Dashboard - } - }, - Expand = new List { "latest_invoice.payment_intent" } - }; - var subscriptionService = new SubscriptionService(); - return subscriptionService.Create(subscriptionOptions); + new() + { + Price = priceId // Price ID from your Stripe Dashboard + } + }, + Expand = new List { "latest_invoice.payment_intent" } + }; + var subscriptionService = new SubscriptionService(); + return await subscriptionService.CreateAsync(subscriptionOptions); + } + catch (StripeException ex) + { + throw new InvalidOperationException($"Failed to create subscription: {ex.Message}", ex); + } + } + + public async Task GetSubscriptionAsync(string subscriptionId) + { + if (string.IsNullOrEmpty(subscriptionId)) + throw new ArgumentException("Subscription ID cannot be null or empty", nameof(subscriptionId)); + + try + { + var subscriptionService = new SubscriptionService(); + return await subscriptionService.GetAsync(subscriptionId); + } + catch (StripeException ex) + { + throw new InvalidOperationException($"Failed to retrieve subscription: {ex.Message}", ex); + } + } + + public async Task CancelSubscriptionAsync(string subscriptionId) + { + if (string.IsNullOrEmpty(subscriptionId)) + throw new ArgumentException("Subscription ID cannot be null or empty", nameof(subscriptionId)); + + try + { + var subscriptionService = new SubscriptionService(); + return await subscriptionService.CancelAsync(subscriptionId, new SubscriptionCancelOptions()); + } + catch (StripeException ex) + { + throw new InvalidOperationException($"Failed to cancel subscription: {ex.Message}", ex); + } + } + + public async Task> GetCustomerSubscriptionsAsync(string customerId) + { + if (string.IsNullOrEmpty(customerId)) + throw new ArgumentException("Customer ID cannot be null or empty", nameof(customerId)); + + try + { + var subscriptionService = new SubscriptionService(); + var options = new SubscriptionListOptions + { + Customer = customerId, + Status = "all" + }; + return await subscriptionService.ListAsync(options); + } + catch (StripeException ex) + { + throw new InvalidOperationException($"Failed to retrieve customer subscriptions: {ex.Message}", ex); + } } } diff --git a/Code/AppBlueprint/Shared-Modules/AppBlueprint.Presentation.ApiModule/Controllers/Baseline/PaymentController.cs b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Presentation.ApiModule/Controllers/Baseline/PaymentController.cs index f7a1bcb..cd2f97d 100644 --- a/Code/AppBlueprint/Shared-Modules/AppBlueprint.Presentation.ApiModule/Controllers/Baseline/PaymentController.cs +++ b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Presentation.ApiModule/Controllers/Baseline/PaymentController.cs @@ -1,90 +1,295 @@ -// using Microsoft.AspNetCore.Authorization; -// using Microsoft.AspNetCore.Http; -// using Microsoft.AspNetCore.Mvc; -// using Microsoft.Extensions.Configuration; -// using Microsoft.Extensions.Logging; -// using Newtonsoft.Json; -// using Stripe; -// -// namespace AppBlueprint.Presentation.ApiModule.Controllers.Baseline; -// -// [Authorize (Roles = Roles.CustomerAdmin)] -// [ApiController] -// [Route("api/payment")] -// [Produces("application/json")] -// public class PaymentController : BaseController -// { -// private readonly IConfiguration _configuration; -// private readonly ILogger _logger; -// -// public PaymentController(ILogger logger): base(configuration) -// { -// _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); -// _logger = logger ?? throw new ArgumentNullException(nameof(logger)); -// -// } -// -// /// -// /// Retrieves subscriptions. -// /// -// [HttpGet("get-subscriptions")] -// [ProducesResponseType(StatusCodes.Status200OK)] -// public async Task GetSubscriptions(CancellationToken cancellationToken) -// { -// // Simulate retrieving subscriptions (replace with real implementation) -// return Ok("Subscriptions...."); -// } -// -// /// -// /// Creates a subscription for the customer. -// /// -// [HttpPost("create-subscription")] -// [ProducesResponseType(StatusCodes.Status200OK)] -// public async Task CreateSubscription(CancellationToken cancellationToken) -// { -// var options = new SubscriptionCreateOptions -// { -// Customer = "cus_9s6XGagagagagDTHzA66Po", -// Items = new List -// { -// new SubscriptionItemOptions -// { -// Price = "subscription_plan_price_id", -// }, -// } -// }; -// var service = new SubscriptionService(); -// service.Create(options); -// -// return Ok("Subscription created successfully."); -// } -// -// /// -// /// Cancels the subscription with the specified ID. -// /// -// /// Request containing subscription ID. -// [HttpPost("cancel-subscription")] -// [ProducesResponseType(typeof(Subscription), StatusCodes.Status200OK)] -// [ProducesResponseType(StatusCodes.Status400BadRequest)] -// public ActionResult CancelSubscription([FromBody] CancelSubscriptionRequest req) -// { -// if (string.IsNullOrEmpty(req.Subscription)) -// { -// return BadRequest("Subscription ID is required."); -// } -// -// var service = new SubscriptionService(); -// var subscription = service.Cancel(req.Subscription, null); -// return Ok(subscription); -// } -// -// -// } -// -// public class CancelSubscriptionRequest -// { -// [JsonProperty("subscriptionId")] -// public string Subscription { get; set; } -// } +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using AppBlueprint.Infrastructure.Services; +using AppBlueprint.Contracts.Baseline.Payment.Requests; +using AppBlueprint.Contracts.Baseline.Payment.Responses; +using Stripe; namespace AppBlueprint.Presentation.ApiModule.Controllers.Baseline; + +[ApiController] +[Route("api/payment")] +[Produces("application/json")] +public class PaymentController : ControllerBase +{ + private readonly StripeSubscriptionService _stripeSubscriptionService; + private readonly ILogger _logger; + + public PaymentController( + StripeSubscriptionService stripeSubscriptionService, + ILogger logger) + { + _stripeSubscriptionService = stripeSubscriptionService ?? throw new ArgumentNullException(nameof(stripeSubscriptionService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Creates a new customer in Stripe. + /// + /// Customer creation request. + /// Cancellation token. + /// Created customer information. + [HttpPost("create-customer")] + [ProducesResponseType(typeof(CustomerResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task CreateCustomer([FromBody] CreateCustomerRequest request, CancellationToken cancellationToken) + { + try + { + var customer = await _stripeSubscriptionService.CreateCustomerAsync(request.Email, request.PaymentMethodId); + + if (customer is null) + { + return BadRequest(new ProblemDetails + { + Title = "Customer Creation Failed", + Detail = "Failed to create customer in payment system" + }); + } + + var response = new CustomerResponse + { + Id = customer.Id, + Email = customer.Email, + Name = customer.Name, + Phone = customer.PhoneNumber, + CreatedAt = customer.CreatedAt + }; + + return CreatedAtAction(nameof(CreateCustomer), new { customerId = customer.Id }, response); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Invalid request parameters for customer creation"); + return BadRequest(new ProblemDetails + { + Title = "Invalid Request", + Detail = ex.Message + }); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "Failed to create customer"); + return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails + { + Title = "Customer Creation Failed", + Detail = "An error occurred while creating the customer" + }); + } + } + + /// + /// Creates a subscription for a customer. + /// + /// Subscription creation request. + /// Cancellation token. + /// Created subscription information. + [HttpPost("create-subscription")] + [ProducesResponseType(typeof(SubscriptionResponse), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task CreateSubscription([FromBody] CreateSubscriptionRequest request, CancellationToken cancellationToken) + { + try + { + var subscription = await _stripeSubscriptionService.CreateSubscriptionAsync(request.CustomerId, request.PriceId); + + var response = new SubscriptionResponse + { + Id = subscription.Id, + CustomerId = subscription.CustomerId, + Status = subscription.Status, + PriceId = subscription.Items.Data.First().Price.Id, + Amount = subscription.Items.Data.First().Price.UnitAmount ?? 0, + Currency = subscription.Items.Data.First().Price.Currency, + CreatedAt = subscription.Created, + CurrentPeriodStart = subscription.CurrentPeriodStart, + CurrentPeriodEnd = subscription.CurrentPeriodEnd + }; + + return CreatedAtAction(nameof(GetSubscription), new { subscriptionId = subscription.Id }, response); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Invalid request parameters for subscription creation"); + return BadRequest(new ProblemDetails + { + Title = "Invalid Request", + Detail = ex.Message + }); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "Failed to create subscription"); + return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails + { + Title = "Subscription Creation Failed", + Detail = "An error occurred while creating the subscription" + }); + } + } + + /// + /// Retrieves a specific subscription. + /// + /// The subscription ID. + /// Cancellation token. + /// Subscription information. + [HttpGet("subscription/{subscriptionId}")] + [ProducesResponseType(typeof(SubscriptionResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task GetSubscription(string subscriptionId, CancellationToken cancellationToken) + { + try + { + var subscription = await _stripeSubscriptionService.GetSubscriptionAsync(subscriptionId); + + var response = new SubscriptionResponse + { + Id = subscription.Id, + CustomerId = subscription.CustomerId, + Status = subscription.Status, + PriceId = subscription.Items.Data.First().Price.Id, + Amount = subscription.Items.Data.First().Price.UnitAmount ?? 0, + Currency = subscription.Items.Data.First().Price.Currency, + CreatedAt = subscription.Created, + CanceledAt = subscription.CanceledAt, + CurrentPeriodStart = subscription.CurrentPeriodStart, + CurrentPeriodEnd = subscription.CurrentPeriodEnd + }; + + return Ok(response); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Invalid subscription ID: {SubscriptionId}", subscriptionId); + return BadRequest(new ProblemDetails + { + Title = "Invalid Request", + Detail = ex.Message + }); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "Failed to retrieve subscription: {SubscriptionId}", subscriptionId); + return NotFound(new ProblemDetails + { + Title = "Subscription Not Found", + Detail = "The requested subscription could not be found" + }); + } + } + + /// + /// Retrieves subscriptions for a customer. + /// + /// The customer ID. + /// Cancellation token. + /// List of customer subscriptions. + [HttpGet("customer/{customerId}/subscriptions")] + [ProducesResponseType(typeof(SubscriptionListResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task GetCustomerSubscriptions(string customerId, CancellationToken cancellationToken) + { + try + { + var subscriptions = await _stripeSubscriptionService.GetCustomerSubscriptionsAsync(customerId); + + var response = new SubscriptionListResponse + { + Subscriptions = subscriptions.Data.Select(s => new SubscriptionResponse + { + Id = s.Id, + CustomerId = s.CustomerId, + Status = s.Status, + PriceId = s.Items.Data.First().Price.Id, + Amount = s.Items.Data.First().Price.UnitAmount ?? 0, + Currency = s.Items.Data.First().Price.Currency, + CreatedAt = s.Created, + CanceledAt = s.CanceledAt, + CurrentPeriodStart = s.CurrentPeriodStart, + CurrentPeriodEnd = s.CurrentPeriodEnd + }).ToList(), + HasMore = subscriptions.HasMore, + TotalCount = subscriptions.Data.Count() + }; + + return Ok(response); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Invalid customer ID: {CustomerId}", customerId); + return BadRequest(new ProblemDetails + { + Title = "Invalid Request", + Detail = ex.Message + }); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "Failed to retrieve customer subscriptions: {CustomerId}", customerId); + return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails + { + Title = "Failed to Retrieve Subscriptions", + Detail = "An error occurred while retrieving customer subscriptions" + }); + } + } + + /// + /// Cancels a subscription. + /// + /// Cancellation request. + /// Cancellation token. + /// Canceled subscription information. + [HttpPost("cancel-subscription")] + [ProducesResponseType(typeof(SubscriptionResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task CancelSubscription([FromBody] CancelSubscriptionRequest request, CancellationToken cancellationToken) + { + try + { + var subscription = await _stripeSubscriptionService.CancelSubscriptionAsync(request.SubscriptionId); + + var response = new SubscriptionResponse + { + Id = subscription.Id, + CustomerId = subscription.CustomerId, + Status = subscription.Status, + PriceId = subscription.Items.Data.First().Price.Id, + Amount = subscription.Items.Data.First().Price.UnitAmount ?? 0, + Currency = subscription.Items.Data.First().Price.Currency, + CreatedAt = subscription.Created, + CanceledAt = subscription.CanceledAt, + CurrentPeriodStart = subscription.CurrentPeriodStart, + CurrentPeriodEnd = subscription.CurrentPeriodEnd + }; + + return Ok(response); + } + catch (ArgumentException ex) + { + _logger.LogWarning(ex, "Invalid request parameters for subscription cancellation"); + return BadRequest(new ProblemDetails + { + Title = "Invalid Request", + Detail = ex.Message + }); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "Failed to cancel subscription"); + return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails + { + Title = "Subscription Cancellation Failed", + Detail = "An error occurred while canceling the subscription" + }); + } + } +} diff --git a/Code/AppBlueprint/Shared-Modules/AppBlueprint.Presentation.ApiModule/Extensions/ApplicationBuilderExtensions.cs b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Presentation.ApiModule/Extensions/ApplicationBuilderExtensions.cs index ea424e3..f442e5d 100644 --- a/Code/AppBlueprint/Shared-Modules/AppBlueprint.Presentation.ApiModule/Extensions/ApplicationBuilderExtensions.cs +++ b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Presentation.ApiModule/Extensions/ApplicationBuilderExtensions.cs @@ -5,6 +5,7 @@ using AppBlueprint.Infrastructure.Repositories; using AppBlueprint.Infrastructure.Repositories.Interfaces; using AppBlueprint.Application.Interfaces.UnitOfWork; +using AppBlueprint.Infrastructure.Extensions; using Asp.Versioning; using Asp.Versioning.Routing; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -160,6 +161,9 @@ public static IServiceCollection AddAppBlueprintServices(this IServiceCollection services.AddScoped(); services.AddScoped(); + // Add payment services including Stripe integration + services.AddPaymentServices(); + AddDbContext(services, configuration); ConfigureApiVersioning(services); AddCors(services); diff --git a/global.json b/global.json index 177ad0d..2c21d6b 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.119" + "version": "9.0.300" }, "msbuild-sdks": { "Aspire.AppHost.Sdk": "9.2.1" From a8553a8376a6fcbe63f5345da7932437cc270448 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 Aug 2025 20:28:42 +0000 Subject: [PATCH 4/4] Add comprehensive documentation and configuration for Stripe integration Co-authored-by: Trubador <9870550+Trubador@users.noreply.github.com> --- .../AppBlueprint.Infrastructure/README.md | 54 +++++ .../PAYMENT_API.md | 206 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 Code/AppBlueprint/Shared-Modules/AppBlueprint.Presentation.ApiModule/PAYMENT_API.md diff --git a/Code/AppBlueprint/Shared-Modules/AppBlueprint.Infrastructure/README.md b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Infrastructure/README.md index f80f174..a8c0f62 100644 --- a/Code/AppBlueprint/Shared-Modules/AppBlueprint.Infrastructure/README.md +++ b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Infrastructure/README.md @@ -13,6 +13,60 @@ - Caching using Redis - Databases (PostgreSQL, SQL Server, NoSQL, Redis) - File/Object storage, eg. Azure Blob storage/Cloudflare R2 +- Payment Services (Stripe integration) + +## Payment Services + +### Stripe Integration + +The infrastructure layer includes comprehensive Stripe payment integration through the `StripeSubscriptionService` class. + +#### Features: +- Customer creation and management +- Subscription lifecycle management (create, retrieve, cancel) +- Customer subscription listing +- Comprehensive error handling and logging +- Async/await pattern implementation + +#### Configuration: +The Stripe API key should be configured in the connection strings section: +```json +{ + "ConnectionStrings": { + "StripeApiKey": "sk_test_your_stripe_secret_key_here" + } +} +``` + +#### Usage: +The service is automatically registered in the DI container and can be injected into controllers or other services: + +```csharp +public class PaymentController : ControllerBase +{ + private readonly StripeSubscriptionService _stripeService; + + public PaymentController(StripeSubscriptionService stripeService) + { + _stripeService = stripeService; + } + + // Use the service methods... +} +``` + +#### Available Methods: +- `CreateCustomerAsync(string email, string paymentMethodId)` - Creates a new Stripe customer +- `CreateSubscriptionAsync(string customerId, string priceId)` - Creates a subscription for a customer +- `GetSubscriptionAsync(string subscriptionId)` - Retrieves subscription details +- `CancelSubscriptionAsync(string subscriptionId)` - Cancels a subscription +- `GetCustomerSubscriptionsAsync(string customerId)` - Lists all subscriptions for a customer + +#### Error Handling: +All methods include comprehensive error handling: +- Parameter validation with `ArgumentException` +- Stripe API errors wrapped in `InvalidOperationException` +- Proper logging of errors without exposing sensitive details External systems Databases diff --git a/Code/AppBlueprint/Shared-Modules/AppBlueprint.Presentation.ApiModule/PAYMENT_API.md b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Presentation.ApiModule/PAYMENT_API.md new file mode 100644 index 0000000..de2bc09 --- /dev/null +++ b/Code/AppBlueprint/Shared-Modules/AppBlueprint.Presentation.ApiModule/PAYMENT_API.md @@ -0,0 +1,206 @@ +# Payment API Documentation + +## Overview +The Payment API provides endpoints for managing Stripe customers and subscriptions within the AppBlueprint application. + +## Base URL +``` +/api/payment +``` + +## Authentication +All endpoints require proper authentication. Authentication details depend on the application's configured authentication scheme. + +## Endpoints + +### Create Customer +Creates a new customer in Stripe. + +```http +POST /api/payment/create-customer +``` + +**Request Body:** +```json +{ + "email": "customer@example.com", + "paymentMethodId": "pm_1234567890", + "name": "John Doe", + "phone": "+1234567890" +} +``` + +**Response (201 Created):** +```json +{ + "id": "cus_1234567890", + "email": "customer@example.com", + "name": "John Doe", + "phone": "+1234567890", + "createdAt": "2024-01-15T10:30:00Z" +} +``` + +### Create Subscription +Creates a new subscription for a customer. + +```http +POST /api/payment/create-subscription +``` + +**Request Body:** +```json +{ + "customerId": "cus_1234567890", + "priceId": "price_1234567890" +} +``` + +**Response (201 Created):** +```json +{ + "id": "sub_1234567890", + "customerId": "cus_1234567890", + "status": "active", + "priceId": "price_1234567890", + "amount": 1999, + "currency": "usd", + "createdAt": "2024-01-15T10:30:00Z", + "currentPeriodStart": "2024-01-15T10:30:00Z", + "currentPeriodEnd": "2024-02-15T10:30:00Z" +} +``` + +### Get Subscription +Retrieves details of a specific subscription. + +```http +GET /api/payment/subscription/{subscriptionId} +``` + +**Response (200 OK):** +```json +{ + "id": "sub_1234567890", + "customerId": "cus_1234567890", + "status": "active", + "priceId": "price_1234567890", + "amount": 1999, + "currency": "usd", + "createdAt": "2024-01-15T10:30:00Z", + "canceledAt": null, + "currentPeriodStart": "2024-01-15T10:30:00Z", + "currentPeriodEnd": "2024-02-15T10:30:00Z" +} +``` + +### Get Customer Subscriptions +Retrieves all subscriptions for a specific customer. + +```http +GET /api/payment/customer/{customerId}/subscriptions +``` + +**Response (200 OK):** +```json +{ + "subscriptions": [ + { + "id": "sub_1234567890", + "customerId": "cus_1234567890", + "status": "active", + "priceId": "price_1234567890", + "amount": 1999, + "currency": "usd", + "createdAt": "2024-01-15T10:30:00Z", + "canceledAt": null, + "currentPeriodStart": "2024-01-15T10:30:00Z", + "currentPeriodEnd": "2024-02-15T10:30:00Z" + } + ], + "hasMore": false, + "totalCount": 1 +} +``` + +### Cancel Subscription +Cancels an existing subscription. + +```http +POST /api/payment/cancel-subscription +``` + +**Request Body:** +```json +{ + "subscriptionId": "sub_1234567890" +} +``` + +**Response (200 OK):** +```json +{ + "id": "sub_1234567890", + "customerId": "cus_1234567890", + "status": "canceled", + "priceId": "price_1234567890", + "amount": 1999, + "currency": "usd", + "createdAt": "2024-01-15T10:30:00Z", + "canceledAt": "2024-01-20T10:30:00Z", + "currentPeriodStart": "2024-01-15T10:30:00Z", + "currentPeriodEnd": "2024-02-15T10:30:00Z" +} +``` + +## Error Responses + +### 400 Bad Request +```json +{ + "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", + "title": "Invalid Request", + "detail": "Email cannot be null or empty" +} +``` + +### 404 Not Found +```json +{ + "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4", + "title": "Subscription Not Found", + "detail": "The requested subscription could not be found" +} +``` + +### 500 Internal Server Error +```json +{ + "type": "https://tools.ietf.org/html/rfc7231#section-6.6.1", + "title": "Customer Creation Failed", + "detail": "An error occurred while creating the customer" +} +``` + +## Configuration + +### Required Settings +The following configuration is required in your application settings: + +```json +{ + "ConnectionStrings": { + "StripeApiKey": "sk_test_your_stripe_secret_key_here" + } +} +``` + +### Environment Variables +For production, it's recommended to use environment variables: +- `ConnectionStrings__StripeApiKey`: Your Stripe secret API key + +## Notes +- All monetary amounts are returned in cents (e.g., $19.99 = 1999) +- All timestamps are in UTC format +- The API uses Stripe's standard status values for subscriptions (active, canceled, incomplete, etc.) +- Payment method IDs should be obtained through Stripe's client-side libraries (Stripe.js, etc.) \ No newline at end of file