Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions JobFlow.API/Controllers/PaymentController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public class PaymentController : ControllerBase
private readonly IStripeWebhookService _stripeWebhookService;
private readonly ISubscriptionRecordService _subscriptionRecordService;
private readonly IInvoiceService _invoiceService;
private readonly IJobService _jobService;
private readonly IPaymentHistoryService _paymentHistoryService;
private readonly IStripeSettings _stripeSettings;
private readonly ISquareSettings _squareSettings;
Expand All @@ -73,6 +74,7 @@ public PaymentController(
IStripeWebhookService stripeWebhookService,
ISquareWebhookService squareWebhookService,
IInvoiceService invoiceService,
IJobService jobService,
IPaymentHistoryService paymentHistoryService,
IStripeSettings stripeSettings,
ISquareSettings squareSettings,
Expand All @@ -92,6 +94,7 @@ public PaymentController(
_stripeWebhookService = stripeWebhookService;
_squareWebhookService = squareWebhookService;
_invoiceService = invoiceService;
_jobService = jobService;
_paymentHistoryService = paymentHistoryService;
_stripeSettings = stripeSettings;
_squareSettings = squareSettings;
Expand Down Expand Up @@ -203,6 +206,37 @@ public async Task<IActionResult> Checkout([FromBody] PaymentSessionRequest reque
request.SuccessUrl = $"{feBase}/client-hub/invoices/{invoice.Id}";
}
}
else if (request.InvoiceId == null && request.JobId.HasValue)
{
// No invoice provided — look up or auto-create one for the job (A+B combined).
var jobResult = await _jobService.GetJobByIdAsync(request.JobId.Value, orgId);
if (jobResult.IsFailure)
return NotFound("Job not found.");

var invoiceResult = await _invoiceService.EnsureInvoiceForPaymentAsync(
orgId, jobResult.Value, request.Amount);

if (invoiceResult.IsFailure)
return BadRequest(invoiceResult.Error.Description);

var ensured = invoiceResult.Value;
request.InvoiceId = ensured.Id;

if (!request.Amount.HasValue || request.Amount <= 0)
request.Amount = ensured.BalanceDue;

if (request.Amount <= 0)
return BadRequest("This invoice has already been paid.");

request.OrganizationClientId = ensured.OrganizationClientId;
request.ProductName ??= $"Invoice {ensured.InvoiceNumber}";

if (Enum.IsDefined(typeof(PaymentProvider), ensured.PaymentProvider)
&& ensured.PaymentProvider != 0)
{
provider = ensured.PaymentProvider;
}
}

IPaymentProcessor processor;
try
Expand Down Expand Up @@ -242,10 +276,22 @@ public async Task<IActionResult> Checkout([FromBody] PaymentSessionRequest reque
{
request.ConnectedAccountId = organization.StripeConnectAccountId;
}
PaymentSessionResult paymentIntent;

// Ensure success/cancel URLs are set so the hosted checkout page can redirect back.
if (string.IsNullOrWhiteSpace(request.SuccessUrl))
{
var feBase = (_frontEndSettings.BaseUrl ?? "https://app.gojobflow.com").TrimEnd('/');
request.SuccessUrl = $"{feBase}/payment-complete";
request.CancelUrl ??= $"{feBase}/payment-cancelled";
}

PaymentSessionResult session;
try
{
paymentIntent = await processor.CreatePaymentIntentAsync(request);
// Use the hosted checkout flow which returns a redirect URL the mobile
// app can open directly in a browser. (CreatePaymentIntentAsync only
// returns a clientSecret, not a URL.)
session = await processor.CreateOneTimeCheckoutSessionAsync(request);
}
catch (InvalidOperationException ex)
{
Expand All @@ -254,9 +300,9 @@ public async Task<IActionResult> Checkout([FromBody] PaymentSessionRequest reque

return Ok(new
{
clientSecret = paymentIntent.ClientSecret,
url = paymentIntent.RedirectUrl,
providerPaymentId = paymentIntent.ProviderPaymentId
clientSecret = session.ClientSecret,
url = session.RedirectUrl,
providerPaymentId = session.ProviderPaymentId
});
}
return Ok(new { url = checkoutUrl });
Expand Down
5 changes: 5 additions & 0 deletions JobFlow.Business/Models/DTOs/AssignmentDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ public class AssignmentDto
public string? JobTitle { get; set; }
public Guid OrganizationClientId { get; set; }
public string? ClientName { get; set; }

/// <summary>Balance remaining on the associated invoice, if one exists. Null when no invoice has been raised for the job.</summary>
public decimal? BalanceDue { get; set; }
/// <summary>Invoice ID tied to the job, if one exists.</summary>
public Guid? InvoiceId { get; set; }
}

public class CreateAssignmentRequestDto
Expand Down
2 changes: 2 additions & 0 deletions JobFlow.Business/PaymentGateways/IPaymentProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ namespace JobFlow.Business.PaymentGateways;
public interface IPaymentProcessor
{
Task<PaymentSessionResult> CreatePaymentIntentAsync(PaymentSessionRequest request);
/// <summary>Creates a hosted one-time payment checkout session and returns a redirect URL.</summary>
Task<PaymentSessionResult> CreateOneTimeCheckoutSessionAsync(PaymentSessionRequest request);
Task<string> CreateSubscriptionCheckoutSessionAsync(PaymentSessionRequest request);
Task<PaymentOperationResult> CreateTrialSubscriptionAsync(string email, Guid orgId, string planPriceId, int trialDays = 14);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ public class PaymentSessionRequest

public Guid? InvoiceId { get; set; }

/// <summary>When provided without an InvoiceId, the API will look up or auto-create
/// an invoice for this job before processing the checkout session.</summary>
public Guid? JobId { get; set; }

// Subscription-specific
public string? StripePriceId { get; set; }
public string? StripeCustomerId { get; set; }
Expand Down
28 changes: 26 additions & 2 deletions JobFlow.Business/Services/AssignmentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class AssignmentService : IAssignmentService
private readonly IRepository<AssignmentAssignee> _assignmentAssignees;
private readonly IRepository<Employee> _employees;
private readonly IRepository<Job> _jobs;
private readonly IRepository<Invoice> _invoices;
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly IOnboardingService _onboardingService;
Expand All @@ -45,6 +46,7 @@ public AssignmentService(
_assignmentAssignees = unitOfWork.RepositoryOf<AssignmentAssignee>();
_employees = unitOfWork.RepositoryOf<Employee>();
_jobs = unitOfWork.RepositoryOf<Job>();
_invoices = unitOfWork.RepositoryOf<Invoice>();

_mapper = mapper;
_onboardingService = onboardingService;
Expand Down Expand Up @@ -327,8 +329,22 @@ public async Task<Result<List<AssignmentDto>>> GetAssignmentsAsync(
if (labelMapResult.IsFailure)
return Result.Failure<List<AssignmentDto>>(labelMapResult.Error);

// Fetch the latest invoice for each job so we can surface BalanceDue on each assignment.
var jobIds = assignments.Select(a => a.JobId).ToHashSet();
var invoicesByJobId = await _invoices.Query()
.Where(i => i.JobId.HasValue && jobIds.Contains(i.JobId.Value))
.GroupBy(i => i.JobId!.Value)
.Select(g => g.OrderByDescending(i => i.InvoiceDate).First())
.ToDictionaryAsync(i => i.JobId!.Value);

var labelMap = labelMapResult.Value;
var mapped = assignments.Select(a => MapToDto(a, labelMap)).ToList();
var mapped = assignments
.Select(a =>
{
invoicesByJobId.TryGetValue(a.JobId, out var inv);
return MapToDto(a, labelMap, inv?.Id, inv?.BalanceDue);
})
.ToList();

return Result.Success(mapped);
}
Expand Down Expand Up @@ -434,7 +450,11 @@ private async Task TryCompleteJobAsync(Guid organizationId, Guid jobId)
}
}

private AssignmentDto MapToDto(Assignment assignment, Dictionary<JobLifecycleStatus, string> labelMap)
private AssignmentDto MapToDto(
Assignment assignment,
Dictionary<JobLifecycleStatus, string> labelMap,
Guid? invoiceId = null,
decimal? balanceDue = null)
{
var dto = _mapper.Map<AssignmentDto>(assignment);

Expand Down Expand Up @@ -470,6 +490,10 @@ private AssignmentDto MapToDto(Assignment assignment, Dictionary<JobLifecycleSta
})
.ToList();

// Invoice enrichment (populated by GetAssignmentsAsync via the invoice pre-fetch).
dto.InvoiceId = invoiceId;
dto.BalanceDue = balanceDue > 0 ? balanceDue : null;

return dto;
}

Expand Down
61 changes: 61 additions & 0 deletions JobFlow.Business/Services/InvoiceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,67 @@ private async Task SendInvoiceToClientAsync(Invoice invoice)
await MarkInvoiceSentAsync(invoice.Id);
}

public async Task<Result<Invoice>> EnsureInvoiceForPaymentAsync(
Guid organizationId,
Job job,
decimal? fallbackAmount)
{
// 1 — Return any existing invoice for the job (unpaid or paid).
var existing = await invoices.Query()
.Include(i => i.LineItems)
.FirstOrDefaultAsync(i => i.OrganizationId == organizationId && i.JobId == job.Id);

if (existing != null)
return Result.Success(existing);

// 2 — Option A: build invoice from an accepted estimate.
var fromEstimate = await CreateInvoiceFromEstimateAsync(organizationId, job);
if (fromEstimate.IsSuccess)
return fromEstimate;

// 3 — Option B: no estimate — create a quick single-line-item draft invoice.
if (!fallbackAmount.HasValue || fallbackAmount.Value <= 0)
return Result.Failure<Invoice>(
Error.Validation("Invoice.AmountRequired",
"No invoice or accepted estimate found for this job. Please provide a payment amount."));

var client = await clients.Query()
.FirstOrDefaultAsync(c => c.Id == job.OrganizationClientId);

if (client == null)
return Result.Failure<Invoice>(EstimateErrors.ClientNotFound);

var quickInvoice = new Invoice
{
Id = Guid.NewGuid(),
OrganizationId = organizationId,
OrganizationClientId = job.OrganizationClientId,
JobId = job.Id,
InvoiceNumber = await _numberGenerator.GenerateAsync(organizationId),
InvoiceDate = DateTime.UtcNow,
DueDate = DateTime.UtcNow.AddDays(14),
Status = InvoiceStatus.Draft,
OrganizationClient = client,
LineItems =
[
new InvoiceLineItem
{
Id = Guid.NewGuid(),
Description = string.IsNullOrWhiteSpace(job.Title) ? "Services rendered" : job.Title,
Quantity = 1,
UnitPrice = fallbackAmount.Value,
}
],
};

var saved = await UpsertInvoiceAsync(quickInvoice);
if (saved.IsFailure)
return Result.Failure<Invoice>(saved.Error);

// Return the hydrated version so BalanceDue is computed.
return await GetInvoiceByIdAsync(saved.Value.Id);
}

private async Task<Result<Invoice>> CreateInvoiceFromEstimateAsync(Guid organizationId, Job job)
{
Estimate? estimate = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ Task<Result<CursorPagedResponseDto<Invoice>>> GetInvoicesByOrganizationPagedAsyn
Task<Result> SendInvoiceToClientAsync(Guid invoiceId);
Task<Result> SendInvoiceForJobAsync(Guid organizationId, Job job);

/// <summary>
/// Finds an existing invoice for the job, or creates one.
/// Creation first tries to build from an accepted estimate (Option A).
/// If no estimate exists and <paramref name="fallbackAmount"/> is provided,
/// a single-line-item draft invoice is created for that amount (Option B).
/// </summary>
Task<Result<Invoice>> EnsureInvoiceForPaymentAsync(Guid organizationId, Job job, decimal? fallbackAmount);

Task<Result<Invoice>> MarkPaidAsync(
Guid invoiceId,
PaymentProvider provider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ public Task<PaymentSessionResult> CreatePaymentIntentAsync(PaymentSessionRequest
return CreateCheckoutIntentAsync(request);
}

// Square already uses a hosted checkout page for all payments.
public Task<PaymentSessionResult> CreateOneTimeCheckoutSessionAsync(PaymentSessionRequest request)
{
return CreateCheckoutIntentAsync(request);
}

public Task<string> CreateSubscriptionCheckoutSessionAsync(PaymentSessionRequest request)
{
return CreateCheckoutSessionAsync(request);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,69 @@ public async Task<PaymentSessionResult> CreatePaymentIntentAsync(
};
}

/// <summary>
/// Creates a Stripe Checkout Session (hosted payment page) for a one-time payment.
/// Returns a <see cref="PaymentSessionResult"/> whose <c>RedirectUrl</c> points to the
/// Stripe-hosted checkout page. The caller opens this URL in a browser or web-view.
/// </summary>
public async Task<PaymentSessionResult> CreateOneTimeCheckoutSessionAsync(PaymentSessionRequest request)
{
if (string.IsNullOrWhiteSpace(request.ConnectedAccountId))
throw new InvalidOperationException("Connected account is required.");

var amountInCents =
request.Amount?.ToCents()
?? throw new InvalidOperationException("Payment amount is required.");

long applicationFee = _paymentSettings.ApplicationFee.ToCents();

var options = new SessionCreateOptions
{
Mode = "payment",
LineItems = new List<SessionLineItemOptions>
{
new()
{
PriceData = new SessionLineItemPriceDataOptions
{
Currency = "usd",
UnitAmount = amountInCents,
ProductData = new SessionLineItemPriceDataProductDataOptions
{
Name = request.ProductName ?? "Service",
},
},
Quantity = request.Quantity ?? 1,
}
},
PaymentIntentData = new SessionPaymentIntentDataOptions
{
ApplicationFeeAmount = applicationFee,
TransferData = new SessionPaymentIntentDataTransferDataOptions
{
Destination = request.ConnectedAccountId,
},
Metadata = request.InvoiceId.HasValue
? new Dictionary<string, string> { { "invoiceId", request.InvoiceId.Value.ToString() } }
: null,
},
SuccessUrl = request.SuccessUrl,
CancelUrl = request.CancelUrl,
Metadata = request.InvoiceId.HasValue
? new Dictionary<string, string> { { "invoiceId", request.InvoiceId.Value.ToString() } }
: null,
};

var sessionService = new SessionService();
var session = await sessionService.CreateAsync(options);

return new PaymentSessionResult
{
RedirectUrl = session.Url,
ProviderPaymentId = session.Id,
};
}

public async Task<PaymentSessionResult> CreateDepositPaymentAsync(PaymentSessionRequest request)
{
request.Amount = request.DepositAmount ?? request.Amount;
Expand Down
Loading
Loading