From a5e3cb84b78de136ce98fb023d8a02ce61205667 Mon Sep 17 00:00:00 2001 From: Charles Showalter Date: Mon, 30 May 2022 13:25:27 -0700 Subject: [PATCH] Stripe Payments Continued --- API/Controllers/PaymentsController.cs | 5 +- API/Dtos/CustomerBasketDto.cs | 2 + Core/Entities/CustomerBasket.cs | 1 + Core/Entities/OrderAggregate/Order.cs | 3 +- ...ByPaymentIntentIdWithItemsSpecification.cs | 11 +++ Infrastructure/Services/OrderService.cs | 16 ++- Infrastructure/Services/PaymentService.cs | 1 + client/src/app/basket/basket.service.ts | 13 +++ .../checkout-payments.component.html | 25 ++++- .../checkout-payments.component.ts | 99 +++++++++++++++---- .../checkout-review.component.html | 2 +- .../checkout-review.component.ts | 17 +++- .../src/app/checkout/checkout.component.html | 4 +- client/src/app/checkout/checkout.component.ts | 10 +- .../core/interceptors/loading.interceptor.ts | 8 +- client/src/app/shared/models/baskset.ts | 4 + client/src/index.html | 1 + 17 files changed, 189 insertions(+), 33 deletions(-) create mode 100644 Core/Specifications/OrderByPaymentIntentIdWithItemsSpecification.cs diff --git a/API/Controllers/PaymentsController.cs b/API/Controllers/PaymentsController.cs index 4cbd71f..71de009 100644 --- a/API/Controllers/PaymentsController.cs +++ b/API/Controllers/PaymentsController.cs @@ -1,3 +1,4 @@ +using API.Errors; using Core.Entities; using Core.Interfaces; using Microsoft.AspNetCore.Authorization; @@ -17,7 +18,9 @@ namespace API.Controllers [HttpPost("{basketId}")] public async Task> CreateOrUpdatePaymentIntent(string basketId) { - return await _paymentService.CreateOrUpdatePaymentIntent(basketId); + var basket = await _paymentService.CreateOrUpdatePaymentIntent(basketId); + if(basket == null) return BadRequest(new ApiResponse(400, "Problem with your basket")); + return basket; } } } \ No newline at end of file diff --git a/API/Dtos/CustomerBasketDto.cs b/API/Dtos/CustomerBasketDto.cs index 355a204..53e4f15 100644 --- a/API/Dtos/CustomerBasketDto.cs +++ b/API/Dtos/CustomerBasketDto.cs @@ -10,5 +10,7 @@ namespace API.Dtos public int? DeliveryMethodId { get; set; } public string ClientSecret { get; set; } public string PaymentItentId { get; set; } + + public decimal ShippingPrice { get; set; } } } \ No newline at end of file diff --git a/Core/Entities/CustomerBasket.cs b/Core/Entities/CustomerBasket.cs index aad1470..2d3846f 100644 --- a/Core/Entities/CustomerBasket.cs +++ b/Core/Entities/CustomerBasket.cs @@ -21,5 +21,6 @@ namespace Core.Entities public int? DeliveryMethodId { get; set; } public string ClientSecret { get; set; } public string PaymentItentId { get; set; } + public decimal ShippingPrice { get; set; } } } \ No newline at end of file diff --git a/Core/Entities/OrderAggregate/Order.cs b/Core/Entities/OrderAggregate/Order.cs index bab1a33..d33f457 100644 --- a/Core/Entities/OrderAggregate/Order.cs +++ b/Core/Entities/OrderAggregate/Order.cs @@ -6,13 +6,14 @@ namespace Core.Entities.OrderAggregate { } - public Order(IReadOnlyList orderItems, string buyerEmail, Address shipToAddress, DeliveryMethod deliveryMethod, decimal subtotal) + public Order(IReadOnlyList orderItems, string buyerEmail, Address shipToAddress, DeliveryMethod deliveryMethod, decimal subtotal, string paymentIntentId) { BuyerEmail = buyerEmail; ShipToAddress = shipToAddress; DeliveryMethod = deliveryMethod; OrderItems = orderItems; Subtotal = subtotal; + PaymentIntentId = paymentIntentId; } public string BuyerEmail { get; set; } diff --git a/Core/Specifications/OrderByPaymentIntentIdWithItemsSpecification.cs b/Core/Specifications/OrderByPaymentIntentIdWithItemsSpecification.cs new file mode 100644 index 0000000..2425751 --- /dev/null +++ b/Core/Specifications/OrderByPaymentIntentIdWithItemsSpecification.cs @@ -0,0 +1,11 @@ +using Core.Entities.OrderAggregate; + +namespace Core.Specifications +{ + public class OrderByPaymentIntentIdWithItemsSpecification : BaseSpecification + { + public OrderByPaymentIntentIdWithItemsSpecification(string paymentIntentId) : base(o => o.PaymentIntentId == paymentIntentId) + { + } + } +} \ No newline at end of file diff --git a/Infrastructure/Services/OrderService.cs b/Infrastructure/Services/OrderService.cs index 0b156dd..0c450ad 100644 --- a/Infrastructure/Services/OrderService.cs +++ b/Infrastructure/Services/OrderService.cs @@ -9,9 +9,11 @@ namespace Infrastructure.Services { private readonly IBasketRepository _basketRepo; private readonly IUnitOfWork _unitOfWork; + private readonly IPaymentService _paymentService; - public OrderService(IBasketRepository basketRepo, IUnitOfWork unitOfWork) + public OrderService(IBasketRepository basketRepo, IUnitOfWork unitOfWork, IPaymentService paymentService) { + _paymentService = paymentService; _unitOfWork = unitOfWork; _basketRepo = basketRepo; } @@ -29,11 +31,19 @@ namespace Infrastructure.Services } var deliveryMethod = await _unitOfWork.Repository().GetByIdAsync(deliverMethodId); var subtotal = items.Sum(item => item.Price * item.Quantity); - var order = new Order(items, buyerEmail, shippingAddress, deliveryMethod, subtotal); + var spec = new OrderByPaymentIntentIdWithItemsSpecification(basket.PaymentItentId); + var existingOrder = await _unitOfWork.Repository().GetEntityWithSpec(spec); + if(existingOrder != null) + { + _unitOfWork.Repository().Delete(existingOrder); + await _paymentService.CreateOrUpdatePaymentIntent(basket.PaymentItentId); + } + + var order = new Order(items, buyerEmail, shippingAddress, deliveryMethod, subtotal, basket.PaymentItentId); _unitOfWork.Repository().Add(order); var result = await _unitOfWork.Complete(); if(result <= 0) return null; - await _basketRepo.DeleteBasketAsysnc(basketId); + return order; } diff --git a/Infrastructure/Services/PaymentService.cs b/Infrastructure/Services/PaymentService.cs index 6df72f0..75f6a0a 100644 --- a/Infrastructure/Services/PaymentService.cs +++ b/Infrastructure/Services/PaymentService.cs @@ -23,6 +23,7 @@ namespace Infrastructure.Services { StripeConfiguration.ApiKey = _config["StripeSettings:SecretKey"]; var basket = await _basketRepository.GetBasketAsync(basketId); + if(basket == null) return null; var shippingPrice = 0m; if(basket.DeliveryMethodId.HasValue){ diff --git a/client/src/app/basket/basket.service.ts b/client/src/app/basket/basket.service.ts index 9eb3ade..3052690 100644 --- a/client/src/app/basket/basket.service.ts +++ b/client/src/app/basket/basket.service.ts @@ -19,15 +19,28 @@ export class BasketService { constructor(private http: HttpClient) { } + createPaymentIntent(){ + return this.http.post(this.baseUrl + 'payments/' + this.getCurrentBasketValue().id, {}).pipe( + map((basket: IBasket) => { + this.basketSource.next(basket); + }) + ); + } + setShippingPrice(deliveryMethod: IDeliveryMethods){ this.shipping = deliveryMethod.price; + const basket = this.getCurrentBasketValue(); + basket.deliveryMethodId = deliveryMethod.id; + basket.shippingPrice = deliveryMethod.price; this.calculateTotals(); + this.setBasket(basket); } getBasket(id: string){ return this.http.get(this.baseUrl + 'basket?id=' + id).pipe( map((basket: IBasket) => { this.basketSource.next(basket); + this.shipping = basket.shippingPrice; this.calculateTotals(); }) ); diff --git a/client/src/app/checkout/checkout-payments/checkout-payments.component.html b/client/src/app/checkout/checkout-payments/checkout-payments.component.html index 264ebb6..aa8bb4c 100644 --- a/client/src/app/checkout/checkout-payments/checkout-payments.component.html +++ b/client/src/app/checkout/checkout-payments/checkout-payments.component.html @@ -1,9 +1,30 @@ -

checkout-payments works!

+
+
+
+ +
+
+
+ + + {{cardErrors}} + + +
+
+
+
+
+
+
+
+
-
diff --git a/client/src/app/checkout/checkout-payments/checkout-payments.component.ts b/client/src/app/checkout/checkout-payments/checkout-payments.component.ts index 1f2c98d..04d8cbe 100644 --- a/client/src/app/checkout/checkout-payments/checkout-payments.component.ts +++ b/client/src/app/checkout/checkout-payments/checkout-payments.component.ts @@ -1,43 +1,106 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { NavigationExtras, Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; +import { lastValueFrom } from 'rxjs'; import { BasketService } from 'src/app/basket/basket.service'; import { IBasket } from 'src/app/shared/models/baskset'; import { IOrder } from 'src/app/shared/models/order'; import { CheckoutService } from '../checkout.service'; +declare var Stripe; + @Component({ selector: 'app-checkout-payments', templateUrl: './checkout-payments.component.html', styleUrls: ['./checkout-payments.component.scss'] }) -export class CheckoutPaymentsComponent implements OnInit { +export class CheckoutPaymentsComponent implements AfterViewInit, OnDestroy { @Input() checkoutForm: FormGroup; + @ViewChild('cardNumber', {static: true}) cardNumberElement: ElementRef; + @ViewChild('cardExpiry', {static: true}) cardExpiryElement: ElementRef; + @ViewChild('cardCvc', {static: true}) cardCvcElement: ElementRef; + stripe: any; + cardNumber: any; + cardExpiry: any; + cardCvc: any; + cardErrors: any; + cardHandler = this.onChange.bind(this); + loading = false; constructor(private basketService: BasketService, private checkoutService: CheckoutService, private toastr: ToastrService, private router: Router) { } - ngOnInit(): void { + ngAfterViewInit(): void { + this.stripe = Stripe('pk_test_51L4B2fKnYx7tVZlJDJxQ56Wrjip0xv6ieenwhyzoM8hPu2iHRg581JBJAwOpMoQLlsWg7uSs3izk7xQv0xpT5sK000HDZNIA1l'); + const elements = this.stripe.elements(); + + this.cardNumber = elements.create('cardNumber'); + this.cardNumber.mount(this.cardNumberElement.nativeElement); + this.cardNumber.addEventListener('change', this.cardHandler); + + this.cardExpiry = elements.create('cardExpiry'); + this.cardExpiry.mount(this.cardExpiryElement.nativeElement); + this.cardExpiry.addEventListener('change', this.cardHandler); + + this.cardCvc = elements.create('cardCvc'); + this.cardCvc.mount(this.cardCvcElement.nativeElement); + this.cardCvc.addEventListener('change', this.cardHandler); + } + + ngOnDestroy(): void { + this.cardNumber.destroy(); + this.cardExpiry.destroy(); + this.cardCvc.destroy(); + } + + onChange({error}) { + if(error) + { + this.cardErrors = error.message; + } else + { + this.cardErrors = null; + } + } + + async submitOrder(){ + this.loading = true; + const basket = this.basketService.getCurrentBasketValue(); + try { + const createdOrder = await this.createOrder(basket); + const paymentResult = await this.confirmPaymentWithStripe(basket); + + if(paymentResult.paymentIntent) + { + this.basketService.deleteLocalBasket(basket.id); + const navigationExtras: NavigationExtras = {state: createdOrder}; + this.router.navigate(['checkout/success'], navigationExtras); + } else { + this.toastr.error(paymentResult.error.message); + } + this.loading = false; + } catch(error) { + console.log(error); + this.loading = false; + } } - submitOrder(){ - const basket = this.basketService.getCurrentBasketValue(); - const orderToCreate = this.getOrderToCreate(basket); - this.checkoutService.createOrder(orderToCreate).subscribe({ - next: (order: IOrder) => { - this.toastr.success('Order created successfully'); - this.basketService.deleteLocalBasket(basket.id); - const navigationExtras: NavigationExtras = {state: order}; - this.router.navigate(['checkout/success'], navigationExtras); - }, - error: (e: any) => { - this.toastr.error(e.message); - console.log(e); - }, - complete: () => { console.log('completed') } + private async confirmPaymentWithStripe(basket: IBasket) { + return this.stripe.confirmCardPayment(basket.clientSecret, { + payment_method: { + card: this.cardNumber, + billing_details: { + name: this.checkoutForm.get('paymentForm').get('nameOnCard').value + } + } }); } + private async createOrder(basket: IBasket) { + const orderToCreate = this.getOrderToCreate(basket); + return lastValueFrom(this.checkoutService.createOrder(orderToCreate)); + } + private getOrderToCreate(basket: IBasket) { return { basketId: basket.id, diff --git a/client/src/app/checkout/checkout-review/checkout-review.component.html b/client/src/app/checkout/checkout-review/checkout-review.component.html index df17587..f3875a7 100644 --- a/client/src/app/checkout/checkout-review/checkout-review.component.html +++ b/client/src/app/checkout/checkout-review/checkout-review.component.html @@ -8,7 +8,7 @@ - diff --git a/client/src/app/checkout/checkout-review/checkout-review.component.ts b/client/src/app/checkout/checkout-review/checkout-review.component.ts index a390ef3..c930fd7 100644 --- a/client/src/app/checkout/checkout-review/checkout-review.component.ts +++ b/client/src/app/checkout/checkout-review/checkout-review.component.ts @@ -1,4 +1,6 @@ -import { Component, OnInit } from '@angular/core'; +import { CdkStepper } from '@angular/cdk/stepper'; +import { Component, Input, OnInit } from '@angular/core'; +import { ToastrService } from 'ngx-toastr'; import { Observable } from 'rxjs'; import { BasketService } from 'src/app/basket/basket.service'; import { IBasket } from 'src/app/shared/models/baskset'; @@ -9,12 +11,23 @@ import { IBasket } from 'src/app/shared/models/baskset'; styleUrls: ['./checkout-review.component.scss'] }) export class CheckoutReviewComponent implements OnInit { + @Input() appStepper: CdkStepper; basket$: Observable; - constructor(private basketService: BasketService) { } + constructor(private basketService: BasketService, private toastrService: ToastrService) { } ngOnInit(): void { this.basket$ = this.basketService.basket$; } + createPaymentIntent(){ + return this.basketService.createPaymentIntent().subscribe({ + next: (response: any) => { + this.appStepper.next(); + }, + error: (e: any ) => { console.log(e); }, + complete: () => { console.log('completed'); } + }) + } + } diff --git a/client/src/app/checkout/checkout.component.html b/client/src/app/checkout/checkout.component.html index 7216188..7eee6ab 100644 --- a/client/src/app/checkout/checkout.component.html +++ b/client/src/app/checkout/checkout.component.html @@ -1,7 +1,7 @@
- + @@ -9,7 +9,7 @@ - + diff --git a/client/src/app/checkout/checkout.component.ts b/client/src/app/checkout/checkout.component.ts index 4898fde..a37e881 100644 --- a/client/src/app/checkout/checkout.component.ts +++ b/client/src/app/checkout/checkout.component.ts @@ -20,6 +20,7 @@ export class CheckoutComponent implements OnInit { ngOnInit(): void { this.createCheckoutForm(); this.getAddressFormValues(); + this.getDeliveryMethodValue(); this.basketTotals$ = this.basketService.basketTotal$; } @@ -36,7 +37,7 @@ export class CheckoutComponent implements OnInit { deliveryForm: this.fb.group({ deliveryMethod: [null, Validators.required] }), - paymenForm: this.fb.group({ + paymentForm: this.fb.group({ nameOnCard: [null, Validators.required] }) }); @@ -54,4 +55,11 @@ export class CheckoutComponent implements OnInit { }); } + getDeliveryMethodValue(){ + const basket = this.basketService.getCurrentBasketValue(); + if(basket.deliveryMethodId !== null){ + this.checkoutForm.get('deliveryForm').get('deliveryMethod').patchValue(basket.deliveryMethodId.toString()); + } + } + } diff --git a/client/src/app/core/interceptors/loading.interceptor.ts b/client/src/app/core/interceptors/loading.interceptor.ts index a9d96b2..8325091 100644 --- a/client/src/app/core/interceptors/loading.interceptor.ts +++ b/client/src/app/core/interceptors/loading.interceptor.ts @@ -8,10 +8,14 @@ export class LoadingInterceptor implements HttpInterceptor { constructor(private busyService: BusyService) {} intercept(req: HttpRequest, next: HttpHandler): Observable> { - if(!req.url.includes('emailexists')){ - this.busyService.busy(); + if(req.method === 'POST' && req.url.includes('orders')){ + return next.handle(req); } + if(req.url.includes('emailexists')){ + return next.handle(req); + } + this.busyService.busy(); return next.handle(req).pipe( delay(1000), finalize(()=> { diff --git a/client/src/app/shared/models/baskset.ts b/client/src/app/shared/models/baskset.ts index 92227da..2487cbd 100644 --- a/client/src/app/shared/models/baskset.ts +++ b/client/src/app/shared/models/baskset.ts @@ -13,6 +13,10 @@ export interface IBasketItem { export interface IBasket { id: string; items: IBasketItem[]; + clientSecret?: string; + paymentIntentId?: string; + deliveryMethodId?: number; + shippingPrice?: number; } export class Basket implements IBasket { diff --git a/client/src/index.html b/client/src/index.html index 81abc14..7ff1e91 100644 --- a/client/src/index.html +++ b/client/src/index.html @@ -9,5 +9,6 @@ +