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:
Stevan Freeborn
2026-03-09 04:29:01 -05:00
parent 48e95e5242
commit a9f1ced0d5
62 changed files with 3625 additions and 68 deletions
@@ -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();
}
}