Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Monthly subscription functionallity #24

Merged
merged 12 commits into from
Jun 3, 2024
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,11 +32,12 @@
p.WithIgnoreIfDefaultConvention(false);
p.WithIgnoreIfNullConvention(true);
});
builder.AddStripeIntegration();

builder.Host.UseLogging(p =>
{
p.WithConsoleSink(true);
p.WithSeqSink(builder.Configuration["SeqServerUrl"]);

Check warning on line 40 in src/server/CookingApp/Program.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'url' in 'LoggingConfiguration LoggingConfiguration.WithSeqSink(string url)'.

Check warning on line 40 in src/server/CookingApp/Program.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'url' in 'LoggingConfiguration LoggingConfiguration.WithSeqSink(string url)'.
});

var app = builder.Build();
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
Loading