diff --git a/TipCatDotNet.Api/Controllers/MemberController.cs b/TipCatDotNet.Api/Controllers/MemberController.cs index f51f57d..0d14f31 100644 --- a/TipCatDotNet.Api/Controllers/MemberController.cs +++ b/TipCatDotNet.Api/Controllers/MemberController.cs @@ -3,10 +3,9 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using TipCatDotNet.Api.Filters.Authorization.HospitalityFacilityPermissions; using TipCatDotNet.Api.Infrastructure; using TipCatDotNet.Api.Models.HospitalityFacilities; -using TipCatDotNet.Api.Models.Permissions.Enums; +using TipCatDotNet.Api.Models.Payments.Enums; using TipCatDotNet.Api.Services; using TipCatDotNet.Api.Services.HospitalityFacilities; @@ -174,6 +173,28 @@ public async Task UpdateCurrent([FromRoute] int memberId, [FromRo } + /// + /// Updates member's active stripe account. + /// + /// Target member ID + /// Target account ID + /// Type of active stripe account + /// + [HttpPut("accounts/{accountId:int}/members/{memberId:int}/stripe-account/set-active")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Update([FromRoute] int memberId, [FromRoute] int accountId, + [FromQuery] ActiveStripeAccountType accountType = ActiveStripeAccountType.Undefined) + { + var (_, isFailure, memberContext, error) = await _memberContextService.Get(); + if (isFailure) + return NotFound(error); + + return NoContentOrBadRequest(await _memberService.Update(memberContext, new MemberRequest(memberId, accountId), accountType)); + } + + private readonly IMemberContextService _memberContextService; private readonly IMemberService _memberService; } \ No newline at end of file diff --git a/TipCatDotNet.Api/Data/Models/HospitalityFacility/Account.cs b/TipCatDotNet.Api/Data/Models/HospitalityFacility/Account.cs index 89704fb..a9d166c 100644 --- a/TipCatDotNet.Api/Data/Models/HospitalityFacility/Account.cs +++ b/TipCatDotNet.Api/Data/Models/HospitalityFacility/Account.cs @@ -15,6 +15,7 @@ public class Account public string Email { get; set; } = null!; [StringLength(32)] public string Phone { get; set; } = null!; + public string StripeAccount { get; set; } = null!; public DateTime Created { get; set; } public DateTime Modified { get; set; } public bool IsActive { get; set; } diff --git a/TipCatDotNet.Api/Data/Models/HospitalityFacility/Member.cs b/TipCatDotNet.Api/Data/Models/HospitalityFacility/Member.cs index 5892628..be4ce7a 100644 --- a/TipCatDotNet.Api/Data/Models/HospitalityFacility/Member.cs +++ b/TipCatDotNet.Api/Data/Models/HospitalityFacility/Member.cs @@ -22,6 +22,7 @@ public class Member public string MemberCode { get; set; } = null!; [StringLength(64)] public string? Position { get; set; } + public string ActiveStripeId { get; set; } = null!; public string QrCodeUrl { get; set; } = null!; public MemberPermissions Permissions { get; set; } = MemberPermissions.None; public DateTime Created { get; set; } diff --git a/TipCatDotNet.Api/Migrations/20220209161037_ActiveStripeAccountsWereAddedBothForOrganizationAndMembers.Designer.cs b/TipCatDotNet.Api/Migrations/20220209161037_ActiveStripeAccountsWereAddedBothForOrganizationAndMembers.Designer.cs new file mode 100644 index 0000000..d4449e5 --- /dev/null +++ b/TipCatDotNet.Api/Migrations/20220209161037_ActiveStripeAccountsWereAddedBothForOrganizationAndMembers.Designer.cs @@ -0,0 +1,337 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TipCatDotNet.Api.Data; + +#nullable disable + +namespace TipCatDotNet.Api.Migrations +{ + [DbContext(typeof(AetherDbContext))] + [Migration("20220209161037_ActiveStripeAccountsWereAddedBothForOrganizationAndMembers")] + partial class ActiveStripeAccountsWereAddedBothForOrganizationAndMembers + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TipCatDotNet.Api.Data.Analitics.AccountStats", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer"); + + b.Property("AmountPerDay") + .HasColumnType("numeric"); + + b.Property("CurrentDate") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalAmount") + .HasColumnType("numeric"); + + b.Property("TransactionsCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("AccountsStats"); + }); + + modelBuilder.Entity("TipCatDotNet.Api.Data.Models.Auth.MemberInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Link") + .HasColumnType("text"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("MemberInvitations"); + }); + + modelBuilder.Entity("TipCatDotNet.Api.Data.Models.HospitalityFacility.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OperatingName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("StripeAccount") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("TipCatDotNet.Api.Data.Models.HospitalityFacility.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer"); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDefault") + .HasColumnType("boolean"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("SessionEndTime") + .HasColumnType("time without time zone"); + + b.HasKey("Id"); + + b.ToTable("Facilities"); + }); + + modelBuilder.Entity("TipCatDotNet.Api.Data.Models.HospitalityFacility.Member", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("integer"); + + b.Property("ActiveStripeId") + .IsRequired() + .HasColumnType("text"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("FacilityId") + .HasColumnType("integer"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IdentityHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MemberCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("Permissions") + .HasColumnType("integer"); + + b.Property("Position") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QrCodeUrl") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Members"); + }); + + modelBuilder.Entity("TipCatDotNet.Api.Data.Models.Payment.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text"); + + b.Property("FacilityId") + .HasColumnType("integer"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone"); + + b.Property("PaymentIntentId") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Amount"); + + NpgsqlIndexBuilderExtensions.HasSortOrder(b.HasIndex("Amount"), new[] { SortOrder.Ascending, SortOrder.Descending }); + + b.HasIndex("Created"); + + NpgsqlIndexBuilderExtensions.HasSortOrder(b.HasIndex("Created"), new[] { SortOrder.Ascending, SortOrder.Descending }); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("TipCatDotNet.Api.Data.Models.Stripe.StripeAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LastPaidOut") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReceived") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("StripeId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("StripeAccounts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TipCatDotNet.Api/Migrations/20220209161037_ActiveStripeAccountsWereAddedBothForOrganizationAndMembers.cs b/TipCatDotNet.Api/Migrations/20220209161037_ActiveStripeAccountsWereAddedBothForOrganizationAndMembers.cs new file mode 100644 index 0000000..7e58f64 --- /dev/null +++ b/TipCatDotNet.Api/Migrations/20220209161037_ActiveStripeAccountsWereAddedBothForOrganizationAndMembers.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TipCatDotNet.Api.Migrations +{ + public partial class ActiveStripeAccountsWereAddedBothForOrganizationAndMembers : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ActiveStripeId", + table: "Members", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "StripeAccount", + table: "Accounts", + type: "text", + nullable: false, + defaultValue: ""); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ActiveStripeId", + table: "Members"); + + migrationBuilder.DropColumn( + name: "StripeAccount", + table: "Accounts"); + } + } +} diff --git a/TipCatDotNet.Api/Migrations/AetherDbContextModelSnapshot.cs b/TipCatDotNet.Api/Migrations/AetherDbContextModelSnapshot.cs index 2f8cfca..dae4938 100644 --- a/TipCatDotNet.Api/Migrations/AetherDbContextModelSnapshot.cs +++ b/TipCatDotNet.Api/Migrations/AetherDbContextModelSnapshot.cs @@ -130,6 +130,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(32) .HasColumnType("character varying(32)"); + b.Property("StripeAccount") + .IsRequired() + .HasColumnType("text"); + b.HasKey("Id"); b.ToTable("Accounts"); @@ -200,6 +204,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AccountId") .HasColumnType("integer"); + b.Property("ActiveStripeId") + .IsRequired() + .HasColumnType("text"); + b.Property("AvatarUrl") .HasColumnType("text"); diff --git a/TipCatDotNet.Api/Models/Payments/Enums/ActiveStripeAccountType.cs b/TipCatDotNet.Api/Models/Payments/Enums/ActiveStripeAccountType.cs new file mode 100644 index 0000000..c78d532 --- /dev/null +++ b/TipCatDotNet.Api/Models/Payments/Enums/ActiveStripeAccountType.cs @@ -0,0 +1,13 @@ +using System; +using System.Text.Json.Serialization; + +namespace TipCatDotNet.Api.Models.Payments.Enums; + +[JsonConverter(typeof(JsonStringEnumConverter))] +[Flags] +public enum ActiveStripeAccountType +{ + Undefined = 0, + Organizational = 1, + Personal = 2 +} \ No newline at end of file diff --git a/TipCatDotNet.Api/Services/HospitalityFacilities/AccountService.cs b/TipCatDotNet.Api/Services/HospitalityFacilities/AccountService.cs index 69c351f..35a5fb3 100644 --- a/TipCatDotNet.Api/Services/HospitalityFacilities/AccountService.cs +++ b/TipCatDotNet.Api/Services/HospitalityFacilities/AccountService.cs @@ -12,16 +12,18 @@ using TipCatDotNet.Api.Infrastructure.FunctionalExtensions; using TipCatDotNet.Api.Models.HospitalityFacilities; using TipCatDotNet.Api.Models.HospitalityFacilities.Validators; +using TipCatDotNet.Api.Services.Payments; namespace TipCatDotNet.Api.Services.HospitalityFacilities; public class AccountService : IAccountService { - public AccountService(AetherDbContext context, IMemberContextCacheService memberContextCacheService, IFacilityService facilityService) + public AccountService(AetherDbContext context, IStripeAccountService stripeAccountService, IMemberContextCacheService memberContextCacheService, IFacilityService facilityService) { _context = context; _memberContextCacheService = memberContextCacheService; _facilityService = facilityService; + _stripeAccountService = stripeAccountService; } @@ -64,6 +66,8 @@ async Task> AddAccount() _context.Accounts.Add(newAccount); await _context.SaveChangesAsync(cancellationToken); + await _stripeAccountService.AddForAccountAndManager(context.Id, newAccount, cancellationToken); + return newAccount; } @@ -71,8 +75,8 @@ async Task> AddAccount() { var (_, isFailure, facilityId) = await _facilityService.AddDefault(account.Id, account.OperatingName, cancellationToken); - return isFailure - ? Result.Failure<(int, int)>("Default facility hadn't been created.") + return isFailure + ? Result.Failure<(int, int)>("Default facility hadn't been created.") : (account.Id, facilityId); } @@ -159,7 +163,7 @@ private async Task> GetAccount(int accountId, List> AccountProjection(List facilities) => a => new AccountResponse(a.Id, a.Name, a.OperatingName, a.Address, a.AvatarUrl, a.Email, a.Phone, a.IsActive, facilities); @@ -167,4 +171,5 @@ private static Expression> AccountProjection(List private readonly AetherDbContext _context; private readonly IMemberContextCacheService _memberContextCacheService; private readonly IFacilityService _facilityService; + private readonly IStripeAccountService _stripeAccountService; } \ No newline at end of file diff --git a/TipCatDotNet.Api/Services/HospitalityFacilities/IMemberService.cs b/TipCatDotNet.Api/Services/HospitalityFacilities/IMemberService.cs index f32ff72..d29cfab 100644 --- a/TipCatDotNet.Api/Services/HospitalityFacilities/IMemberService.cs +++ b/TipCatDotNet.Api/Services/HospitalityFacilities/IMemberService.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using CSharpFunctionalExtensions; using TipCatDotNet.Api.Models.HospitalityFacilities; +using TipCatDotNet.Api.Models.Payments.Enums; namespace TipCatDotNet.Api.Services.HospitalityFacilities; @@ -17,4 +18,5 @@ public interface IMemberService Task> RegenerateQr(MemberContext memberContext, int memberId, int accountId, CancellationToken cancellationToken = default); Task Remove(MemberContext memberContext, int memberId, int accountId, CancellationToken cancellationToken = default); Task> Update(MemberContext memberContext, MemberRequest request, CancellationToken cancellationToken = default); + Task Update(MemberContext memberContext, MemberRequest request, ActiveStripeAccountType accountType, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/TipCatDotNet.Api/Services/HospitalityFacilities/MemberService.cs b/TipCatDotNet.Api/Services/HospitalityFacilities/MemberService.cs index 713d7ec..69cd695 100644 --- a/TipCatDotNet.Api/Services/HospitalityFacilities/MemberService.cs +++ b/TipCatDotNet.Api/Services/HospitalityFacilities/MemberService.cs @@ -16,6 +16,7 @@ using TipCatDotNet.Api.Models.Auth.Enums; using TipCatDotNet.Api.Models.HospitalityFacilities; using TipCatDotNet.Api.Models.HospitalityFacilities.Validators; +using TipCatDotNet.Api.Models.Payments.Enums; using TipCatDotNet.Api.Models.Permissions.Enums; using TipCatDotNet.Api.Services.Auth; using TipCatDotNet.Api.Services.Images; @@ -262,6 +263,69 @@ async Task UpdateMember() } + public Task Update(MemberContext memberContext, MemberRequest request, + ActiveStripeAccountType accountType, CancellationToken cancellationToken = default) + { + return ValidateGeneral(memberContext, request) + .Bind(DefineActiveAccount) + .Bind(UpdateActiveAccount); + + + async Task> DefineActiveAccount() + { + string? activeStripeId = null; + + switch (accountType) + { + case ActiveStripeAccountType.Organizational: + { + activeStripeId = await _context.Accounts + .Where(a => a.Id == request.AccountId) + .Select(a => a.StripeAccount) + .SingleAsync(cancellationToken); + + break; + } + case ActiveStripeAccountType.Personal: + { + activeStripeId = await _context.Members + .Where(m => m.Id == request.Id) + .Select(m => m.ActiveStripeId) + .SingleAsync(cancellationToken); + + break; + } + case ActiveStripeAccountType.Undefined: + { + activeStripeId = string.Empty; + break; + } + } + + if (activeStripeId is null) + Result.Failure("Target stripe account wasn't defined!"); + + return Result.Success(activeStripeId); + } + + + async Task UpdateActiveAccount(string activeStripeId) + { + var targetMember = await _context.Members + .SingleAsync(m => m.Id == request.Id, cancellationToken); + + targetMember.ActiveStripeId = activeStripeId; + + targetMember.Modified = DateTime.UtcNow; + + _context.Members.Update(targetMember); + await _context.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } + } + + private Result ValidateGeneral(MemberContext memberContext, MemberRequest request) { var validator = new MemberRequestValidator(memberContext, _context); @@ -334,12 +398,26 @@ private async Task> AddMemberInternal(string identityHash, int? acco await _context.SaveChangesAsync(cancellationToken); _context.DetachEntities(); - // var (_, isFailure, error) = await _stripeAccountService - // .Add(new MemberRequest(newMember.Id, accountId, firstName, lastName, email, permissions, position), cancellationToken); - // if (isFailure) - // return Result.Failure(error); + if (accountId is null) + return newMember.Id; + + return await SetStripeAccoint(newMember.Id); - return newMember.Id; + + async Task> SetStripeAccoint(int memberId) + { + var stripeAccountId = await _context.Accounts + .Where(a => a.Id == accountId) + .Select(a => a.StripeAccount) + .SingleAsync(cancellationToken); + + var (_, isFailure, error) = await _stripeAccountService + .SetActiveStripeAccount(stripeAccountId, memberId, cancellationToken); + if (isFailure) + return Result.Failure(error); + + return Result.Success(memberId); + } async Task GetFacilityId() diff --git a/TipCatDotNet.Api/Services/Payments/IStripeAccountService.cs b/TipCatDotNet.Api/Services/Payments/IStripeAccountService.cs index 98dc988..8474f7a 100644 --- a/TipCatDotNet.Api/Services/Payments/IStripeAccountService.cs +++ b/TipCatDotNet.Api/Services/Payments/IStripeAccountService.cs @@ -1,6 +1,7 @@ using System.Threading; using System.Threading.Tasks; using CSharpFunctionalExtensions; +using TipCatDotNet.Api.Data.Models.HospitalityFacility; using TipCatDotNet.Api.Models.HospitalityFacilities; namespace TipCatDotNet.Api.Services.Payments; @@ -8,7 +9,9 @@ namespace TipCatDotNet.Api.Services.Payments; public interface IStripeAccountService { Task Add(MemberRequest request, CancellationToken cancellationToken); + Task AddForAccountAndManager(int memberId, Account request, CancellationToken cancellationToken); Task AttachDefaultExternal(PayoutMethodRequest request, CancellationToken cancellationToken); + Task SetActiveStripeAccount(string accountId, int memberId, CancellationToken cancellationToken); Task> Retrieve(MemberRequest request, CancellationToken cancellationToken); Task Update(MemberRequest request, CancellationToken cancellationToken); Task Remove(int memberId, CancellationToken cancellationToken); diff --git a/TipCatDotNet.Api/Services/Payments/PaymentService.cs b/TipCatDotNet.Api/Services/Payments/PaymentService.cs index 7c90c23..c7deca2 100644 --- a/TipCatDotNet.Api/Services/Payments/PaymentService.cs +++ b/TipCatDotNet.Api/Services/Payments/PaymentService.cs @@ -49,21 +49,26 @@ Result Validate() } - async Task> GetOperatingName() + async Task> GetOperatingName() => await _context.Members .Where(m => m.Id == paymentRequest.MemberId) - .Join(_context.Accounts, m => m.AccountId, a => a.Id, (m, a) => a.OperatingName) + .Join(_context.Accounts, m => m.AccountId, a => a.Id, (m, a) + => new Tuple(a.OperatingName, m.ActiveStripeId).ToValueTuple()) .SingleAsync(); - async Task> ProceedPayment(string operatingName) + async Task> ProceedPayment((string name, string accountId) receiver) { var createOptions = new PaymentIntentCreateOptions { PaymentMethodTypes = PaymentEnums.PaymentMethodService.GetAllowed(), - Description = $"Tips left at {operatingName}", + Description = $"Tips left at {receiver.name}", Amount = ToIntegerUnits(paymentRequest.TipsAmount), Currency = paymentRequest.TipsAmount.Currency.ToString(), + TransferData = new PaymentIntentTransferDataOptions + { + Destination = receiver.accountId, + }, Metadata = new Dictionary { { "MemberId", paymentRequest.MemberId.ToString() }, @@ -182,15 +187,15 @@ async Task PerformAction(Event stripeEvent) switch (stripeEvent.Type) { case "payment_intent.created": - { - // TODO: call method for handle created event - break; - } + { + // TODO: call method for handle created event + break; + } case "payment_intent.succeeded": - { - await _transactionService.Update(paymentIntent!, null); - break; - } + { + await _transactionService.Update(paymentIntent!, null); + break; + } } return Result.Success(); diff --git a/TipCatDotNet.Api/Services/Payments/StripeAccountService.cs b/TipCatDotNet.Api/Services/Payments/StripeAccountService.cs index 5515306..0106fd0 100644 --- a/TipCatDotNet.Api/Services/Payments/StripeAccountService.cs +++ b/TipCatDotNet.Api/Services/Payments/StripeAccountService.cs @@ -11,6 +11,7 @@ using TipCatDotNet.Api.Infrastructure; using TipCatDotNet.Api.Models.HospitalityFacilities; using TipCatDotNet.Api.Options; +using TipCatData = TipCatDotNet.Api.Data.Models; namespace TipCatDotNet.Api.Services.Payments; @@ -87,11 +88,127 @@ async Task CreateRelatedAccount(string accountId) await _context.SaveChangesAsync(cancellationToken); _context.DetachEntities(); + await SetActiveStripeAccount(accountId, request.Id.Value, cancellationToken); + return Result.Success(); } } + public Task AddForAccountAndManager(int memberId, TipCatData.HospitalityFacility.Account account, CancellationToken cancellationToken) + { + return Result.Success() + .Bind(CreateStripeAccount) + .Bind(SetStripeAccount) + .Bind(stripeAccountId => SetActiveStripeAccount(stripeAccountId, memberId, cancellationToken)); + + + async Task> CreateStripeAccount() + { + var options = new AccountCreateOptions + { + Country = "AE", + Type = "custom", + BusinessType = "company", + BusinessProfile = new AccountBusinessProfileOptions + { + Name = account.Name, + ProductDescription = account.OperatingName, + // Mcc = "", TODO + // SupportAddress = new AddressOptions + // { + // City = "", + // Country = "", + // Line1 = "", + // Line2 = "", + // PostalCode = "", + // State = "" + // }, + SupportPhone = account.Phone, + SupportEmail = account.Email, + }, + Company = new AccountCompanyOptions + { + // Address = new AddressOptions + // { + // City = "", + // Country = "", + // Line1 = "", + // Line2 = "", + // PostalCode = "", + // State = "" + // }, + Name = account.Name, + Phone = account.Phone, + // RegistrationNumber = "", + // TaxId = "", + // VatId = "", + // Verification = new AccountCompanyVerificationOptions + // { + // Document = new AccountCompanyVerificationDocumentOptions + // { + // Back = "", //The back of a document returned by a file upload + // Front = "" //The front of a document returned by a file upload + // } + // } + }, + Metadata = new Dictionary() + { + { "AccountId", account.Id!.ToString() ?? string.Empty }, + }, + Capabilities = new AccountCapabilitiesOptions + { + CardPayments = new AccountCapabilitiesCardPaymentsOptions + { + Requested = true, + }, + Transfers = new AccountCapabilitiesTransfersOptions + { + Requested = true, + }, + // TODO: Figure out which cababilities (Payment_methods) account requested + } + }; + try + { + var account = await _accountService.CreateAsync(options, cancellationToken: cancellationToken); + return account.Id; + } + catch (StripeException ex) + { + return Result.Failure(ex.Message); + } + } + + + async Task> SetStripeAccount(string stripeAccountId) + { + account.StripeAccount = stripeAccountId; + + _context.Accounts.Update(account); + await _context.SaveChangesAsync(); + + return Result.Success(stripeAccountId); + } + } + + + + public async Task SetActiveStripeAccount(string stripeAccountId, int memberId, CancellationToken cancellationToken) + { + var targetMember = await _context.Members + .SingleAsync(m => m.Id == memberId, cancellationToken); + + targetMember.ActiveStripeId = stripeAccountId; + targetMember.Modified = DateTime.UtcNow; + + _context.Members.Update(targetMember); + await _context.SaveChangesAsync(cancellationToken); + + return Result.Success(); + } + + public async Task> Retrieve(MemberRequest request, CancellationToken cancellationToken) { var stripeAccount = await _context.StripeAccounts diff --git a/TipCatDotNet.Api/TipCatDotNet.Api.xml b/TipCatDotNet.Api/TipCatDotNet.Api.xml index 2bc8c69..0ab7b6c 100644 --- a/TipCatDotNet.Api/TipCatDotNet.Api.xml +++ b/TipCatDotNet.Api/TipCatDotNet.Api.xml @@ -187,6 +187,15 @@ Change request + + + Updates member's active stripe account. + + Target member ID + Target account ID + Type of active stripe account + + Gets payment details by member code. diff --git a/TipCatDotNet.ApiTests/AccountServiceTests.cs b/TipCatDotNet.ApiTests/AccountServiceTests.cs index 132c8d1..7e37d7a 100644 --- a/TipCatDotNet.ApiTests/AccountServiceTests.cs +++ b/TipCatDotNet.ApiTests/AccountServiceTests.cs @@ -9,6 +9,7 @@ using TipCatDotNet.Api.Data.Models.HospitalityFacility; using TipCatDotNet.Api.Models.HospitalityFacilities; using TipCatDotNet.Api.Services.HospitalityFacilities; +using TipCatDotNet.Api.Services.Payments; using TipCatDotNet.ApiTests.Utils; using Microsoft.EntityFrameworkCore; using TipCatDotNet.Api.Services; @@ -35,13 +36,19 @@ public AccountServiceTests() .ReturnsAsync(new List()); _facilityService = facilityServiceMock.Object; + + var stripeAccountServiceMock = new Mock(); + stripeAccountServiceMock.Setup(c => c.AddForAccountAndManager(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success()); + + _stripeAccountService = stripeAccountServiceMock.Object; } [Fact] public async Task Add_should_not_add_account_when_member_has_one() { - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure) = await service.Add(new MemberContext(1, "hash", 1, string.Empty), new AccountRequest()); @@ -54,7 +61,7 @@ public async Task Add_should_not_add_account_when_name_is_not_specified() { var accountRequest = new AccountRequest(null, string.Empty, string.Empty); var memberContext = new MemberContext(1, "hash", null, string.Empty); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure) = await service.Add(memberContext, accountRequest); @@ -67,7 +74,7 @@ public async Task Add_should_not_add_account_when_address_is_not_specified() { var accountRequest = new AccountRequest(null, string.Empty, "Tipcat.net"); var memberContext = new MemberContext(1, "hash", null, string.Empty); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure) = await service.Add(memberContext, accountRequest); @@ -80,7 +87,7 @@ public async Task Add_should_not_add_account_when_phone_and_email_are_not_specif { var accountRequest = new AccountRequest(null, "Dubai, Saraya Avenue Building, B2, 205", "Tipcat.net"); var memberContext = new MemberContext(1, "hash", null, string.Empty); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure) = await service.Add(memberContext, accountRequest); @@ -93,7 +100,7 @@ public async Task Add_should_add_account_when_phone_is_specified() { var request = new AccountRequest(null, "Dubai, Saraya Avenue Building, B2, 205", "Tipcat.net", null, null, "+8 (800) 2000 500"); var memberContext = new MemberContext(1, "hash", null, string.Empty); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure, response) = await service.Add(memberContext, request); @@ -111,7 +118,7 @@ public async Task Add_should_add_account_when_email_is_specified() { var request = new AccountRequest(null, "Dubai, Saraya Avenue Building, B2, 205", "Tipcat.net", email: "kirill.taran@tipcat.net"); var memberContext = new MemberContext(1, "hash", null, string.Empty); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure, response) = await service.Add(memberContext, request); @@ -129,11 +136,11 @@ public async Task Add_should_add_account() { var request = new AccountRequest(null, "Dubai, Saraya Avenue Building, B2, 205", "Tipcat.net", null, null, "+8 (800) 2000 500"); var memberContext = new MemberContext(1, "hash", null, "kirill.taran@tipcat.net"); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure, response) = await service.Add(memberContext, request); - Assert.False(isFailure); + Assert.False(isFailure); Assert.Equal(request.Name, response.Name); Assert.Equal(request.Address, response.Address); Assert.Equal(memberContext.Email, response.Email); @@ -161,8 +168,8 @@ public async Task Add_should_create_default_facility() return new List(); })); - - var service = new AccountService(_aetherDbContext, _memberContextCacheService, facilityServiceMock.Object); + + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, facilityServiceMock.Object); var (_, isFailure, response) = await service.Add(memberContext, request); var defaultFacility = await _aetherDbContext.Facilities @@ -179,7 +186,7 @@ public async Task Get_should_not_get_account_when_context_has_no_account_ids() { const int accountId = 1; var memberContext = new MemberContext(1, "hash", null, string.Empty); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure) = await service.Get(memberContext, accountId); @@ -192,7 +199,7 @@ public async Task Get_should_not_get_account_when_member_has_no_access_to_accoun { const int accountId = 1; var memberContext = new MemberContext(1, "hash", 0, string.Empty); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure) = await service.Get(memberContext, accountId); @@ -205,7 +212,7 @@ public async Task Get_should_not_get_account_when_account_deactivated() { const int accountId = 1; var memberContext = new MemberContext(1, "hash", 1, string.Empty); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure) = await service.Get(memberContext, accountId); @@ -218,7 +225,7 @@ public async Task Get_should_get_account() { const int accountId = 2; var memberContext = new MemberContext(1, "hash", accountId, string.Empty); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, _, accountInfo) = await service.Get(memberContext, accountId); @@ -237,7 +244,7 @@ public async Task Get_should_get_account() public async Task Update_should_not_update_if_account_request_has_no_id() { var memberContext = new MemberContext(1, "hash", null, string.Empty); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure) = await service.Update(memberContext, new AccountRequest()); @@ -252,7 +259,7 @@ public async Task Update_should_not_update_if_request_id_and_context_id_do_not_m { var memberContext = new MemberContext(1, "hash", accountId, string.Empty); var accountRequest = new AccountRequest(2, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure) = await service.Update(memberContext, accountRequest); @@ -266,7 +273,7 @@ public async Task Update_should_not_update_if_account_name_is_empty() const int accountId = 2; var memberContext = new MemberContext(1, "hash", accountId, string.Empty); var accountRequest = new AccountRequest(accountId, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure) = await service.Update(memberContext, accountRequest); @@ -280,7 +287,7 @@ public async Task Update_should_not_update_if_account_address_is_empty() const int accountId = 2; var memberContext = new MemberContext(1, "hash", accountId, string.Empty); var accountRequest = new AccountRequest(accountId, string.Empty, "Tipcat.net", string.Empty, string.Empty, string.Empty); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure) = await service.Update(memberContext, accountRequest); @@ -294,7 +301,7 @@ public async Task Update_should_not_update_if_account_phone_is_empty() const int accountId = 2; var memberContext = new MemberContext(1, "hash", accountId, string.Empty); var accountRequest = new AccountRequest(accountId, "Dubai, Saraya Avenue Building, B2, 205", "Tipcat.net", string.Empty, string.Empty, string.Empty); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, isFailure) = await service.Update(memberContext, accountRequest); @@ -312,7 +319,7 @@ public async Task Update_should_update_account() var memberContext = new MemberContext(1, "hash", accountId, string.Empty); var accountRequest = new AccountRequest(accountId, address, name, string.Empty, string.Empty, phone); - var service = new AccountService(_aetherDbContext, _memberContextCacheService, _facilityService); + var service = new AccountService(_aetherDbContext, _stripeAccountService, _memberContextCacheService, _facilityService); var (_, _, account) = await service.Update(memberContext, accountRequest); @@ -370,4 +377,5 @@ public async Task Update_should_update_account() private readonly AetherDbContext _aetherDbContext; private readonly IMemberContextCacheService _memberContextCacheService; private readonly IFacilityService _facilityService; + private readonly IStripeAccountService _stripeAccountService; } \ No newline at end of file diff --git a/TipCatDotNet.ApiTests/MemberServiceTests.cs b/TipCatDotNet.ApiTests/MemberServiceTests.cs index b771733..72928b1 100644 --- a/TipCatDotNet.ApiTests/MemberServiceTests.cs +++ b/TipCatDotNet.ApiTests/MemberServiceTests.cs @@ -833,12 +833,20 @@ public async Task Update_should_update_member() new Account { Id = 1, - IsActive = false + IsActive = false, + StripeAccount = "acc_1" }, new Account { Id = 2, - IsActive = true + IsActive = true, + StripeAccount = "acc_2" + }, + new Account + { + Id = 5, + IsActive = true, + StripeAccount = "acc_7" } };