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

Real payment integration #50

Merged
67 changes: 67 additions & 0 deletions src/server/CookingApp/Controllers/StripeController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@

namespace CookingApp.Controllers
{
using CookingApp.Infrastructure.Configurations.Stripe;
using CookingApp.Services.Stripe;
using CookingApp.ViewModels.Api;
using CookingApp.ViewModels.Stripe.Customer;
using CookingApp.ViewModels.Stripe.Subscription;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Product = ViewModels.Stripe.Product;

[Route("api/stripe")]
[ApiController]
public class StripeController(IStripeService stripeService) : ControllerBase
{
[HttpGet("products")]
public async Task<ApiResponse<List<Product>>> GetProductsAsync()
{
var products = await stripeService.GetProductsAsync();

return new ApiResponse<List<Product>>()
{
Status = 200,
Data = products.ToList()
};

}

[HttpPost("customer")]
public async Task<ApiResponse<CustomerCreationResponse>> CreateCustomerAsync ([FromBody] CustomerCreation model)
{
var customer = await stripeService.CreateCustomerAsync(model.Email);

return new ApiResponse<CustomerCreationResponse>()
{
Status = 200,
Data = customer
};
}

[HttpPost("subscription")]
public async Task<ApiResponse<SubscriptionCreationResponse>> CreateSubscriptionAsync([FromBody] SubscriptionCreation model)
{
var customer = await stripeService.CreateSubscriptionAsync(model);

return new ApiResponse<SubscriptionCreationResponse>()
{
Status = 200,
Data = customer
};
}

[HttpPost("cancel")]
public async Task<ApiResponse<SubscriptionCancellationResponse>> CancelSubscriptionAsync([FromBody] SubscriptionCancellation model)
{
var subscription = await stripeService.CancelSubscriptionAsync(model);

return new ApiResponse<SubscriptionCancellationResponse>()
{
Status = 200,
Data = subscription
};
}

}
}
25 changes: 0 additions & 25 deletions src/server/CookingApp/Controllers/SubscriptionController.cs

This file was deleted.

7 changes: 7 additions & 0 deletions src/server/CookingApp/CookingApp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
<DockerfileContext>.</DockerfileContext>
</PropertyGroup>

<ItemGroup>
<Compile Remove="ViewModels\Stripe\Product\**" />
<Content Remove="ViewModels\Stripe\Product\**" />
<EmbeddedResource Remove="ViewModels\Stripe\Product\**" />
<None Remove="ViewModels\Stripe\Product\**" />
</ItemGroup>

<ItemGroup>

<PackageReference Include="AutoMapper" Version="13.0.1" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,19 +155,19 @@ public static IHostApplicationBuilder AddMongoDatabase(this WebApplicationBuilde

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>();
string apiKey = builder.Configuration.GetValue<string>("StripeOptions:SecretKey") ?? string.Empty;
string webhookSecret = builder.Configuration.GetValue<string>("StripeOptions:WebhookSecret") ?? string.Empty;

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

StripeConfiguration.ApiKey = apiKey;
return builder;
}

Expand All @@ -188,9 +188,10 @@ public static IHostApplicationBuilder AddOpenAIIntegration(this WebApplicationBu

public static IHostApplicationBuilder AddServices(this WebApplicationBuilder builder)
{
builder.Services.AddScoped<IStripeService, StripeService>();
builder.Services.AddScoped<IUserProfileService, UserProfileService>();

return builder;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@

namespace CookingApp.Infrastructure.Middleware
{
using CookingApp.ViewModels.Api;
using Stripe;
using System.Net;
using System.Text.Json;

public class ExceptionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}

private static Task HandleExceptionAsync(HttpContext context, Exception exception)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = exception switch
{
ArgumentException => (int)HttpStatusCode.BadRequest,
StripeException => (int)HttpStatusCode.BadRequest,
_ => (int)HttpStatusCode.InternalServerError
};

var response = new ApiResponse<object>
{
Status = context.Response.StatusCode,
Errors = [exception.Message]
};

var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

var jsonResponse = JsonSerializer.Serialize(response, options);

return context.Response.WriteAsync(jsonResponse);
}
}
}
3 changes: 3 additions & 0 deletions src/server/CookingApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;
using CookingApp.Infrastructure.Middleware;

var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
Expand All @@ -23,7 +24,7 @@
builder.AddSwagger(x =>
{
x.LoadSettingsFrom(swaggerSettings);
x.LoadSecuritySettingsFrom(oAuthSettings);

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

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'securitySettings' in 'void SwaggerConfiguration.LoadSecuritySettingsFrom(AuthenticationSettings securitySettings)'.
});
builder.AddCorsWithAcceptAll();
builder.AddMongoDatabase(p =>
Expand Down Expand Up @@ -51,7 +52,7 @@
builder.Host.UseLogging(p =>
{
p.WithConsoleSink(true);
p.WithSeqSink(builder.Configuration["SeqServerUrl"]);

Check warning on line 55 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 All @@ -72,6 +73,8 @@

app.MapControllers();

app.UseMiddleware<ExceptionMiddleware>();

app.Run();

[ExcludeFromCodeCoverage]
Expand Down
6 changes: 3 additions & 3 deletions src/server/CookingApp/Services/Stripe/IStripeService.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
using CookingApp.ViewModels.Stripe.Customer;
using CookingApp.ViewModels.Stripe.Product;
using CookingApp.ViewModels.Stripe;
using CookingApp.ViewModels.Stripe.Customer;
using CookingApp.ViewModels.Stripe.Subscription;

namespace CookingApp.Services.Stripe
{
public interface IStripeService
{
Task<CustomerCreationResponse> CreateCustomerAsync(string email);
Task<IEnumerable<ProductsResponse>> GetProductsAsync();
Task<IEnumerable<Product>> GetProductsAsync();
Task<SubscriptionCreationResponse> CreateSubscriptionAsync(SubscriptionCreation model);
Task<SubscriptionCancellationResponse> CancelSubscriptionAsync(SubscriptionCancellation model);
}
Expand Down
93 changes: 36 additions & 57 deletions src/server/CookingApp/Services/Stripe/StripeService.cs
Original file line number Diff line number Diff line change
@@ -1,72 +1,56 @@
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
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;
}
using CookingApp.ViewModels.Stripe.Customer;
using CookingApp.ViewModels.Stripe.Subscription;
using global::Stripe;
using static CookingApp.Common.ExceptionMessages;
using Product = ViewModels.Stripe.Product;

public class StripeService(CustomerService customerService,
PriceService priceService,
ProductService productService,
SubscriptionService subscriptionService) : IStripeService
{
/// <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
};
var customer = await customerService.CreateAsync(options);

Customer customer = await customerService.CreateAsync(options);

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

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

StripeList<Product> products = await productService.ListAsync(options);
var options = new ProductListOptions { Limit = 3 };

List<ProductsResponse> result = new List<ProductsResponse>();
var products = await productService.ListAsync(options);
var result = new List<Product>();

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

return result;
}

Expand All @@ -78,40 +62,34 @@ public async Task<IEnumerable<ProductsResponse>> GetProductsAsync()
/// </summary>
public async Task<SubscriptionCreationResponse> CreateSubscriptionAsync(SubscriptionCreation model)
{
if(model == null ||
if (model == null ||
string.IsNullOrEmpty(model.CustomerId) ||
string.IsNullOrEmpty(model.PriceId))
{
throw new ArgumentNullException(NullOrEmptyInputValues);
throw new ArgumentException(NullOrEmptyInputValues);
}
var subscriptionOptions = new SubscriptionCreateOptions
{
Customer = model.CustomerId,
Items = new List<SubscriptionItemOptions>
{
Items =
[
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));
}
var subscription = await subscriptionService.CreateAsync(subscriptionOptions);

return new SubscriptionCreationResponse(
subscription.Id,
subscription.LatestInvoice.PaymentIntent.ClientSecret,
subscription.LatestInvoiceId,
subscription.LatestInvoice.HostedInvoiceUrl
);
}

/// <summary>
Expand All @@ -121,6 +99,7 @@ public async Task<SubscriptionCancellationResponse> CancelSubscriptionAsync(Subs
{
ArgumentException.ThrowIfNullOrEmpty(model.SubscriptionId);
var subscription = await subscriptionService.CancelAsync(model.SubscriptionId);

return new SubscriptionCancellationResponse(subscription.CanceledAt);
}
}
Expand Down
Loading
Loading