Skip to content

Commit 109a6f8

Browse files
committed
Enhance billing service with BYOK usage tracking and PayPal improvements
1 parent 7e1df4c commit 109a6f8

7 files changed

Lines changed: 333 additions & 57 deletions

File tree

cloud/deploy/config.sample.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,5 +166,8 @@
166166
"rateLimitPerMinute": 10
167167
}
168168
}
169+
},
170+
"superAdmin": {
171+
"emails": ["admin@example.com"]
169172
}
170173
}

cloud/src/LrmCloud.Api/Services/Billing/IPaymentProvider.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,9 @@ public interface IPaymentProvider
117117
/// </summary>
118118
/// <param name="payload">Raw webhook payload body.</param>
119119
/// <param name="signature">Signature header value (provider-specific).</param>
120+
/// <param name="headers">Additional HTTP headers needed for verification (PayPal requires multiple headers).</param>
120121
/// <returns>Parsed webhook result with normalized event data.</returns>
121-
Task<WebhookResult> ProcessWebhookAsync(string payload, string? signature);
122+
Task<WebhookResult> ProcessWebhookAsync(string payload, string? signature, IDictionary<string, string>? headers = null);
122123

123124
#endregion
124125
}

cloud/src/LrmCloud.Api/Services/Billing/Providers/PayPalPaymentProvider.cs

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -375,12 +375,12 @@ private static long ParsePayPalAmount(string? value)
375375
#region Webhooks
376376

377377
/// <inheritdoc />
378-
public async Task<WebhookResult> ProcessWebhookAsync(string payload, string? signature)
378+
public async Task<WebhookResult> ProcessWebhookAsync(string payload, string? signature, IDictionary<string, string>? headers = null)
379379
{
380380
EnsureEnabled();
381381

382-
// Verify webhook signature
383-
var isValid = await VerifyWebhookSignatureAsync(payload, signature);
382+
// Verify webhook signature using PayPal's verification API
383+
var isValid = await VerifyWebhookSignatureAsync(payload, signature, headers);
384384
if (!isValid)
385385
{
386386
_logger.LogWarning("PayPal webhook signature verification failed");
@@ -490,18 +490,82 @@ private WebhookResult HandlePaymentCompleted(PayPalWebhookEvent webhookEvent)
490490
};
491491
}
492492

493-
private async Task<bool> VerifyWebhookSignatureAsync(string payload, string? signature)
493+
private async Task<bool> VerifyWebhookSignatureAsync(string payload, string? signature, IDictionary<string, string>? headers)
494494
{
495495
if (string.IsNullOrEmpty(PayPalConfig?.WebhookId))
496496
{
497497
_logger.LogWarning("PayPal webhook ID not configured, skipping signature verification");
498-
return true; // Allow in development
498+
return true; // Allow in development only
499499
}
500500

501-
// PayPal webhook verification requires multiple headers
502-
// For simplicity, we'll trust the webhook if the webhook ID is configured
503-
// In production, implement full signature verification
504-
return true;
501+
if (headers == null || string.IsNullOrEmpty(signature))
502+
{
503+
_logger.LogWarning("PayPal webhook missing required headers for verification");
504+
return false;
505+
}
506+
507+
// Extract required headers for PayPal verification
508+
headers.TryGetValue("PAYPAL-AUTH-ALGO", out var authAlgo);
509+
headers.TryGetValue("PAYPAL-CERT-URL", out var certUrl);
510+
headers.TryGetValue("PAYPAL-TRANSMISSION-ID", out var transmissionId);
511+
headers.TryGetValue("PAYPAL-TRANSMISSION-TIME", out var transmissionTime);
512+
513+
if (string.IsNullOrEmpty(authAlgo) || string.IsNullOrEmpty(certUrl) ||
514+
string.IsNullOrEmpty(transmissionId) || string.IsNullOrEmpty(transmissionTime))
515+
{
516+
_logger.LogWarning("PayPal webhook missing required verification headers");
517+
return false;
518+
}
519+
520+
try
521+
{
522+
var accessToken = await GetAccessTokenAsync();
523+
524+
// Build the verification request per PayPal's API
525+
var verificationRequest = new PayPalWebhookVerificationRequest
526+
{
527+
AuthAlgo = authAlgo,
528+
CertUrl = certUrl,
529+
TransmissionId = transmissionId,
530+
TransmissionSig = signature,
531+
TransmissionTime = transmissionTime,
532+
WebhookId = PayPalConfig.WebhookId,
533+
WebhookEvent = JsonSerializer.Deserialize<JsonElement>(payload)
534+
};
535+
536+
var request = new HttpRequestMessage(HttpMethod.Post,
537+
$"{PayPalConfig.ApiBaseUrl}/v1/notifications/verify-webhook-signature");
538+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
539+
request.Content = new StringContent(
540+
JsonSerializer.Serialize(verificationRequest, JsonOptions),
541+
Encoding.UTF8, "application/json");
542+
543+
var response = await _httpClient.SendAsync(request);
544+
var responseBody = await response.Content.ReadAsStringAsync();
545+
546+
if (!response.IsSuccessStatusCode)
547+
{
548+
_logger.LogWarning("PayPal webhook verification API failed: {StatusCode} - {Body}",
549+
response.StatusCode, responseBody);
550+
return false;
551+
}
552+
553+
var result = JsonSerializer.Deserialize<PayPalVerificationResponse>(responseBody, JsonOptions);
554+
var isValid = result?.VerificationStatus == "SUCCESS";
555+
556+
if (!isValid)
557+
{
558+
_logger.LogWarning("PayPal webhook verification returned status: {Status}",
559+
result?.VerificationStatus ?? "null");
560+
}
561+
562+
return isValid;
563+
}
564+
catch (Exception ex)
565+
{
566+
_logger.LogError(ex, "Error verifying PayPal webhook signature");
567+
return false;
568+
}
505569
}
506570

507571
#endregion
@@ -754,5 +818,36 @@ private class PayPalMoney
754818
public string? Value { get; set; }
755819
}
756820

821+
// Models for webhook signature verification
822+
private class PayPalWebhookVerificationRequest
823+
{
824+
[JsonPropertyName("auth_algo")]
825+
public string? AuthAlgo { get; set; }
826+
827+
[JsonPropertyName("cert_url")]
828+
public string? CertUrl { get; set; }
829+
830+
[JsonPropertyName("transmission_id")]
831+
public string? TransmissionId { get; set; }
832+
833+
[JsonPropertyName("transmission_sig")]
834+
public string? TransmissionSig { get; set; }
835+
836+
[JsonPropertyName("transmission_time")]
837+
public string? TransmissionTime { get; set; }
838+
839+
[JsonPropertyName("webhook_id")]
840+
public string? WebhookId { get; set; }
841+
842+
[JsonPropertyName("webhook_event")]
843+
public JsonElement WebhookEvent { get; set; }
844+
}
845+
846+
private class PayPalVerificationResponse
847+
{
848+
[JsonPropertyName("verification_status")]
849+
public string? VerificationStatus { get; set; }
850+
}
851+
757852
#endregion
758853
}

cloud/src/LrmCloud.Api/Services/Billing/Providers/StripePaymentProvider.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,9 @@ public async Task<string> GetUpdatePaymentMethodUrlAsync(string customerId, stri
249249
#region Webhooks
250250

251251
/// <inheritdoc />
252-
public Task<WebhookResult> ProcessWebhookAsync(string payload, string? signature)
252+
public Task<WebhookResult> ProcessWebhookAsync(string payload, string? signature, IDictionary<string, string>? headers = null)
253253
{
254+
// Stripe uses only the signature header, so headers parameter is ignored
254255
EnsureEnabled();
255256

256257
Event stripeEvent;

0 commit comments

Comments
 (0)