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
4 changes: 2 additions & 2 deletions JobFlow.API/Controllers/OnboardingController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,12 @@ public async Task<IResult> ApplyQuickStart([FromBody] OnboardingQuickStartApplyR
return orgResult.ToProblemDetails();
}

if (!HasMinPlan(orgResult.Value.SubscriptionPlanName, "Flow"))
if (!HasMinPlan(orgResult.Value.SubscriptionPlanName, "Go"))
{
return Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "Subscription Required",
detail: "A Flow plan is required to apply quick-start presets.");
detail: "A Go plan is required to apply quick-start presets.");
}

var result = await onboarding.ApplyQuickStartAsync(organizationId, request);
Expand Down
7 changes: 3 additions & 4 deletions JobFlow.API/Controllers/OrganizationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using JobFlow.Business.Services.ServiceInterfaces;
using JobFlow.Domain.Enums;
using JobFlow.Domain.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace JobFlow.API.Controllers;
Expand All @@ -14,7 +15,6 @@ namespace JobFlow.API.Controllers;
[ApiController]
public class OrganizationController : ControllerBase
{
private readonly INotificationService _notificationService;
private readonly IOrganizationBrandingService _organizationBrandingService;
private readonly IOrganizationService _organizationService;
private readonly IPaymentProfileService _paymentProfileService;
Expand All @@ -24,14 +24,12 @@ public OrganizationController(
IOrganizationService organizationService,
IUserService userService,
IPaymentProfileService paymentProfileService,
INotificationService notificationService,
IOrganizationBrandingService organizationBrandingService
)
{
_organizationService = organizationService;
_userService = userService;
_paymentProfileService = paymentProfileService;
_notificationService = notificationService;
_organizationBrandingService = organizationBrandingService;
}

Expand All @@ -45,10 +43,10 @@ public async Task<IResult> GetAllOrganizations()

[HttpPost]
[Route("create")]
[AllowAnonymous]
public async Task<IResult> CreateOrganizationAccount(Organization model)
{
var result = await _organizationService.UpsertOrganization(model);
if (result.IsSuccess) await _notificationService.SendOrganizationWelcomeNotificationAsync(model);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblemDetails();
}

Expand Down Expand Up @@ -96,6 +94,7 @@ await FirebaseAuth.DefaultInstance.SetCustomUserClaimsAsync(model.FireBaseUid,

[HttpPost]
[Route("retrieve")]
[AllowAnonymous]
public async Task<IResult> GetOrganizationById([FromBody] OrganizationRequest org)
{
var result = await _organizationService.GetOrganizationDtoById(org.OrganizationId);
Expand Down
137 changes: 128 additions & 9 deletions JobFlow.API/Controllers/PaymentController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using JobFlow.Business.PaymentGateways.SharedModels;
using JobFlow.Business.ConfigurationSettings.ConfigurationInterfaces;
using JobFlow.Business.Services.ServiceInterfaces;
using JobFlow.Business.Onboarding;
using JobFlow.Domain.Enums;
using JobFlow.API.Extensions;
using JobFlow.Infrastructure.ExternalServices.ConfigurationInterfaces;
Expand All @@ -14,6 +15,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using Stripe;
using CreateSubscriptionRequest = JobFlow.Business.Models.DTOs.CreateSubscriptionRequest;
Expand All @@ -38,6 +40,7 @@
private readonly ISquareTokenEncryptionService _squareTokenEncryption;
private readonly IHostEnvironment _hostEnvironment;
private readonly IFrontendSettings _frontEndSettings;
private readonly IOnboardingService _onboardingService;

public PaymentController(
IPaymentProcessorFactory processorFactory,
Expand All @@ -51,7 +54,8 @@
ISquareSettings squareSettings,
ISquareTokenEncryptionService squareTokenEncryption,
IHostEnvironment hostEnvironment,
IFrontendSettings frontEndSettings)
IFrontendSettings frontEndSettings,
IOnboardingService onboardingService)
{
_processorFactory = processorFactory;
_organizationService = organizationService;
Expand All @@ -65,12 +69,27 @@
_squareTokenEncryption = squareTokenEncryption;
_hostEnvironment = hostEnvironment;
_frontEndSettings = frontEndSettings;
_onboardingService = onboardingService;
}

[HttpPost("checkout")]
[AllowAnonymous]
public async Task<IActionResult> Checkout([FromBody] PaymentSessionRequest request)
{
var orgId = HttpContext.GetOrganizationId();
Guid orgId;
if (User?.Identity?.IsAuthenticated == true)
{
orgId = HttpContext.GetOrganizationId();
}
else
{
var requestedOrgId = request.OrgId ?? request.OrganizationId;
if (!requestedOrgId.HasValue || requestedOrgId.Value == Guid.Empty)
return BadRequest("Organization id is required.");

orgId = requestedOrgId.Value;
}

request.OrgId = orgId;

var org = await _organizationService.GetOrganiztionById(orgId);
Expand All @@ -91,7 +110,7 @@
if (invoice.OrganizationId != orgId)
return Unauthorized();

if (User.IsInRole(UserRoles.OrganizationClient))

Check warning on line 113 in JobFlow.API/Controllers/PaymentController.cs

View workflow job for this annotation

GitHub Actions / Build

Dereference of a possibly null reference.

Check warning on line 113 in JobFlow.API/Controllers/PaymentController.cs

View workflow job for this annotation

GitHub Actions / Build

Dereference of a possibly null reference.

Check warning on line 113 in JobFlow.API/Controllers/PaymentController.cs

View workflow job for this annotation

GitHub Actions / Build

Dereference of a possibly null reference.

Check warning on line 113 in JobFlow.API/Controllers/PaymentController.cs

View workflow job for this annotation

GitHub Actions / Build

Dereference of a possibly null reference.
{
var clientId = HttpContext.GetUserId();
if (invoice.OrganizationClientId != clientId)
Expand All @@ -117,9 +136,17 @@
}
}

var processor = provider == PaymentProvider.Square
? await _processorFactory.GetProcessorForOrgAsync(orgId, provider)
: _processorFactory.GetProcessor(provider);
IPaymentProcessor processor;
try
{
processor = provider == PaymentProvider.Square
? await GetSquareProcessorForCheckoutAsync(orgId, provider)
: _processorFactory.GetProcessor(provider);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}

string checkoutUrl;
if (request.Mode == "subscription")
Expand All @@ -130,7 +157,15 @@
{
request.ConnectedAccountId = organization.StripeConnectAccountId;
}
var paymentIntent = await processor.CreatePaymentIntentAsync(request);
PaymentSessionResult paymentIntent;
try
{
paymentIntent = await processor.CreatePaymentIntentAsync(request);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}

return Ok(new
{
Expand All @@ -142,6 +177,18 @@
return Ok(new { url = checkoutUrl });
}

private async Task<IPaymentProcessor> GetSquareProcessorForCheckoutAsync(Guid orgId, PaymentProvider provider)
{
try
{
return await _processorFactory.GetProcessorForOrgAsync(orgId, provider);
}
catch (CryptographicException)
{
throw new InvalidOperationException("Your Square connection has expired. Disconnect and reconnect Square, then try checkout again.");
}
}

[HttpPost("create-connected-account")]
public async Task<IActionResult> CreateConnectedAccount(
[FromQuery] PaymentProvider? provider = null)
Expand Down Expand Up @@ -225,9 +272,35 @@
return BadRequest(profileResult.Error);

var orgUpdate = await _organizationService.UpsertOrganization(organization);
if (orgUpdate.IsSuccess)
await _onboardingService.MarkStepCompleteAsync(orgId, OnboardingStepKeys.ConnectStripe);

return orgUpdate.IsSuccess ? Ok(new { linked = true }) : BadRequest(orgUpdate.Error);
}

[HttpDelete("square/disconnect")]
public async Task<IActionResult> DisconnectSquare()
{
var orgId = HttpContext.GetOrganizationId();
var orgResult = await _organizationService.GetOrganiztionById(orgId);
if (orgResult.IsFailure)
return NotFound(orgResult.Error);

var organization = orgResult.Value;
organization.IsSquareConnected = false;
organization.SquareMerchantId = null;

var updateResult = await _organizationService.UpsertOrganization(organization);
if (updateResult.IsFailure)
return BadRequest(updateResult.Error);

var profileDisconnectResult = await _paymentProfileService.DisconnectOrganizationProviderAsync(orgId, PaymentProvider.Square);
if (profileDisconnectResult.IsFailure)
return BadRequest(profileDisconnectResult.Error);

return Ok(new { disconnected = true });
}

[HttpPost("refund")]
public async Task<IActionResult> RefundPayment([FromBody] PaymentRefundRequestDto request)
{
Expand Down Expand Up @@ -348,13 +421,24 @@

// POST: api/payments/subscription
[HttpPost("subscription")]
[AllowAnonymous]
public async Task<IActionResult> CreateSubscription([FromBody] CreateSubscriptionRequest request)
{
if (request.PaymentProfileId == Guid.Empty)
return BadRequest("Payment profile is required.");

if (string.IsNullOrWhiteSpace(request.ProviderSubscriptionId))
return BadRequest("Provider subscription ID is required.");

var normalizedStatus = NormalizeSubscriptionStatus(request.Status);
if (normalizedStatus is null)
return BadRequest("Invalid subscription status.");

var result = await _subscriptionRecordService.CreateAsync(
request.PaymentProfileId,
request.ProviderSubscriptionId,
request.ProviderPriceId,
request.Status ?? "active",
normalizedStatus,
""
);

Expand All @@ -363,16 +447,49 @@

// POST: api/payments/subscription/cancel
[HttpPost("subscription/cancel")]
[AllowAnonymous]
public async Task<IActionResult> CancelSubscription([FromBody] CancelSubscriptionRequest request)
{
if (string.IsNullOrWhiteSpace(request.ProviderSubscriptionId))
return BadRequest("Provider subscription ID is required.");

var subscriptionResult = await _subscriptionRecordService.GetByProviderIdAsync(request.ProviderSubscriptionId);
if (subscriptionResult.IsFailure)
return NotFound(subscriptionResult.Error);

if (string.Equals(subscriptionResult.Value.Status, "canceled", StringComparison.OrdinalIgnoreCase))
return Ok();

var canceledAt = request.CanceledAt == default ? DateTime.UtcNow : request.CanceledAt.ToUniversalTime();

var result = await _subscriptionRecordService.CancelAsync(
request.ProviderSubscriptionId,
request.CanceledAt
canceledAt
);

return result.IsSuccess ? Ok() : BadRequest(result.Error);
}

private static string? NormalizeSubscriptionStatus(string? status)
{
var normalized = string.IsNullOrWhiteSpace(status)
? "active"
: status.Trim().ToLowerInvariant();

return normalized switch
{
"active" => "active",
"trialing" => "trialing",
"past_due" => "past_due",
"incomplete" => "incomplete",
"incomplete_expired" => "incomplete_expired",
"unpaid" => "unpaid",
"paused" => "paused",
"canceled" => "canceled",
_ => null
};
}

// POST: api/payments/profile/default-method
[HttpPost("profile/default-method")]
public async Task<IActionResult> SetDefaultPaymentMethod([FromBody] SetDefaultPaymentMethodRequest request)
Expand Down Expand Up @@ -526,6 +643,8 @@
if (updateResult.IsFailure)
return Redirect($"{uiBase}?provider=square&success=false&error={Uri.EscapeDataString(updateResult.Error?.ToString() ?? "Unable to update organization payment provider.")}");

await _onboardingService.MarkStepCompleteAsync(organizationId, OnboardingStepKeys.ConnectStripe);

return Redirect($"{uiBase}?provider=square&success=true&merchantId={Uri.EscapeDataString(merchantId)}");
}

Expand All @@ -540,7 +659,7 @@
? "https://connect.squareupsandbox.com"
: "https://connect.squareup.com";

return $"{connectBaseUrl}/oauth2/authorize?client_id={Uri.EscapeDataString(_squareSettings.ApplicationId)}&response_type=code&scope=PAYMENTS_WRITE+PAYMENTS_READ+ORDERS_READ+SUBSCRIPTIONS_READ+SUBSCRIPTIONS_WRITE&state={Uri.EscapeDataString(orgState)}&redirect_uri={Uri.EscapeDataString(_squareSettings.RedirectUrl)}";
return $"{connectBaseUrl}/oauth2/authorize?client_id={Uri.EscapeDataString(_squareSettings.ApplicationId)}&response_type=code&scope=PAYMENTS_WRITE+PAYMENTS_READ+ORDERS_READ+ORDERS_WRITE+SUBSCRIPTIONS_READ+SUBSCRIPTIONS_WRITE&state={Uri.EscapeDataString(orgState)}&redirect_uri={Uri.EscapeDataString(_squareSettings.RedirectUrl)}";
}

private string BuildSquareUiRedirectBaseUrl()
Expand Down
4 changes: 3 additions & 1 deletion JobFlow.API/Mappings/MapsterConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ public void Register(TypeAdapterConfig config)
config.NewConfig<EmployeeRole, EmployeeRoleDto>();

// Organization → DTO
config.NewConfig<Organization, OrganizationDto>();
config.NewConfig<Organization, OrganizationDto>()
.Map(dest => dest.Email, src => src.EmailAddress)
.Map(dest => dest.EmailAddress, src => src.EmailAddress);

//OrganizationBranding → DTO
config.NewConfig<OrganizationBranding, BrandingDto>();
Expand Down
5 changes: 4 additions & 1 deletion JobFlow.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,10 @@
app.UseSwaggerUI();
}

app.UseHttpsRedirection();
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
app.UseExceptionHandler(app.Environment.IsProduction() ? "/error" : "/error-development");

app.UseRouting();
Expand Down
1 change: 1 addition & 0 deletions JobFlow.Business/Models/DTOs/OrganizationDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class OrganizationDto
public Guid? Id { get; set; }
public string? OrganizationName { get; set; }
public string? Email { get; set; }
public string? EmailAddress { get; set; }
public string? Address1 { get; set; }
public string? Address2 { get; set; }
public string? City { get; set; }
Expand Down
10 changes: 5 additions & 5 deletions JobFlow.Business/Onboarding/OnboardingCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ public static class OnboardingCatalog
{ OnboardingStepKeys.ConnectStripe, 10 },
{ OnboardingStepKeys.CreateCustomer, 20 },
{ OnboardingStepKeys.CreateJob, 30 },
{ OnboardingStepKeys.CreateInvoice, 40 },
{ OnboardingStepKeys.SendInvoice, 50 },
{ OnboardingStepKeys.ReceivePayment, 60 },
{ OnboardingStepKeys.ScheduleJob, 70 }
{ OnboardingStepKeys.ScheduleJob, 40 },
{ OnboardingStepKeys.CreateInvoice, 50 },
{ OnboardingStepKeys.SendInvoice, 60 },
{ OnboardingStepKeys.ReceivePayment, 70 }
};

private static readonly Dictionary<string, int> OrganizedFirstOrder = new()
Expand All @@ -45,7 +45,7 @@ public static class OnboardingCatalog
new(OnboardingStepKeys.CreateJob, "Create your first job", 20, _ => true),
new(OnboardingStepKeys.ScheduleJob, "Schedule the job", 30, _ => true),
new(OnboardingStepKeys.CreateInvoice, "Create an invoice", 40, _ => true),
new(OnboardingStepKeys.ConnectStripe, "Create Stripe connected account", 50, _ => true),
new(OnboardingStepKeys.ConnectStripe, "Connect your payment account", 50, _ => true),
new(OnboardingStepKeys.SendInvoice, "Send the invoice", 60, _ => true),
new(
OnboardingStepKeys.ReceivePayment,
Expand Down
6 changes: 0 additions & 6 deletions JobFlow.Business/Services/OrganizationClientService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,19 +68,13 @@ public async Task<Result> DeleteClient(Guid clientId, Guid organizationId)
public async Task<Result<IEnumerable<OrganizationClient>>> GetAllClients()
{
var clients = await organizationClient.Query().ToListAsync();
if (!clients.Any())
return Result.Failure<IEnumerable<OrganizationClient>>(OrganizationClientErrors.NoClientsToShow);

return Result.Success<IEnumerable<OrganizationClient>>(clients);
}

public async Task<Result<IEnumerable<OrganizationClient>>> GetAllClientsByOrganizationId(Guid organizationId)
{
var clients = await organizationClient.Query().Where(client => client.OrganizationId == organizationId)
.ToListAsync();
if (!clients.Any())
return Result.Failure<IEnumerable<OrganizationClient>>(OrganizationClientErrors.NoClientFound);

return Result.Success<IEnumerable<OrganizationClient>>(clients);
}

Expand Down
Loading
Loading