Overview
AndanDo integrates PayPal’s REST API for secure payment processing. The integration supports order creation, payment approval flows, and multiple currencies. All payment operations are handled server-side to ensure security and PCI compliance.
PayPal REST API Direct integration with PayPal v2 Checkout API
Multi-Currency Support for DOP, USD, EUR, and other currencies
Server-Side Processing All payment logic runs on secure backend
OAuth 2.0 Secure authentication with PayPal using client credentials
PayPal Service Architecture
Service Interface
public interface IPaypalService
{
Task < PaypalOrderResult ?> CreateOrderAsync (
decimal amount ,
string currency ,
string returnUrl ,
string cancelUrl
);
}
public record PaypalOrderResult (
string OrderId ,
string ? ApprovalUrl
);
Service Implementation
public class PaypalService : IPaypalService
{
private readonly HttpClient _httpClient ;
private readonly PaypalOptions _options ;
public PaypalService ( HttpClient httpClient , IOptions < PaypalOptions > options )
{
_httpClient = httpClient ;
_options = options . Value ;
}
public async Task < PaypalOrderResult ?> CreateOrderAsync (
decimal amount ,
string currency ,
string returnUrl ,
string cancelUrl )
{
// 1. Acquire access token
var accessToken = await AcquireAccessTokenAsync ();
if ( string . IsNullOrWhiteSpace ( accessToken ))
{
return null ;
}
// 2. Create order
using var request = new HttpRequestMessage (
HttpMethod . Post ,
$" { _options . BaseUrl . TrimEnd ( '/' )} /v2/checkout/orders"
);
request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , accessToken );
var body = new
{
intent = "CAPTURE" ,
purchase_units = new []
{
new
{
amount = new
{
currency_code = currency . ToUpperInvariant (),
value = amount . ToString ( "0.00" , CultureInfo . InvariantCulture )
}
}
},
application_context = new
{
return_url = returnUrl ,
cancel_url = cancelUrl
}
};
request . Content = new StringContent (
JsonSerializer . Serialize ( body ),
Encoding . UTF8 ,
"application/json"
);
var response = await _httpClient . SendAsync ( request );
if ( ! response . IsSuccessStatusCode )
{
Console . WriteLine ( $"PayPal order failed: { response . StatusCode } " );
return null ;
}
await using var stream = await response . Content . ReadAsStreamAsync ();
var order = await JsonSerializer . DeserializeAsync < PaypalOrderResponse >( stream );
var approval = order ? . links ? . FirstOrDefault (
l => string . Equals ( l . rel , "approve" , StringComparison . OrdinalIgnoreCase )
) ? . href ;
return order is null ? null : new PaypalOrderResult ( order . id ?? string . Empty , approval );
}
private async Task < string ?> AcquireAccessTokenAsync ()
{
var creds = Convert . ToBase64String (
Encoding . UTF8 . GetBytes ( $" { _options . ClientId } : { _options . ClientSecret } " )
);
using var request = new HttpRequestMessage (
HttpMethod . Post ,
$" { _options . BaseUrl . TrimEnd ( '/' )} /v1/oauth2/token"
);
request . Headers . Authorization = new AuthenticationHeaderValue ( "Basic" , creds );
request . Content = new StringContent (
"grant_type=client_credentials" ,
Encoding . UTF8 ,
"application/x-www-form-urlencoded"
);
var response = await _httpClient . SendAsync ( request );
if ( ! response . IsSuccessStatusCode )
{
Console . WriteLine ( $"PayPal token failed: { response . StatusCode } " );
return null ;
}
await using var stream = await response . Content . ReadAsStreamAsync ();
var token = await JsonSerializer . DeserializeAsync < PaypalTokenResponse >( stream );
return token ? . access_token ;
}
private record PaypalTokenResponse (
string ? scope ,
string ? access_token ,
string ? token_type ,
string ? app_id ,
int expires_in
);
private record PaypalOrderResponse (
string ? id ,
string ? status ,
PaypalLink []? links
);
private record PaypalLink (
string ? href ,
string ? rel ,
string ? method
);
}
Configuration
PayPal Options
public sealed class PaypalOptions
{
public string ClientId { get ; set ; } = string . Empty ;
public string ClientSecret { get ; set ; } = string . Empty ;
public string BaseUrl { get ; set ; } = "https://api-m.sandbox.paypal.com" ;
}
appsettings.json
{
"Paypal" : {
"ClientId" : "your-paypal-client-id" ,
"ClientSecret" : "your-paypal-client-secret" ,
"BaseUrl" : "https://api-m.sandbox.paypal.com"
}
}
Production vs Sandbox:
Sandbox: https://api-m.sandbox.paypal.com
Production: https://api-m.paypal.com
Always use sandbox credentials during development!
Service Registration
builder . Services . Configure < PaypalOptions >( builder . Configuration . GetSection ( "Paypal" ));
builder . Services . AddHttpClient < IPaypalService , PaypalService >();
Payment Flow
Step 1: Create PayPal Order
private async Task InitiatePaymentAsync ()
{
var amount = CalculateTotalAmount ();
var returnUrl = $" { NavigationManager . BaseUri } payment/return" ;
var cancelUrl = $" { NavigationManager . BaseUri } payment/cancel" ;
var result = await PaypalService . CreateOrderAsync (
amount ,
"DOP" ,
returnUrl ,
cancelUrl
);
if ( result is null || string . IsNullOrWhiteSpace ( result . ApprovalUrl ))
{
ShowError ( "No pudimos iniciar el pago. Intenta de nuevo." );
return ;
}
// Create reservation in pending state
var reservationId = await CreatePendingReservationAsync ();
// Store reservation ID for return handling
await Storage . SetAsync ( "pending_reservation_id" , reservationId );
await Storage . SetAsync ( "paypal_order_id" , result . OrderId );
// Redirect to PayPal
NavigationManager . NavigateTo ( result . ApprovalUrl , forceLoad : true );
}
Calculate Total
Sum all ticket prices and extras
Create PayPal Order
Call PayPal API to create order and get approval URL
Create Pending Reservation
Store reservation with PaymentStatus = 0 (Pending)
Redirect to PayPal
Send user to PayPal approval page
Step 2: PayPal Approval
User completes payment on PayPal’s hosted page. PayPal redirects to your return URL with order details:
https://yoursite.com/payment/return?token=ORDER-123&PayerID=PAYER-456
Step 3: Payment Return Handler
@page "/payment/return"
@inject IPaypalService PaypalService
@inject ITourService TourService
@inject ProtectedLocalStorage Storage
@inject NavigationManager NavigationManager
< div class = "container" >
@if ( _isProcessing )
{
< div class = "text-center" >
< div class = "spinner-border text-accent-1" style = "width: 3rem; height: 3rem;" ></ div >
< h3 > Procesando tu pago... </ h3 >
< p > No cierres esta ventana. </ p >
</ div >
}
else if ( _success )
{
< div class = "alert alert-success" >
< h3 > ¡Pago exitoso! </ h3 >
< p > Tu reserva ha sido confirmada. Reserva # @ _reservationId </ p >
< a href = "/hosts/managing-bookings" class = "btn btn-primary" > Ver mis reservas </ a >
</ div >
}
else
{
< div class = "alert alert-danger" >
< h3 > Error al procesar el pago </ h3 >
< p > @ _errorMessage </ p >
< a href = "/features/marketplace" class = "btn btn-secondary" > Volver al marketplace </ a >
</ div >
}
</ div >
@code {
[ Parameter , SupplyParameterFromQuery ( Name = "token" )]
public string ? OrderToken { get ; set ; }
[ Parameter , SupplyParameterFromQuery ( Name = "PayerID" )]
public string ? PayerId { get ; set ; }
private bool _isProcessing = true ;
private bool _success ;
private int ? _reservationId ;
private string ? _errorMessage ;
protected override async Task OnInitializedAsync ()
{
try
{
// 1. Retrieve stored reservation ID
var storedReservation = await Storage . GetAsync < int >( "pending_reservation_id" );
if ( ! storedReservation . Success || storedReservation . Value <= 0 )
{
_errorMessage = "No se encontró la reserva pendiente." ;
return ;
}
_reservationId = storedReservation . Value ;
// 2. Validate PayPal order token
if ( string . IsNullOrWhiteSpace ( OrderToken ))
{
_errorMessage = "Token de PayPal inválido." ;
return ;
}
// 3. Update reservation status
await TourService . UpdateReservationPaymentStatusAsync (
_reservationId . Value ,
1 , // PaymentStatus = 1 (Completed)
"PayPal" ,
OrderToken
);
// 4. Clear pending data
await Storage . DeleteAsync ( "pending_reservation_id" );
await Storage . DeleteAsync ( "paypal_order_id" );
_success = true ;
}
catch ( Exception ex )
{
Console . WriteLine ( ex );
_errorMessage = "Ocurrió un error al confirmar tu pago. Contáctanos para verificar tu reserva." ;
}
finally
{
_isProcessing = false ;
}
}
}
Step 4: Payment Cancellation
@page "/payment/cancel"
@inject ProtectedLocalStorage Storage
@inject NavigationManager NavigationManager
< div class = "container" >
< div class = "alert alert-warning" >
< h3 > Pago cancelado </ h3 >
< p > Has cancelado el proceso de pago. Tu reserva no ha sido confirmada. </ p >
< button class = "btn btn-primary" @onclick = "RetryPayment" > Intentar de nuevo </ button >
< a href = "/marketplace" class = "btn btn-secondary" > Volver al marketplace </ a >
</ div >
</ div >
@code {
private async Task RetryPayment ()
{
var storedReservation = await Storage . GetAsync < int >( "pending_reservation_id" );
if ( storedReservation . Success && storedReservation . Value > 0 )
{
// Redirect back to tour details to retry
var tourId = await GetTourIdFromReservationAsync ( storedReservation . Value );
NavigationManager . NavigateTo ( $"/tours/details/ { tourId } " );
}
else
{
NavigationManager . NavigateTo ( "/marketplace" );
}
}
}
Payment Status Management
Reservations track payment status:
public async Task UpdateReservationPaymentStatusAsync (
int reservationId ,
byte paymentStatus ,
string ? paymentProvider ,
string ? paymentReference ,
CancellationToken cancellationToken = default )
{
if ( reservationId <= 0 )
{
throw new ArgumentException ( "ReservationId invalido." , nameof ( reservationId ));
}
await using var connection = CreateConnection ();
await connection . OpenAsync ( cancellationToken );
var sql = $@"
UPDATE TourReservation
SET PaymentStatus = @PaymentStatus,
PaymentProvider = @PaymentProvider,
PaymentReference = @PaymentReference,
UpdatedAt = SYSUTCDATETIME()
WHERE TourReservationId = @ReservationId;
" ;
await using var cmd = new SqlCommand ( sql , connection );
cmd . Parameters . Add ( new SqlParameter ( "@PaymentStatus" , SqlDbType . TinyInt )
{ Value = paymentStatus });
cmd . Parameters . AddWithValue ( "@PaymentProvider" , ( object ?) paymentProvider ?? DBNull . Value );
cmd . Parameters . AddWithValue ( "@PaymentReference" , ( object ?) paymentReference ?? DBNull . Value );
cmd . Parameters . Add ( new SqlParameter ( "@ReservationId" , SqlDbType . Int )
{ Value = reservationId });
var rows = await cmd . ExecuteNonQueryAsync ( cancellationToken );
if ( rows == 0 )
{
throw new InvalidOperationException ( "No se encontro la reserva a actualizar." );
}
}
Payment Status Codes
Status Description 0 Pending 1 Completed 2 Failed 3 Refunded 4 Cancelled
Multi-Currency Support
PayPal supports multiple currencies. Ensure amounts are formatted correctly:
var body = new
{
intent = "CAPTURE" ,
purchase_units = new []
{
new
{
amount = new
{
currency_code = currency . ToUpperInvariant (), // "DOP", "USD", etc.
value = amount . ToString ( "0.00" , CultureInfo . InvariantCulture ) // "150.00"
}
}
}
};
Always use CultureInfo.InvariantCulture when formatting amounts for PayPal to ensure consistent decimal separators (period, not comma).
Error Handling
Common PayPal Errors
public async Task < PaypalOrderResult ?> CreateOrderAsync (
decimal amount ,
string currency ,
string returnUrl ,
string cancelUrl )
{
try
{
var accessToken = await AcquireAccessTokenAsync ();
if ( string . IsNullOrWhiteSpace ( accessToken ))
{
Console . WriteLine ( "Failed to acquire PayPal access token" );
return null ;
}
// ... create order ...
var response = await _httpClient . SendAsync ( request );
if ( ! response . IsSuccessStatusCode )
{
var errorBody = await response . Content . ReadAsStringAsync ();
Console . WriteLine ( $"PayPal order failed: { response . StatusCode } " );
Console . WriteLine ( $"Error details: { errorBody } " );
return null ;
}
// ... parse response ...
}
catch ( HttpRequestException ex )
{
Console . WriteLine ( $"Network error communicating with PayPal: { ex . Message } " );
return null ;
}
catch ( Exception ex )
{
Console . WriteLine ( $"Unexpected error creating PayPal order: { ex . Message } " );
return null ;
}
}
Security Best Practices
Never Expose Secrets
Store ClientId and ClientSecret in secure configuration
Never commit credentials to source control
Use environment variables in production
Validate Server-Side
Always verify payment status on your server
Don’t trust client-side payment confirmations
Use PayPal webhooks for real-time updates
Handle Failures Gracefully
Implement retry logic for network failures
Log all payment attempts
Provide clear error messages to users
Prevent Duplicate Payments
Check reservation status before creating orders
Use idempotency keys
Handle concurrent payment attempts
PayPal Webhooks
For production, implement webhook handlers to receive real-time payment updates:
[ ApiController ]
[ Route ( "api/webhooks/paypal" )]
public class PaypalWebhookController : ControllerBase
{
private readonly ITourService _tourService ;
[ HttpPost ]
public async Task < IActionResult > HandleWebhook ([ FromBody ] JsonElement payload )
{
// 1. Verify webhook signature (important!)
var isValid = await VerifyPaypalWebhookSignatureAsync ( payload );
if ( ! isValid )
{
return Unauthorized ();
}
// 2. Parse event type
var eventType = payload . GetProperty ( "event_type" ). GetString ();
switch ( eventType )
{
case "PAYMENT.CAPTURE.COMPLETED" :
await HandlePaymentCompletedAsync ( payload );
break ;
case "PAYMENT.CAPTURE.DENIED" :
await HandlePaymentDeniedAsync ( payload );
break ;
case "PAYMENT.CAPTURE.REFUNDED" :
await HandlePaymentRefundedAsync ( payload );
break ;
}
return Ok ();
}
}
Testing
Sandbox Test Accounts
Use PayPal sandbox test accounts for development:
Create test accounts at developer.paypal.com
Use sandbox credentials in appsettings.Development.json
Test with PayPal’s test credit cards
Test Credit Cards
Card Number Type CVV 4032034740267099 Visa Any 3 digits 5425162585592488 Mastercard Any 3 digits
Never use real credit cards in sandbox environment!
Next Steps
Implement Webhooks Set up PayPal webhooks for real-time payment notifications
Add Refund Support Implement refund processing through PayPal API
Support More Gateways Add Stripe, Square, or local payment processors
Implement Subscriptions Use PayPal subscriptions for recurring tour bookings