feat: add Plaid transaction handling and async queue processing
- introduced new migration to add Cursor column to AccountMetadata. - updated AppDbContext model snapshot to reflect changes in the database schema. - implemented async queue processing with ChannelAsyncQueue and AsyncQueueHostedService. - created Plaid transaction handlers for added, modified, and removed transactions. - developed PlaidTransactionService and PlaidTransactionSyncer for syncing transactions. - added SyncUpdatesProcessor to handle sync updates from Plaid. - updated tests to accommodate changes in institution and account metadata handling.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
using Going.Plaid;
|
||||
using Going.Plaid.Entity;
|
||||
|
||||
namespace FiscalOS.API.Transactions.FireWebhook;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
private const string Route = "/fire-webhook";
|
||||
|
||||
public static RouteHandlerBuilder MapFireWebhookEndpoint(this RouteGroupBuilder groupBuilder)
|
||||
{
|
||||
return groupBuilder.MapPost(Route, HandleAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
HttpContext httpContext,
|
||||
[FromBody] Request request,
|
||||
[FromServices] AppDbContext appDbContext,
|
||||
[FromServices] IEncryptor encryptor,
|
||||
[FromServices] PlaidClient plaidClient,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var userId = httpContext.GetUserId();
|
||||
|
||||
var user = await appDbContext.Users
|
||||
.Include(u => u.Accounts.Where(a => a.Id.ToString() == request.AccountId))
|
||||
.ThenInclude(static a => a.Institution!)
|
||||
.ThenInclude(static i => i.Metadata)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (user.Accounts.Any() is false)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var plaidMetadata = (PlaidInstitutionMetadata?)user.Accounts.First().Institution?.Metadata;
|
||||
|
||||
if (plaidMetadata is null)
|
||||
{
|
||||
return Results.InternalServerError("No metadata associated with account");
|
||||
}
|
||||
|
||||
var accessToken = await encryptor.DecryptAsyncFor(user, plaidMetadata.EncryptedAccessToken, ct);
|
||||
|
||||
var fireEventResponse = await plaidClient.SandboxItemFireWebhookAsync(new()
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
WebhookType = SandboxItemFireWebhookRequestWebhookTypeEnum.Transactions,
|
||||
WebhookCode = SandboxItemFireWebhookRequestWebhookCodeEnum.SyncUpdatesAvailable,
|
||||
});
|
||||
|
||||
if (fireEventResponse.IsSuccessStatusCode is false)
|
||||
{
|
||||
return Results.InternalServerError(fireEventResponse.Error?.ErrorMessage);
|
||||
}
|
||||
|
||||
return Results.Ok(new { fireEventResponse.RequestId, fireEventResponse.WebhookFired });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace FiscalOS.API.Transactions.FireWebhook;
|
||||
|
||||
public sealed record Request(
|
||||
string AccountId,
|
||||
string WebhookType,
|
||||
string WebhookCode
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace FiscalOS.API.Transactions;
|
||||
|
||||
internal static class TransactionsExtensions
|
||||
{
|
||||
private const string RouteGroupPrefix = "/transactions";
|
||||
|
||||
public static RouteGroupBuilder MapTransactionsGroup(this WebApplication app)
|
||||
{
|
||||
var transactionsGroup = app.MapGroup(RouteGroupPrefix)
|
||||
.RequireAuthorization();
|
||||
|
||||
if (app.Environment.IsProduction() is false)
|
||||
{
|
||||
transactionsGroup.MapFireWebhookEndpoint();
|
||||
}
|
||||
|
||||
transactionsGroup.MapWebhookEndpoint().AllowAnonymous();
|
||||
|
||||
return transactionsGroup;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace FiscalOS.API.Transactions.Webhook;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
private const string Route = "/webhook";
|
||||
|
||||
public static RouteHandlerBuilder MapWebhookEndpoint(this RouteGroupBuilder groupBuilder)
|
||||
{
|
||||
return groupBuilder.MapPost(Route, HandleAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
[FromBody] WebhookBase request,
|
||||
[FromServices] ILogger<Program> logger,
|
||||
[FromServices] IAsyncQueue<SyncUpdatesQueueItem> queue,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Received {WebhookType} webhook with {WebhookCode} payload",
|
||||
request.WebhookType,
|
||||
request.WebhookCode
|
||||
);
|
||||
|
||||
switch (request)
|
||||
{
|
||||
case SyncUpdatesAvailableWebhook webhook:
|
||||
await queue.EnqueueAsync(new(webhook.ItemId), ct);
|
||||
logger.LogInformation(
|
||||
"Enqueued {WebhookType} webhook with {WebhookCode} payload",
|
||||
request.WebhookType,
|
||||
request.WebhookCode
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return Results.Ok();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user