Skip to content

Commit

Permalink
Merge pull request #24 from InternAcademy/21-implement-monthly-subscr…
Browse files Browse the repository at this point in the history
…iption-functionality

Monthly subscription functionallity
  • Loading branch information
dpS1lence authored Jun 3, 2024
2 parents 078aefd + 2136501 commit 4f25e51
Show file tree
Hide file tree
Showing 19 changed files with 410 additions and 1 deletion.
9 changes: 9 additions & 0 deletions src/server/CookingApp/Common/ExceptionMessages.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace CookingApp.Common
{
public static class ExceptionMessages
{
public const string NullOrEmptyInputValues = "The provided input contains either null or an empty value";
public const string SubscriptionCreationFail = "Failed to create a subscription. {0}";

}
}
25 changes: 25 additions & 0 deletions src/server/CookingApp/Controllers/SubscriptionController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using CookingApp.Services.Stripe;
using CookingApp.ViewModels.Stripe.Customer;
using Microsoft.AspNetCore.Mvc;

namespace CookingApp.Controllers
{
//The endpoints are still not working without authorizationScheme
[Route("api/subscription")]
[ApiController]
public class SubscriptionController : ControllerBase
{
private readonly IStripeService stripeService;

public SubscriptionController(IStripeService _stripeService)
{
stripeService = _stripeService;
}

[HttpPost("create-customer")]
public async Task<ActionResult> CreateCustomerAsync ([FromBody] CustomerCreation model)
{
return Ok(await stripeService.CreateCustomerAsync(model.Email));
}
}
}
1 change: 1 addition & 0 deletions src/server/CookingApp/CookingApp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="7.0.1" />
<PackageReference Include="Stripe.net" Version="44.10.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace CookingApp.Infrastructure.Configurations.Stripe
{
public class StripeOptions
{
public string PublishableKey { get; set; } = string.Empty;
public string SecretKey { get; set; } = string.Empty;
public string WebhookSecret { get; set; } = string.Empty;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
using CookingApp.Services.Stripe;
using Stripe;
using CookingApp.Infrastructure.Configurations.Stripe;

namespace CookingApp.Infrastructure.Extensions
{
Expand Down Expand Up @@ -114,5 +117,22 @@ public static IHostApplicationBuilder AddMongoDatabase(this WebApplicationBuilde

return builder;
}
}
public static IHostApplicationBuilder AddStripeIntegration(this WebApplicationBuilder builder)
{
builder.Services.AddScoped<IStripeService, StripeService>();
builder.Services.AddScoped<CustomerService>();
builder.Services.AddScoped<PriceService>();
builder.Services.AddScoped<ProductService>();
builder.Services.AddScoped<SubscriptionService>();

builder.Services.Configure<StripeOptions>(options =>
{
string key = builder.Configuration.GetValue<string>("StripeOptions:SecretKey") ?? string.Empty;
options.SecretKey = key;
StripeConfiguration.ApiKey = key;
});

return builder;
}
}
}
1 change: 1 addition & 0 deletions src/server/CookingApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
p.WithIgnoreIfDefaultConvention(false);
p.WithIgnoreIfNullConvention(true);
});
builder.AddStripeIntegration();

builder.Host.UseLogging(p =>
{
Expand Down
14 changes: 14 additions & 0 deletions src/server/CookingApp/Services/Stripe/IStripeService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using CookingApp.ViewModels.Stripe.Customer;
using CookingApp.ViewModels.Stripe.Product;
using CookingApp.ViewModels.Stripe.Subscription;

namespace CookingApp.Services.Stripe
{
public interface IStripeService
{
Task<CustomerCreationResponse> CreateCustomerAsync(string email);
Task<IEnumerable<ProductsResponse>> GetProductsAsync();
Task<SubscriptionCreationResponse> CreateSubscriptionAsync(SubscriptionCreation model);
Task<SubscriptionCancellationResponse> CancelSubscriptionAsync(SubscriptionCancellation model);
}
}
127 changes: 127 additions & 0 deletions src/server/CookingApp/Services/Stripe/StripeService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using CookingApp.ViewModels.Stripe.Customer;
using CookingApp.ViewModels.Stripe.Product;
using CookingApp.ViewModels.Stripe.Subscription;
using static CookingApp.Common.ExceptionMessages;
using Stripe;

namespace CookingApp.Services.Stripe
{
public class StripeService : IStripeService
{
private readonly CustomerService customerService;
private readonly PriceService priceService;
private readonly ProductService productService;
private readonly SubscriptionService subscriptionService;

public StripeService(CustomerService _customerService,
PriceService _priceService,
ProductService _productService,
SubscriptionService _subscriptionService)
{
customerService = _customerService;
priceService = _priceService;
productService = _productService;
subscriptionService = _subscriptionService;
}

/// <summary>
/// Creates a customer object in Stripe.
/// It is used to create recurring charges and track payments that belong to the same customer.
/// </summary>
public async Task<CustomerCreationResponse> CreateCustomerAsync(string email)
{
ArgumentException.ThrowIfNullOrEmpty(email);

var options = new CustomerCreateOptions
{
Email = email
};

Customer customer = await customerService.CreateAsync(options);

return (new CustomerCreationResponse(
customer.Id,
customer.Email)
);
}

/// <summary>
/// Gets all products that are in the Stripe account.
/// </summary>
public async Task<IEnumerable<ProductsResponse>> GetProductsAsync()
{
var options = new ProductListOptions { Limit = 3 };

StripeList<Product> products = await productService.ListAsync(options);

List<ProductsResponse> result = new List<ProductsResponse>();

foreach (var product in products)
{
var price = await priceService.GetAsync(product.DefaultPriceId);
result.Add(
new ProductsResponse(product.Id,
product.Name,
price.UnitAmount,
product.DefaultPriceId,
product.Description,
product.Images[0]));
}
return result;
}

/// <summary>
/// Creates a subscription with a status "default_incomplete" because the subscription
/// requires a payment. It automatically generates an an initial Invoice.
/// Once the initial Invoice is payed the status then is set to active.
/// If the Invoice is not payed in 23 hours the status then is set to "incomplete_expired"
/// </summary>
public async Task<SubscriptionCreationResponse> CreateSubscriptionAsync(SubscriptionCreation model)
{
if(model == null ||
string.IsNullOrEmpty(model.CustomerId) ||
string.IsNullOrEmpty(model.PriceId))
{
throw new ArgumentNullException(NullOrEmptyInputValues);
}
var subscriptionOptions = new SubscriptionCreateOptions
{
Customer = model.CustomerId,
Items = new List<SubscriptionItemOptions>
{
new SubscriptionItemOptions
{
Price = model.PriceId,
},
},
PaymentBehavior = "default_incomplete",
};
subscriptionOptions.AddExpand("latest_invoice.payment_intent");
try
{
Subscription subscription = await subscriptionService.CreateAsync(subscriptionOptions);

return new SubscriptionCreationResponse(
subscription.Id,
subscription.LatestInvoice.PaymentIntent.ClientSecret,
subscription.LatestInvoiceId,
subscription.LatestInvoice.HostedInvoiceUrl
);
}
catch (StripeException e)
{
throw new InvalidOperationException(string.Format(SubscriptionCreationFail, e));
}
}

/// <summary>
/// Cancels a subscription immediatly. The customer will not be charged again for the subscription
/// </summary>
public async Task<SubscriptionCancellationResponse> CancelSubscriptionAsync(SubscriptionCancellation model)
{
ArgumentException.ThrowIfNullOrEmpty(model.SubscriptionId);
var subscription = await subscriptionService.CancelAsync(model.SubscriptionId);
return new SubscriptionCancellationResponse(subscription.CanceledAt);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;

namespace CookingApp.ViewModels.Stripe.Customer
{
public class CustomerCreation
{
[Required]
[EmailAddress]
public string Email { get; init; } = string.Empty;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;

namespace CookingApp.ViewModels.Stripe.Customer
{
public record CustomerCreationResponse(
string Id,
string Email);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace CookingApp.ViewModels.Stripe.Product
{
public record ProductsResponse(
string Id,
string Name,
long? Price,
string PriceId,
string Description,
string ImageUrl);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace CookingApp.ViewModels.Stripe.Subscription
{
public record SubscriptionCancellation(string SubscriptionId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace CookingApp.ViewModels.Stripe.Subscription
{
public record SubscriptionCancellationResponse(
DateTime? CanceledAt);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;

namespace CookingApp.ViewModels.Stripe.Subscription
{
public class SubscriptionCreation
{
[Required]
[JsonPropertyName("customerId")]
public string CustomerId { get; set; } = string.Empty;

[Required]
[JsonPropertyName("priceId")]
public string PriceId { get; set; } = string.Empty;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace CookingApp.ViewModels.Stripe.Subscription
{
public record SubscriptionCreationResponse(
string SubscriptionId,
string ClientSecret,
string InvoiceId,
string InvoiceUrl);
}
3 changes: 3 additions & 0 deletions src/server/CookingApp/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,8 @@
"Mongo": {
"Url": "mongodb://localhost:27017",
"Database": "cooking-v1"
},
"StripeOptions": {
"SecretKey": "sk_test_51PLorXI0zkGbIS5D2varMrL7PkbzyeZKaM2HAO5eQr8YOdeEJj4cey09AMBN0HIqKquzrtSoRy0tqqdfotgIPmBP00kSWCM4ur"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using CookingApp.Controllers;
using CookingApp.Services.Stripe;
using CookingApp.ViewModels.Stripe.Customer;
using Microsoft.AspNetCore.Mvc;
using Moq;
using NUnit.Framework;
using Assert = NUnit.Framework.Assert;

namespace CookingApp.UnitTests.ControllerTests
{
internal class SubscriptionControllerTests
{
private Mock<IStripeService> stripeService;
private SubscriptionController subsController;

[SetUp]
public void Setup()
{
stripeService = new Mock<IStripeService>();
stripeService.Setup(service => service.CreateCustomerAsync(It.IsAny<string>()))
.ReturnsAsync(new CustomerCreationResponse("cust_12345", "test@example.com"));
subsController = new SubscriptionController(stripeService.Object);
}
[Test]
public async Task ShouldReturnSubscription()
{
var result = await subsController.CreateCustomerAsync(new CustomerCreation() { Email="TEST"});

var okResult = result as OkObjectResult;
Assert.That(okResult,Is.Not.Null);
Assert.That(okResult?.StatusCode,Is.EqualTo(200));
Assert.That(okResult?.Value, Is.Not.Null);


}
}
}
3 changes: 3 additions & 0 deletions test/CookingApp.UnitTests/CookingApp.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="NUnit" Version="4.1.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="Stripe.net" Version="44.10.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
Expand Down
Loading

0 comments on commit 4f25e51

Please sign in to comment.