Skip to content

Commit 6f881c2

Browse files
committed
Add invoice history and payment method display to billing UI
- Add InvoiceDto and PaymentMethodDto shared DTOs - Add API endpoints for invoices, payment method, and update payment URL - Add Web BillingService methods for new endpoints - Update Billing.razor with payment method card and invoice history table - Implement PayPal GetInvoicesAsync using transactions API - Implement PayPal GetPaymentMethodAsync using subscription details - Add PayPal API response models for transactions
1 parent db4fe76 commit 6f881c2

6 files changed

Lines changed: 682 additions & 14 deletions

File tree

cloud/src/LrmCloud.Api/Controllers/BillingController.cs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,4 +173,125 @@ public async Task<ActionResult<ApiResponse>> ReactivateSubscription()
173173
return BadRequest("reactivate-failed", "Failed to reactivate subscription");
174174
}
175175
}
176+
177+
/// <summary>
178+
/// Get the user's invoice history.
179+
/// </summary>
180+
[HttpGet("invoices")]
181+
public async Task<ActionResult<ApiResponse<List<InvoiceDto>>>> GetInvoices([FromQuery] int limit = 10)
182+
{
183+
if (!_billingService.IsEnabled)
184+
{
185+
return Success(new List<InvoiceDto>());
186+
}
187+
188+
var userId = GetUserId();
189+
190+
try
191+
{
192+
var invoices = await _billingService.GetInvoicesAsync(userId, limit);
193+
var invoiceDtos = invoices.Select(i => new InvoiceDto
194+
{
195+
InvoiceId = i.InvoiceId,
196+
InvoiceNumber = i.InvoiceNumber,
197+
Status = i.Status.ToString().ToLowerInvariant(),
198+
AmountTotal = i.AmountTotal,
199+
AmountPaid = i.AmountPaid,
200+
Currency = i.Currency,
201+
CreatedAt = i.CreatedAt,
202+
PaidAt = i.PaidAt,
203+
DueDate = i.DueDate,
204+
PdfUrl = i.PdfUrl,
205+
HostedUrl = i.HostedUrl,
206+
Description = i.Description,
207+
PeriodStart = i.PeriodStart,
208+
PeriodEnd = i.PeriodEnd
209+
}).ToList();
210+
211+
return Success(invoiceDtos);
212+
}
213+
catch (Exception ex)
214+
{
215+
_logger.LogWarning(ex, "Failed to get invoices for user {UserId}", userId);
216+
return Success(new List<InvoiceDto>());
217+
}
218+
}
219+
220+
/// <summary>
221+
/// Get the user's current payment method.
222+
/// </summary>
223+
[HttpGet("payment-method")]
224+
public async Task<ActionResult<ApiResponse<PaymentMethodDto?>>> GetPaymentMethod()
225+
{
226+
if (!_billingService.IsEnabled)
227+
{
228+
return Success<PaymentMethodDto?>(null);
229+
}
230+
231+
var userId = GetUserId();
232+
233+
try
234+
{
235+
var pm = await _billingService.GetPaymentMethodAsync(userId);
236+
if (pm == null)
237+
{
238+
return Success<PaymentMethodDto?>(null);
239+
}
240+
241+
return Success<PaymentMethodDto?>(new PaymentMethodDto
242+
{
243+
PaymentMethodId = pm.PaymentMethodId,
244+
Type = pm.Type.ToString().ToLowerInvariant(),
245+
CardBrand = pm.CardBrand,
246+
CardLast4 = pm.CardLast4,
247+
CardExpMonth = pm.CardExpMonth,
248+
CardExpYear = pm.CardExpYear,
249+
PayPalEmail = pm.PayPalEmail,
250+
BankName = pm.BankName,
251+
BankLast4 = pm.BankLast4,
252+
IsDefault = pm.IsDefault,
253+
DisplayString = pm.DisplayString
254+
});
255+
}
256+
catch (Exception ex)
257+
{
258+
_logger.LogWarning(ex, "Failed to get payment method for user {UserId}", userId);
259+
return Success<PaymentMethodDto?>(null);
260+
}
261+
}
262+
263+
/// <summary>
264+
/// Get URL to update payment method.
265+
/// </summary>
266+
[HttpGet("update-payment-url")]
267+
public async Task<IActionResult> GetUpdatePaymentUrl([FromQuery] string returnUrl)
268+
{
269+
if (!_billingService.IsEnabled)
270+
{
271+
return BadRequest("billing-disabled", "Billing is not enabled");
272+
}
273+
274+
if (string.IsNullOrEmpty(returnUrl))
275+
{
276+
return BadRequest("invalid-request", "Return URL is required");
277+
}
278+
279+
var userId = GetUserId();
280+
281+
try
282+
{
283+
var url = await _billingService.GetUpdatePaymentMethodUrlAsync(userId, returnUrl);
284+
return Ok(new ApiResponse<string> { Data = url });
285+
}
286+
catch (InvalidOperationException ex)
287+
{
288+
_logger.LogWarning(ex, "Get update payment URL failed for user {UserId}", userId);
289+
return BadRequest("update-payment-failed", ex.Message);
290+
}
291+
catch (Exception ex)
292+
{
293+
_logger.LogError(ex, "Failed to get update payment URL for user {UserId}", userId);
294+
return BadRequest("update-payment-failed", "Failed to get update payment URL");
295+
}
296+
}
176297
}

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

Lines changed: 203 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -205,25 +205,127 @@ public async Task ReactivateSubscriptionAsync(string subscriptionId)
205205
#region Custom Portal Support
206206

207207
/// <inheritdoc />
208-
public async Task<List<InvoiceInfo>> GetInvoicesAsync(string customerId, int limit = 10)
208+
public async Task<List<InvoiceInfo>> GetInvoicesAsync(string subscriptionId, int limit = 10)
209209
{
210-
// PayPal's invoicing API is separate from subscriptions
211-
// For now, return an empty list - invoices are managed through PayPal's dashboard
212-
return new List<InvoiceInfo>();
210+
if (!IsEnabled || string.IsNullOrEmpty(subscriptionId))
211+
{
212+
return new List<InvoiceInfo>();
213+
}
214+
215+
try
216+
{
217+
var accessToken = await GetAccessTokenAsync();
218+
219+
// PayPal Subscriptions API - list transactions for a subscription
220+
// Time range: last year to now
221+
var startTime = DateTime.UtcNow.AddYears(-1).ToString("yyyy-MM-ddTHH:mm:ssZ");
222+
var endTime = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
223+
224+
var request = new HttpRequestMessage(HttpMethod.Get,
225+
$"{PayPalConfig!.ApiBaseUrl}/v1/billing/subscriptions/{subscriptionId}/transactions?start_time={startTime}&end_time={endTime}");
226+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
227+
228+
var response = await _httpClient.SendAsync(request);
229+
230+
if (!response.IsSuccessStatusCode)
231+
{
232+
var errorBody = await response.Content.ReadAsStringAsync();
233+
_logger.LogWarning("PayPal get transactions failed: {StatusCode} - {Body}",
234+
response.StatusCode, errorBody);
235+
return new List<InvoiceInfo>();
236+
}
237+
238+
var responseBody = await response.Content.ReadAsStringAsync();
239+
var transactionsResponse = JsonSerializer.Deserialize<PayPalTransactionsResponse>(responseBody, JsonOptions);
240+
241+
if (transactionsResponse?.Transactions == null)
242+
{
243+
return new List<InvoiceInfo>();
244+
}
245+
246+
return transactionsResponse.Transactions
247+
.Take(limit)
248+
.Select(t => new InvoiceInfo
249+
{
250+
InvoiceId = t.Id ?? "",
251+
InvoiceNumber = t.Id,
252+
Status = MapTransactionStatus(t.Status),
253+
AmountTotal = ParsePayPalAmount(t.AmountWithBreakdown?.GrossAmount?.Value),
254+
AmountPaid = t.Status == "COMPLETED" ? ParsePayPalAmount(t.AmountWithBreakdown?.GrossAmount?.Value) : 0,
255+
Currency = t.AmountWithBreakdown?.GrossAmount?.CurrencyCode?.ToLowerInvariant() ?? "usd",
256+
CreatedAt = t.Time ?? DateTime.UtcNow,
257+
PaidAt = t.Status == "COMPLETED" ? t.Time : null,
258+
DueDate = null,
259+
PdfUrl = null, // PayPal doesn't provide PDF receipts via API
260+
HostedUrl = $"https://www.paypal.com/activity/payment/{t.Id}",
261+
Description = $"Subscription payment",
262+
PeriodStart = null,
263+
PeriodEnd = null
264+
})
265+
.ToList();
266+
}
267+
catch (Exception ex)
268+
{
269+
_logger.LogWarning(ex, "Failed to get PayPal transactions for subscription {SubscriptionId}", subscriptionId);
270+
return new List<InvoiceInfo>();
271+
}
213272
}
214273

215274
/// <inheritdoc />
216-
public Task<PaymentMethodInfo?> GetPaymentMethodAsync(string customerId)
275+
public async Task<PaymentMethodInfo?> GetPaymentMethodAsync(string subscriptionId)
217276
{
218-
// PayPal doesn't expose payment method details in the same way
219-
// Return a generic PayPal payment method
220-
return Task.FromResult<PaymentMethodInfo?>(new PaymentMethodInfo
277+
if (!IsEnabled || string.IsNullOrEmpty(subscriptionId))
221278
{
222-
PaymentMethodId = "paypal",
223-
Type = PaymentMethodType.PayPal,
224-
PayPalEmail = null, // Would need to query subscription details
225-
IsDefault = true
226-
});
279+
return null;
280+
}
281+
282+
try
283+
{
284+
// Get subscription details to retrieve subscriber info including email
285+
var accessToken = await GetAccessTokenAsync();
286+
287+
var request = new HttpRequestMessage(HttpMethod.Get,
288+
$"{PayPalConfig!.ApiBaseUrl}/v1/billing/subscriptions/{subscriptionId}");
289+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
290+
291+
var response = await _httpClient.SendAsync(request);
292+
293+
if (!response.IsSuccessStatusCode)
294+
{
295+
_logger.LogWarning("PayPal get subscription for payment method failed: {StatusCode}",
296+
response.StatusCode);
297+
// Return generic PayPal info if we can't fetch details
298+
return new PaymentMethodInfo
299+
{
300+
PaymentMethodId = "paypal",
301+
Type = PaymentMethodType.PayPal,
302+
PayPalEmail = null,
303+
IsDefault = true
304+
};
305+
}
306+
307+
var responseBody = await response.Content.ReadAsStringAsync();
308+
var subscription = JsonSerializer.Deserialize<PayPalSubscriptionResponse>(responseBody, JsonOptions);
309+
310+
return new PaymentMethodInfo
311+
{
312+
PaymentMethodId = subscription?.Subscriber?.PayerId ?? "paypal",
313+
Type = PaymentMethodType.PayPal,
314+
PayPalEmail = subscription?.Subscriber?.EmailAddress,
315+
IsDefault = true
316+
};
317+
}
318+
catch (Exception ex)
319+
{
320+
_logger.LogWarning(ex, "Failed to get PayPal payment method for subscription {SubscriptionId}", subscriptionId);
321+
return new PaymentMethodInfo
322+
{
323+
PaymentMethodId = "paypal",
324+
Type = PaymentMethodType.PayPal,
325+
PayPalEmail = null,
326+
IsDefault = true
327+
};
328+
}
227329
}
228330

229331
/// <inheritdoc />
@@ -233,6 +335,30 @@ public Task<string> GetUpdatePaymentMethodUrlAsync(string customerId, string ret
233335
return Task.FromResult("https://www.paypal.com/myaccount/autopay");
234336
}
235337

338+
private static InvoiceStatus MapTransactionStatus(string? status)
339+
{
340+
return status?.ToUpperInvariant() switch
341+
{
342+
"COMPLETED" => InvoiceStatus.Paid,
343+
"PENDING" => InvoiceStatus.Open,
344+
"DECLINED" => InvoiceStatus.Uncollectible,
345+
"REFUNDED" => InvoiceStatus.Void,
346+
"PARTIALLY_REFUNDED" => InvoiceStatus.Paid,
347+
_ => InvoiceStatus.Open
348+
};
349+
}
350+
351+
private static long ParsePayPalAmount(string? value)
352+
{
353+
if (string.IsNullOrEmpty(value) || !decimal.TryParse(value, out var amount))
354+
{
355+
return 0;
356+
}
357+
// PayPal returns amounts as decimal strings (e.g., "9.00")
358+
// Convert to cents for consistency with Stripe
359+
return (long)(amount * 100);
360+
}
361+
236362
#endregion
237363

238364
#region Native Portal
@@ -564,5 +690,69 @@ private class PayPalWebhookResource
564690
public PayPalBillingInfo? BillingInfo { get; set; }
565691
}
566692

693+
// Models for Transactions API response
694+
private class PayPalTransactionsResponse
695+
{
696+
[JsonPropertyName("transactions")]
697+
public List<PayPalTransaction>? Transactions { get; set; }
698+
699+
[JsonPropertyName("total_items")]
700+
public int? TotalItems { get; set; }
701+
702+
[JsonPropertyName("total_pages")]
703+
public int? TotalPages { get; set; }
704+
}
705+
706+
private class PayPalTransaction
707+
{
708+
[JsonPropertyName("id")]
709+
public string? Id { get; set; }
710+
711+
[JsonPropertyName("status")]
712+
public string? Status { get; set; }
713+
714+
[JsonPropertyName("payer_email")]
715+
public string? PayerEmail { get; set; }
716+
717+
[JsonPropertyName("payer_name")]
718+
public PayPalPayerName? PayerName { get; set; }
719+
720+
[JsonPropertyName("amount_with_breakdown")]
721+
public PayPalAmountWithBreakdown? AmountWithBreakdown { get; set; }
722+
723+
[JsonPropertyName("time")]
724+
public DateTime? Time { get; set; }
725+
}
726+
727+
private class PayPalPayerName
728+
{
729+
[JsonPropertyName("given_name")]
730+
public string? GivenName { get; set; }
731+
732+
[JsonPropertyName("surname")]
733+
public string? Surname { get; set; }
734+
}
735+
736+
private class PayPalAmountWithBreakdown
737+
{
738+
[JsonPropertyName("gross_amount")]
739+
public PayPalMoney? GrossAmount { get; set; }
740+
741+
[JsonPropertyName("fee_amount")]
742+
public PayPalMoney? FeeAmount { get; set; }
743+
744+
[JsonPropertyName("net_amount")]
745+
public PayPalMoney? NetAmount { get; set; }
746+
}
747+
748+
private class PayPalMoney
749+
{
750+
[JsonPropertyName("currency_code")]
751+
public string? CurrencyCode { get; set; }
752+
753+
[JsonPropertyName("value")]
754+
public string? Value { get; set; }
755+
}
756+
567757
#endregion
568758
}

0 commit comments

Comments
 (0)