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

Subscriptions in Web #183

Merged
merged 3 commits into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/client/CookingAppReact/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Layout from "./pages/layout/Layout";
import Recipe from "./pages/recipe/Recipe";
import Admin from "./pages/admin/Admin";
import Subscribtion from "./pages/subscribtion/Subscribtion";
import Success from "./pages/subscribtion/Succes";
import Settings from "./components/userMenu/settings/Settings";
const router = createBrowserRouter([
{
Expand All @@ -25,6 +26,7 @@ const router = createBrowserRouter([
{ path: "r/:recipeId", element: <Recipe /> },
{ path: "admin/dashboard", element: <Admin /> },
{ path: "subscribtion", element: <Subscribtion /> },
{ path: "success", element: <Success /> },
{ path: "settings", element: <Settings /> },
],
},
Expand Down
16 changes: 16 additions & 0 deletions src/client/CookingAppReact/src/hooks/useStripeProduct.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { fetchSubs } from "@/http/subs";
import { getToken } from "../msal/msal";
import { useQuery } from "@tanstack/react-query";
const useStripeProduct = () => {
const { isPending, isError, data, error } = useQuery({
queryKey: ["subs"],
queryFn: async () => {
const token = await getToken();
return fetchSubs(token);
},
});

return { data, isPending, isError, error };
};

export default useStripeProduct;
21 changes: 21 additions & 0 deletions src/client/CookingAppReact/src/hooks/useStripeSession.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useMutation } from "@tanstack/react-query";
import { createSub } from "@/http/subs";
import { useNavigate } from "react-router-dom";
const useStripeSession = () => {
const navigate = useNavigate();
const {
mutate,
isPending: isSubscribing,
isError: isSubError,
error: subError,
} = useMutation({
mutationFn: createSub,
onSuccess: (response) => {
console.log(response.data.sessionUrl);
window.location.href = response.data.sessionUrl;
},
});
return { mutate };
};

export default useStripeSession;
35 changes: 35 additions & 0 deletions src/client/CookingAppReact/src/http/subs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const ip = import.meta.env.VITE_PUBLIC_PERSONAL_IP;
export async function fetchSubs(token) {
const response = await fetch(`${ip}/api/stripe/products`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(response.errors);
}
const data = await response.json();
console.log(data);
return data;
}
export async function createSub({ token, email, priceId }) {
const response = await fetch(`${ip}/api/stripe/subscription`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email: email,
priceId: priceId,
}),
});
console.log(response);
if (!response.ok) {
throw new Error(response.errors);
}
const data = await response.json();
console.log(data);
return data;
}
73 changes: 51 additions & 22 deletions src/client/CookingAppReact/src/pages/subscribtion/Subscribtion.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,67 @@ import { SubscriptionPlanPremium } from "@/components/subscriptions/Subscription
import { SubscriptionPlanFree } from "@/components/subscriptions/SubscriptionPlanFree";
import { useSelector } from "react-redux";
import MealMaster from "/public/meal-master.png";
import { CheckIcon } from '@heroicons/react/24/outline';
import { CheckIcon } from "@heroicons/react/24/outline";
import { CurrencyEuroIcon } from "@heroicons/react/24/outline";
import { LockClosedIcon } from "@heroicons/react/24/outline";
import useStripeProduct from "@/hooks/useStripeProduct";
import useStripeSession from "@/hooks/useStripeSession";
import { getToken } from "@/msal/msal";
import { jwtDecode } from "jwt-decode";

export default function Subscribtion() {
const isOpenRecipes = useSelector((state) => state.ui.recipesOpen);
const isOpenSideBar = useSelector((state) => state.ui.sidebarOpen);
const { data: priceIds, isPending, isError, error } = useStripeProduct();
const { mutate } = useStripeSession();
async function handleClick() {
const token = await getToken();
const decodedToken = jwtDecode(token);
mutate({
token,
email: decodedToken.preferred_username,
priceId: priceIds.data[0],
});
}
return (
<div className="flex flex-col lg:flex-row w-full h-full justify-start content-start items-start bg-orange-50 px-10 lg:pl-40">
{priceIds && (
<div className="flex w-full justify-center content-start items-start flex-col h-full">
<div>
<ul className="flex flex-col justify-center content-center items-start gap-5">
<li className="text-4xl font-bold">Explore our subscribtion plan:</li>
<li>
<div className="bg-orange-400 text-black text-center p-2 uppercase font-bold rounded-xl w-fit">
Hot offer
</div>
</li>
<li className="text-xl font-semibold">• Unlimited messages</li>
<li className="text-xl font-semibold">• Unlimited chats</li>
<li className="text-xl font-semibold">• More than enough recipies</li>
<li className="text-xl font-semibold">• Customizable dietary options</li>
<li className="text-xl font-semibold">• Free cancellation</li>
</ul>
<button className="text-white bg-black py-3 font-bold rounded-xl w-full mt-10">
<div className=' font-semibold text-xl flex flex-row items-center justify-center text-center'>
Pay <CurrencyEuroIcon className="size-6 ml-2" />8.99
</div>
</button>
<div className="w-full flex justify-center items-center text-center mt-2 text-sm">Stripe checkout <LockClosedIcon className="size-4 ml-2"/></div>
<div>
<ul className="flex flex-col justify-center content-center items-start gap-5">
<li className="text-4xl font-bold">
Explore our subscribtion plan:
</li>
<li>
<div className="bg-orange-400 text-black text-center p-2 uppercase font-bold rounded-xl w-fit">
Hot offer
</div>
</li>
<li className="text-xl font-semibold">• Unlimited messages</li>
<li className="text-xl font-semibold">• Unlimited chats</li>
<li className="text-xl font-semibold">
• More than enough recipies
</li>
<li className="text-xl font-semibold">
• Customizable dietary options
</li>
<li className="text-xl font-semibold">• Free cancellation</li>
</ul>
<button
className="text-white bg-black py-3 font-bold rounded-xl w-full mt-10"
onClick={handleClick}
>
<div className=" font-semibold text-xl flex flex-row items-center justify-center text-center">
Pay <CurrencyEuroIcon className="size-6 ml-2" />
8.99
</div>
</button>
<div className="w-full flex justify-center items-center text-center mt-2 text-sm">
Stripe checkout <LockClosedIcon className="size-4 ml-2" />
</div>
</div>
</div>
)}
</div>
);
);
}
3 changes: 3 additions & 0 deletions src/client/CookingAppReact/src/pages/subscribtion/Succes.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Success() {
return <p>Success</p>;
}
5 changes: 2 additions & 3 deletions src/server/CookingApp/Controllers/StripeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,17 @@ namespace CookingApp.Controllers
using CookingApp.ViewModels.Api;
using CookingApp.ViewModels.Stripe.Subscription;
using Microsoft.AspNetCore.Mvc;
using Product = ViewModels.Stripe.Product;

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

return new ApiResponse<List<Product>>()
return new ApiResponse<List<string>>()
{
Status = 200,
Data = products.ToList()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

using CookingApp.Services.Feedback;
using CookingApp.Services.Limitation;
using Stripe.Checkout;

namespace CookingApp.Infrastructure.Extensions
{
Expand Down Expand Up @@ -140,6 +141,7 @@ public static IHostApplicationBuilder AddStripeIntegration(this WebApplicationBu
builder.Services.AddScoped<SubscriptionService>();
builder.Services.AddScoped<BalanceTransactionService>();
builder.Services.AddScoped<InvoiceService>();
builder.Services.AddScoped<SessionService>();
string apiKey = builder.Configuration.GetValue<string>("StripeOptions:SecretKey") ?? string.Empty;
string webhookSecret = builder.Configuration.GetValue<string>("StripeOptions:WebhookSecret") ?? string.Empty;

Expand All @@ -149,6 +151,7 @@ public static IHostApplicationBuilder AddStripeIntegration(this WebApplicationBu
options.WebhookSecret = webhookSecret;
});
StripeConfiguration.ApiKey = apiKey;
StripeConfiguration.StripeClient = new StripeClient(apiKey);
return builder;
}

Expand Down
2 changes: 1 addition & 1 deletion src/server/CookingApp/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "http://192.168.100.221:8000"
"applicationUrl": "http://192.168.0.105:8000"
},
"https": {
"commandName": "Project",
Expand Down
2 changes: 1 addition & 1 deletion src/server/CookingApp/Services/Stripe/IStripeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace CookingApp.Services.Stripe
{
public interface IStripeService
{
Task<IEnumerable<ViewModels.Stripe.Product>> GetProductsAsync();
Task<IEnumerable<string>> GetProductsAsync();
Task<SubscriptionCreationResponse> CreateSubscriptionAsync(SubscriptionCreation model);
Task<SubscriptionCancellationResponse> CancelSubscriptionAsync(SubscriptionCancellation model);
Task<List<CustomerData>> GetAllSubs();
Expand Down
89 changes: 34 additions & 55 deletions src/server/CookingApp/Services/Stripe/StripeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,45 @@
{
using AutoMapper;
using CookingApp.Common.Helpers.Profiles;
using CookingApp.Infrastructure.Configurations.Stripe;
using CookingApp.Infrastructure.Exceptions;
using CookingApp.Infrastructure.Interfaces;
using CookingApp.Models;
using CookingApp.ViewModels.Stripe.Customer;
using CookingApp.ViewModels.Stripe.Statistics;
using CookingApp.ViewModels.Stripe.Subscription;
using global::Stripe;
using global::Stripe.Checkout;
using Microsoft.Extensions.Options;
using MongoDB.Driver.Core.Events;
using static CookingApp.Common.ExceptionMessages;
using Product = ViewModels.Stripe.Product;

public class StripeService(CustomerService customerService,
PriceService priceService,
ProductService productService,
SubscriptionService subscriptionService,
BalanceTransactionService balanceTransactionService,
InvoiceService invoiceService,

Check warning on line 22 in src/server/CookingApp/Services/Stripe/StripeService.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'invoiceService' is unread.
IOptions<StripeOptions> stripeOptions,

Check warning on line 23 in src/server/CookingApp/Services/Stripe/StripeService.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'stripeOptions' is unread.
SessionService sessionService,
IRepository<UserProfile> userRepo,
IHttpContextAccessor httpContextAccessor,
IMapper mapper) : IStripeService
{
/// <summary>
/// Gets all products that are in the Stripe account.
/// </summary>
public async Task<IEnumerable<Product>> GetProductsAsync()
public async Task<IEnumerable<string>> GetProductsAsync()
{
var options = new ProductListOptions { Limit = 3 };
var options = new ProductListOptions { Limit = 1 };

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

foreach (var product in products)
{

var price = await priceService.GetAsync(product.DefaultPriceId);
result.Add(
new Product(product.Id,
product.Name,
price.UnitAmount,
product.DefaultPriceId,
product.Description,
price.Recurring.Interval));
result.Add(price.Id);
}

return result;
Expand Down Expand Up @@ -73,6 +71,20 @@
throw new NotFoundException();
}

var options = new SessionCreateOptions
{
SuccessUrl = $"http://localhost:5173/success",
Mode = "subscription",
LineItems = new List<SessionLineItemOptions>
{
new SessionLineItemOptions
{
Price = model.PriceId,
Quantity = 1,
},
},
};

if(profile.StripeId is not null)
{
var customer = await customerService.GetAsync(profile.StripeId);
Expand All @@ -88,56 +100,23 @@
{
throw new ArgumentException(Stripe.TheUserIsAlreadySubscribed);
}
var incompleteSubscription = subscriptions.FirstOrDefault(sub=>sub.Status=="incomplete");
if(incompleteSubscription is not null)
{
var invoiceListOptions = new InvoiceListOptions
{
Status = "open",
Subscription=incompleteSubscription.Id
};
var invoices=await invoiceService.ListAsync(invoiceListOptions);
var invoice=invoices.First();
return new SubscriptionCreationResponse(
incompleteSubscription.Id,
invoice.Id,
invoice.HostedInvoiceUrl
);
}
}
options.Customer=profile.StripeId;
}


var options = new CustomerCreateOptions
{
Email = model.Email
};
var newCustomer = await customerService.CreateAsync(options);

profile.StripeId=newCustomer.Id;
await userRepo.UpdateAsync(profile);


var subscriptionOptions = new SubscriptionCreateOptions
else
{
Customer = newCustomer.Id,
Items =
[
new SubscriptionItemOptions
{
Price = model.PriceId,
},
],
PaymentBehavior = "default_incomplete",
};
subscriptionOptions.AddExpand("latest_invoice.payment_intent");
var customer = await customerService.CreateAsync(new CustomerCreateOptions(){Email=model.Email});
options.Customer = customer.Id;
profile.StripeId = customer.Id;
await userRepo.UpdateAsync(profile);
}

var session = await sessionService.CreateAsync(options);

var subscription = await subscriptionService.CreateAsync(subscriptionOptions);

return new SubscriptionCreationResponse(
subscription.Id,
subscription.LatestInvoiceId,
subscription.LatestInvoice.HostedInvoiceUrl
session.Url
);
}

Expand Down
Loading
Loading