Stripe Payments Continued

This commit is contained in:
Charles Showalter 2022-05-30 13:25:27 -07:00
parent 9763ef9c25
commit a5e3cb84b7
17 changed files with 189 additions and 33 deletions

View File

@ -1,3 +1,4 @@
using API.Errors;
using Core.Entities; using Core.Entities;
using Core.Interfaces; using Core.Interfaces;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -17,7 +18,9 @@ namespace API.Controllers
[HttpPost("{basketId}")] [HttpPost("{basketId}")]
public async Task<ActionResult<CustomerBasket>> CreateOrUpdatePaymentIntent(string basketId) public async Task<ActionResult<CustomerBasket>> 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;
} }
} }
} }

View File

@ -10,5 +10,7 @@ namespace API.Dtos
public int? DeliveryMethodId { get; set; } public int? DeliveryMethodId { get; set; }
public string ClientSecret { get; set; } public string ClientSecret { get; set; }
public string PaymentItentId { get; set; } public string PaymentItentId { get; set; }
public decimal ShippingPrice { get; set; }
} }
} }

View File

@ -21,5 +21,6 @@ namespace Core.Entities
public int? DeliveryMethodId { get; set; } public int? DeliveryMethodId { get; set; }
public string ClientSecret { get; set; } public string ClientSecret { get; set; }
public string PaymentItentId { get; set; } public string PaymentItentId { get; set; }
public decimal ShippingPrice { get; set; }
} }
} }

View File

@ -6,13 +6,14 @@ namespace Core.Entities.OrderAggregate
{ {
} }
public Order(IReadOnlyList<OrderItem> orderItems, string buyerEmail, Address shipToAddress, DeliveryMethod deliveryMethod, decimal subtotal) public Order(IReadOnlyList<OrderItem> orderItems, string buyerEmail, Address shipToAddress, DeliveryMethod deliveryMethod, decimal subtotal, string paymentIntentId)
{ {
BuyerEmail = buyerEmail; BuyerEmail = buyerEmail;
ShipToAddress = shipToAddress; ShipToAddress = shipToAddress;
DeliveryMethod = deliveryMethod; DeliveryMethod = deliveryMethod;
OrderItems = orderItems; OrderItems = orderItems;
Subtotal = subtotal; Subtotal = subtotal;
PaymentIntentId = paymentIntentId;
} }
public string BuyerEmail { get; set; } public string BuyerEmail { get; set; }

View File

@ -0,0 +1,11 @@
using Core.Entities.OrderAggregate;
namespace Core.Specifications
{
public class OrderByPaymentIntentIdWithItemsSpecification : BaseSpecification<Order>
{
public OrderByPaymentIntentIdWithItemsSpecification(string paymentIntentId) : base(o => o.PaymentIntentId == paymentIntentId)
{
}
}
}

View File

@ -9,9 +9,11 @@ namespace Infrastructure.Services
{ {
private readonly IBasketRepository _basketRepo; private readonly IBasketRepository _basketRepo;
private readonly IUnitOfWork _unitOfWork; 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; _unitOfWork = unitOfWork;
_basketRepo = basketRepo; _basketRepo = basketRepo;
} }
@ -29,11 +31,19 @@ namespace Infrastructure.Services
} }
var deliveryMethod = await _unitOfWork.Repository<DeliveryMethod>().GetByIdAsync(deliverMethodId); var deliveryMethod = await _unitOfWork.Repository<DeliveryMethod>().GetByIdAsync(deliverMethodId);
var subtotal = items.Sum(item => item.Price * item.Quantity); 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<Order>().GetEntityWithSpec(spec);
if(existingOrder != null)
{
_unitOfWork.Repository<Order>().Delete(existingOrder);
await _paymentService.CreateOrUpdatePaymentIntent(basket.PaymentItentId);
}
var order = new Order(items, buyerEmail, shippingAddress, deliveryMethod, subtotal, basket.PaymentItentId);
_unitOfWork.Repository<Order>().Add(order); _unitOfWork.Repository<Order>().Add(order);
var result = await _unitOfWork.Complete(); var result = await _unitOfWork.Complete();
if(result <= 0) return null; if(result <= 0) return null;
await _basketRepo.DeleteBasketAsysnc(basketId);
return order; return order;
} }

View File

@ -23,6 +23,7 @@ namespace Infrastructure.Services
{ {
StripeConfiguration.ApiKey = _config["StripeSettings:SecretKey"]; StripeConfiguration.ApiKey = _config["StripeSettings:SecretKey"];
var basket = await _basketRepository.GetBasketAsync(basketId); var basket = await _basketRepository.GetBasketAsync(basketId);
if(basket == null) return null;
var shippingPrice = 0m; var shippingPrice = 0m;
if(basket.DeliveryMethodId.HasValue){ if(basket.DeliveryMethodId.HasValue){

View File

@ -19,15 +19,28 @@ export class BasketService {
constructor(private http: HttpClient) { } 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){ setShippingPrice(deliveryMethod: IDeliveryMethods){
this.shipping = deliveryMethod.price; this.shipping = deliveryMethod.price;
const basket = this.getCurrentBasketValue();
basket.deliveryMethodId = deliveryMethod.id;
basket.shippingPrice = deliveryMethod.price;
this.calculateTotals(); this.calculateTotals();
this.setBasket(basket);
} }
getBasket(id: string){ getBasket(id: string){
return this.http.get(this.baseUrl + 'basket?id=' + id).pipe( return this.http.get(this.baseUrl + 'basket?id=' + id).pipe(
map((basket: IBasket) => { map((basket: IBasket) => {
this.basketSource.next(basket); this.basketSource.next(basket);
this.shipping = basket.shippingPrice;
this.calculateTotals(); this.calculateTotals();
}) })
); );

View File

@ -1,9 +1,30 @@
<p>checkout-payments works!</p> <div class="mt-5 pb-3" [formGroup]="checkoutForm">
<div class="row">
<div class="form-group col-12" formGroupName="paymentForm">
<app-text-inputs [label]="'Name on card'" formControlName="nameOnCard"></app-text-inputs>
</div>
<div class="form-group col-6">
<div class="form-control py-3" #cardNumber></div>
<ng-container *ngIf="cardErrors">
<span class="text-warning">
{{cardErrors}}
</span>
</ng-container>
</div>
<div class="form-group col-3">
<div class="form-control py-3" #cardExpiry></div>
</div>
<div class="form-group col-3">
<div class="form-control py-3" #cardCvc></div>
</div>
</div>
</div>
<div class="float-none d-flex justify-content-between flex-column flex-lg-row mb-5"> <div class="float-none d-flex justify-content-between flex-column flex-lg-row mb-5">
<button class="btn btn-outline-primary" cdkStepperPrevious> <button class="btn btn-outline-primary" cdkStepperPrevious>
<i class="fa fa-angle-left"></i> Back to Review <i class="fa fa-angle-left"></i> Back to Review
</button> </button>
<button class="btn btn-primary" (click)="submitOrder()"> <button [disabled]="loading" class="btn btn-primary" (click)="submitOrder()">
Complete Checkout <i class="fa fa-angle-right"></i> Complete Checkout <i class="fa fa-angle-right"></i>
<i *ngIf="loading" class="fa fa-spinner fa-spin"></i>
</button> </button>
</div> </div>

View File

@ -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 { FormGroup } from '@angular/forms';
import { NavigationExtras, Router } from '@angular/router'; import { NavigationExtras, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { lastValueFrom } from 'rxjs';
import { BasketService } from 'src/app/basket/basket.service'; import { BasketService } from 'src/app/basket/basket.service';
import { IBasket } from 'src/app/shared/models/baskset'; import { IBasket } from 'src/app/shared/models/baskset';
import { IOrder } from 'src/app/shared/models/order'; import { IOrder } from 'src/app/shared/models/order';
import { CheckoutService } from '../checkout.service'; import { CheckoutService } from '../checkout.service';
declare var Stripe;
@Component({ @Component({
selector: 'app-checkout-payments', selector: 'app-checkout-payments',
templateUrl: './checkout-payments.component.html', templateUrl: './checkout-payments.component.html',
styleUrls: ['./checkout-payments.component.scss'] styleUrls: ['./checkout-payments.component.scss']
}) })
export class CheckoutPaymentsComponent implements OnInit { export class CheckoutPaymentsComponent implements AfterViewInit, OnDestroy {
@Input() checkoutForm: FormGroup; @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) { } 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(){ private async confirmPaymentWithStripe(basket: IBasket) {
const basket = this.basketService.getCurrentBasketValue(); return this.stripe.confirmCardPayment(basket.clientSecret, {
const orderToCreate = this.getOrderToCreate(basket); payment_method: {
this.checkoutService.createOrder(orderToCreate).subscribe({ card: this.cardNumber,
next: (order: IOrder) => { billing_details: {
this.toastr.success('Order created successfully'); name: this.checkoutForm.get('paymentForm').get('nameOnCard').value
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 createOrder(basket: IBasket) {
const orderToCreate = this.getOrderToCreate(basket);
return lastValueFrom(this.checkoutService.createOrder(orderToCreate));
}
private getOrderToCreate(basket: IBasket) { private getOrderToCreate(basket: IBasket) {
return { return {
basketId: basket.id, basketId: basket.id,

View File

@ -8,7 +8,7 @@
<button class="btn btn-outline-primary" cdkStepperPrevious> <button class="btn btn-outline-primary" cdkStepperPrevious>
<i class="fa fa-angle-left"></i> Back to Delivery <i class="fa fa-angle-left"></i> Back to Delivery
</button> </button>
<button class="btn btn-primary" cdkStepperNext> <button class="btn btn-primary" (click)="createPaymentIntent()">
Payment Options <i class="fa fa-angle-right"></i> Payment Options <i class="fa fa-angle-right"></i>
</button> </button>
</div> </div>

View File

@ -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 { Observable } from 'rxjs';
import { BasketService } from 'src/app/basket/basket.service'; import { BasketService } from 'src/app/basket/basket.service';
import { IBasket } from 'src/app/shared/models/baskset'; 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'] styleUrls: ['./checkout-review.component.scss']
}) })
export class CheckoutReviewComponent implements OnInit { export class CheckoutReviewComponent implements OnInit {
@Input() appStepper: CdkStepper;
basket$: Observable<IBasket>; basket$: Observable<IBasket>;
constructor(private basketService: BasketService) { } constructor(private basketService: BasketService, private toastrService: ToastrService) { }
ngOnInit(): void { ngOnInit(): void {
this.basket$ = this.basketService.basket$; 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'); }
})
}
} }

View File

@ -1,7 +1,7 @@
<div class="container mt-5"> <div class="container mt-5">
<div class="row"> <div class="row">
<div class="col-8"> <div class="col-8">
<app-stepper [linearModeSelected]="false" #appStepper> <app-stepper [linearModeSelected]="true" #appStepper>
<cdk-step [label]="'Address'" [completed]="(checkoutForm.get('addressForm')).valid"> <cdk-step [label]="'Address'" [completed]="(checkoutForm.get('addressForm')).valid">
<app-checkout-address [checkoutForm]="checkoutForm"></app-checkout-address> <app-checkout-address [checkoutForm]="checkoutForm"></app-checkout-address>
</cdk-step> </cdk-step>
@ -9,7 +9,7 @@
<app-checkout-delivery [checkoutForm]="checkoutForm"></app-checkout-delivery> <app-checkout-delivery [checkoutForm]="checkoutForm"></app-checkout-delivery>
</cdk-step> </cdk-step>
<cdk-step [label]="'Review'"> <cdk-step [label]="'Review'">
<app-checkout-review></app-checkout-review> <app-checkout-review [appStepper]="appStepper"></app-checkout-review>
</cdk-step> </cdk-step>
<cdk-step [label]="'Payment'"> <cdk-step [label]="'Payment'">
<app-checkout-payments [checkoutForm]="checkoutForm"></app-checkout-payments> <app-checkout-payments [checkoutForm]="checkoutForm"></app-checkout-payments>

View File

@ -20,6 +20,7 @@ export class CheckoutComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.createCheckoutForm(); this.createCheckoutForm();
this.getAddressFormValues(); this.getAddressFormValues();
this.getDeliveryMethodValue();
this.basketTotals$ = this.basketService.basketTotal$; this.basketTotals$ = this.basketService.basketTotal$;
} }
@ -36,7 +37,7 @@ export class CheckoutComponent implements OnInit {
deliveryForm: this.fb.group({ deliveryForm: this.fb.group({
deliveryMethod: [null, Validators.required] deliveryMethod: [null, Validators.required]
}), }),
paymenForm: this.fb.group({ paymentForm: this.fb.group({
nameOnCard: [null, Validators.required] 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());
}
}
} }

View File

@ -8,10 +8,14 @@ export class LoadingInterceptor implements HttpInterceptor {
constructor(private busyService: BusyService) {} constructor(private busyService: BusyService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if(!req.url.includes('emailexists')){ if(req.method === 'POST' && req.url.includes('orders')){
this.busyService.busy(); return next.handle(req);
} }
if(req.url.includes('emailexists')){
return next.handle(req);
}
this.busyService.busy();
return next.handle(req).pipe( return next.handle(req).pipe(
delay(1000), delay(1000),
finalize(()=> { finalize(()=> {

View File

@ -13,6 +13,10 @@ export interface IBasketItem {
export interface IBasket { export interface IBasket {
id: string; id: string;
items: IBasketItem[]; items: IBasketItem[];
clientSecret?: string;
paymentIntentId?: string;
deliveryMethodId?: number;
shippingPrice?: number;
} }
export class Basket implements IBasket { export class Basket implements IBasket {

View File

@ -9,5 +9,6 @@
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
<script src="https://js.stripe.com/v3/"></script>
</body> </body>
</html> </html>